diff --git a/.gitmodules b/.gitmodules index 1f2488f..24909ad 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "tests/test_data/lib/forge-std"] path = tests/test_data/lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "tests/test_data/lib/properties"] + path = tests/test_data/lib/properties + url = https://github.com/crytic/properties +[submodule "tests/test_data/lib/solmate"] + path = tests/test_data/lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8c75b1..03d7626 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,12 @@ Below is a rough outline of fuzz-utils's design: ```text . -├── fuzzers # Contains supported fuzzer classes that parse and generate the test files -├── templates # String templates used for test generation +├── generate # Classes related to the `generate` command +| └── fuzzers # Supported fuzzer classes +├── parsing # Contains the main parser logic +| └── commands # Flags and execution logic per supported subparser +├── template # Classes related to the `template` command +├── templates # Common templates such as the default config and templates for test and harness generation ├── utils # Utility functions ├── main.py # Main entry point └── ... diff --git a/Makefile b/Makefile index c776d76..fadabc9 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,10 @@ reformat: test tests: $(VENV)/pyvenv.cfg . $(VENV_BIN)/activate && \ solc-select use 0.8.19 --always-install && \ - pytest $(T) $(TEST_ARGS) + cd tests/test_data && \ + forge install && \ + cd ../.. && \ + pytest --ignore tests/test_data/lib $(T) $(TEST_ARGS) .PHONY: package package: $(VENV)/pyvenv.cfg diff --git a/README.md b/README.md index 106fbae..4740e7e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ Slither Static Analysis Framework Logo -# Automated tool for generating Foundry unit tests from smart contract fuzzer failed properties +# Automated utility tooling for smart contract fuzzers -`fuzz-utils` is a Python tool that generates unit tests from [Echidna](https://github.com/crytic/echidna) and [Medusa](https://github.com/crytic/medusa/tree/master) failed properties, using the generated reproducer files. It uses [Slither](https://github.com/crytic/slither) for determining types and jinja2 for generating the test files using string templates. +`fuzz-utils` is a set of Python tools that aim to improve the developer experience when using smart contract fuzzing. +The tools include: +- automatically generate unit tests from [Echidna](https://github.com/crytic/echidna) and [Medusa](https://github.com/crytic/medusa/tree/master) failed properties, using the generated reproducer files. +- automatically generate a Echidna/Medusa compatible fuzzing harness. + +`fuzz-utils` uses [Slither](https://github.com/crytic/slither) for determining types and `jinja2` for generating the test files using string templates. **Disclaimer**: Please note that `fuzz-utils` is **under development**. Currently, not all Solidity types are supported and some types (like `bytes*`, and `string`) might be improperly decoded from the corpora call sequences. We are investigating a better corpus format that will ease the creation of unit tests. ## Features `fuzz-utils` provides support for: -- ✔️ Generating Foundry unit tests from the fuzzer corpus of single entry point fuzzing harnesses -- ✔️ Medusa and Echidna corpora -- ✔️ Solidity types: `bool`,`uint*`, `int*`, `address`, `struct`, `enum`, single-dimensional fixed-size arrays and dynamic arrays, multi-dimensional fixed-size arrays. +- ✔️ Generating Foundry unit tests from the fuzzer corpus of single entry point fuzzing harnesses. +- ✔️ Generating fuzzing harnesses, `Actor` contracts, and templated `attack` contracts to ease fuzzing setup. +- ✔️ Supports Medusa and Echidna corpora +- ✔️ Test generation supports Solidity types: `bool`,`uint*`, `int*`, `address`, `struct`, `enum`, single-dimensional fixed-size arrays and dynamic arrays, multi-dimensional fixed-size arrays. Multi-dimensional dynamic arrays, function pointers, and other more complex types are in the works, but are currently not supported. ## Installation and pre-requisites @@ -23,24 +29,110 @@ pip install fuzz-utils These commands will install all the Python libraries and tools required to run `fuzz-utils`. However, it won't install Echidna or Medusa, so you will need to download and install the latest version yourself from its official releases ([Echidna](https://github.com/crytic/echidna/releases), [Medusa](https://github.com/crytic/medusa/releases)). -## Example +## Tools +The available tool commands are: +- [`init`](#initializing-a-configuration-file) - Initializes a configuration file +- [`generate`](#generating-unit-tests) - generates unit tests from a corpus +- [`template`](#generating-fuzzing-harnesses) - generates a fuzzing harness + +### Generating unit tests + +The `generate` command is used to generate Foundry unit tests from Echidna or Medusa corpus call sequences. + +**Command-line options:** +- `compilation_path`: The path to the Solidity file or Foundry directory +- `-cd`/`--corpus-dir` `path_to_corpus_dir`: The path to the corpus directory relative to the working directory. +- `-c`/`--contract` `contract_name`: The name of the target contract. +- `-td`/`--test-directory` `path_to_test_directory`: The path to the test directory relative to the working directory. +- `-i`/`--inheritance-path` `relative_path_to_contract`: The relative path from the test directory to the contract (used for inheritance). +- `-f`/`--fuzzer` `fuzzer_name`: The name of the fuzzer, currently supported: `echidna` and `medusa` +- `--named-inputs`: Includes function input names when making calls +- `--config`: Path to the fuzz-utils config JSON file +- `--all-sequences`: Include all corpus sequences when generating unit tests. + +**Example** In order to generate a test file for the [BasicTypes.sol](tests/test_data/src/BasicTypes.sol) contract, based on the Echidna corpus reproducers for this contract ([corpus-basic](tests/test_data/echidna-corpora/corpus-basic/)), we need to `cd` into the `tests/test_data` directory which contains the Foundry project and run the command: ```bash -fuzz-utils ./src/BasicTypes.sol --corpus-dir echidna-corpora/corpus-basic --contract "BasicTypes" --test-directory "./test/" --inheritance-path "../src/" --fuzzer echidna +fuzz-utils generate ./src/BasicTypes.sol --corpus-dir echidna-corpora/corpus-basic --contract "BasicTypes" --test-directory "./test/" --inheritance-path "../src/" --fuzzer echidna ``` -Running this command should generate a `BasicTypes_Echidna_Test.sol` file in the [tests](/tests/test_data/test/) directory of the Foundry project. +Running this command should generate a `BasicTypes_Echidna_Test.sol` file in the [test](/tests/test_data/test/) directory of the Foundry project. -## Command-line options +### Generating fuzzing harnesses -Additional options are available for the script: +The `template` command is used to generate a fuzzing harness. The harness can include multiple `Actor` contracts which are used as proxies for user actions, as well as `attack` contracts which can be selected from a set of premade contracts that perform certain common attack scenarios. -- `-cd`/`--corpus-dir` `path_to_corpus_dir`: The path to the corpus directory relative to the working directory. -- `-c`/`--contract` `contract_name`: The name of the contract. -- `-td`/`--test-directory` `path_to_test_directory`: The path to the test directory relative to the working directory. -- `-i`/`--inheritance-path` `relative_path_to_contract`: The relative path from the test directory to the contract (used for inheritance). -- `-f`/`--fuzzer` `fuzzer_name`: The name of the fuzzer, currently supported: `echidna` and `medusa` +**Command-line options:** +- `compilation_path`: The path to the Solidity file or Foundry directory +- `-n`/`--name` `name: str`: The name of the fuzzing harness. +- `-c`/`--contracts` `target_contracts: list`: The name of the target contract. +- `-o`/`--output-dir` `output_directory: str`: Output directory name. By default it is `fuzzing` +- `--config`: Path to the `fuzz-utils` config JSON file + +**Example** + +In order to generate a fuzzing harness for the [BasicTypes.sol](tests/test_data/src/BasicTypes.sol) contract, we need to `cd` into the `tests/test_data/` directory which contains the Foundry project and run the command: +```bash +fuzz-utils template ./src/BasicType.sol --name "DefaultHarness" --contracts BasicTypes +``` + +Running this command should generate the directory structure in [tests/test_data/test/fuzzing](tests/test_data/test/fuzzing), which contains the fuzzing harness [DefaultHarness](tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol) and the Actor contract [DefaultActor](tests/test_data/test/fuzzing/actors/ActorDefault.sol). + +## Utilities + +### Initializing a configuration file + +The `init` command can be used to initialize a default configuration file in the project root. + +**Configuration file:** +Using the configuration file allows for more granular control than just using the command-line options. Valid configuration options are listed below: +```json +{ + "generate": { + "targetContract": "BasicTypes", // The Echidna/Medusa fuzzing harness + "compilationPath": "./src/BasicTypes", // Path to the file or Foundry directory + "corpusDir": "echidna-corpora/corpus-basic", // Path to the corpus directory + "fuzzer": "echidna", // `echidna` | `medusa` + "testsDir": "./test/", // Path to the directory where the tests will be generated + "inheritancePath": "../src/", // Relative path from the testing directory to the contracts + "namedInputs": false, // True | False, whether to include function input names when making calls + "allSequences": false, // True | False, whether to generate tests for the entire corpus (including non-failing sequences) + }, + "template": { + "name": "DefaultHarness", // The name of the fuzzing harness that will be generated + "targets": ["BasicTypes"], // The contracts to be included in the fuzzing harness + "outputDir": "./test/fuzzing", // The output directory where the files and directories will be saved + "compilationPath": ".", // The path to the Solidity file (if single target) or Foundry directory + "actors": [ // At least one actor is required. If the array is empty, the DefaultActor which wraps all of the functions from the target contracts will be generated + { + "name": "Default", // The name of the Actor contract, saved as `Actor{name}` + "targets": ["BasicTypes"], // The list of contracts that the Actor can interact with + "number": 3, // The number of instances of this Actor that will be used in the harness + "filters": { // Used to filter functions so that only functions that fulfill certain criteria are included + "strict": false, // If `true`, only functions that fulfill *all* the criteria will be included. If `false`, functions that fulfill *any* criteria will be included + "onlyModifiers": [], // List of modifiers to include + "onlyPayable": false, // If `true`, only `payable` functions will be included. If `false`, both payable and non-payable functions will be included + "onlyExternalCalls": [], // Only include functions that make a certain external call. E.g. [`transferFrom`] + }, + } + ], + "attacks": [ // A list of premade attack contracts to include. + { + "name": "Deposit", // The name of the attack contract. + "targets": ["BasicTypes"], // The list of contracts that the attack contract can interact with + "number": 1, // The number of instances of this attack contract that will be used in the harness + "filters": { // Used to filter functions so that only functions that fulfill certain criteria are included + "strict": false, // If `true`, only functions that fulfill *all* the criteria will be included. If `false`, functions that fulfill *any* criteria will be included + "onlyModifiers": [], // List of modifiers to include + "onlyPayable": false, // If `true`, only `payable` functions will be included. If `false`, both payable and non-payable functions will be included + "onlyExternalCalls": [], // Only include functions that make a certain external call. E.g. [`transferFrom`] + }, + } + ], + }, +} +``` ## Contributing For information about how to contribute to this project, check out the [CONTRIBUTING](CONTRIBUTING.md) guidelines. diff --git a/fuzz_utils/generate/FoundryTest.py b/fuzz_utils/generate/FoundryTest.py new file mode 100644 index 0000000..66e0ae3 --- /dev/null +++ b/fuzz_utils/generate/FoundryTest.py @@ -0,0 +1,99 @@ +"""The FoundryTest class that handles generation of unit tests from call sequences""" +import os +import sys +import json +from typing import Any +import jinja2 + +from slither import Slither +from slither.core.declarations.contract import Contract +from fuzz_utils.utils.crytic_print import CryticPrint + +from fuzz_utils.generate.fuzzers.Medusa import Medusa +from fuzz_utils.generate.fuzzers.Echidna import Echidna +from fuzz_utils.templates.foundry_templates import templates + + +class FoundryTest: # pylint: disable=too-many-instance-attributes + """ + Handles the generation of Foundry test files + """ + + def __init__( + self, + config: dict, + slither: Slither, + fuzzer: Echidna | Medusa, + ) -> None: + self.inheritance_path = config["inheritancePath"] + self.target_name = config["targetContract"] + self.corpus_path = config["corpusDir"] + self.test_dir = config["testsDir"] + self.all_sequences = config["allSequences"] + self.slither = slither + self.target = self.get_target_contract() + self.fuzzer = fuzzer + + def get_target_contract(self) -> Contract: + """Gets the Slither Contract object for the specified contract file""" + contracts = self.slither.get_contract_from_name(self.target_name) + # Loop in case slither fetches multiple contracts for some reason (e.g., similar names?) + for contract in contracts: + if contract.name == self.target_name: + return contract + + # TODO throw error if no contract found + sys.exit(-1) + + def create_poc(self) -> str: + """Takes in a directory path to the echidna reproducers and generates a test file""" + + file_list: list[dict[str, Any]] = [] + tests_list = [] + dir_list = [] + if self.all_sequences: + dir_list = self.fuzzer.corpus_dirs + else: + dir_list = [self.fuzzer.reproducer_dir] + + # 1. Iterate over each directory and reproducer file (open it) + for directory in dir_list: + for entry in os.listdir(directory): + full_path = os.path.join(directory, entry) + + if os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as file: + file_list.append({"path": full_path, "content": json.load(file)}) + except Exception: # pylint: disable=broad-except + print(f"Fail on {full_path}") + + # 2. Parse each reproducer file and add each test function to the functions list + for idx, file_obj in enumerate(file_list): + try: + tests_list.append( + self.fuzzer.parse_reproducer(file_obj["path"], file_obj["content"], idx) + ) + except Exception: # pylint: disable=broad-except + print(f"Parsing fail on {file_obj['content']}: index: {idx}") + + # 4. Generate the test file + template = jinja2.Template(templates["CONTRACT"]) + write_path = f"{self.test_dir}{self.target_name}" + inheritance_path = f"{self.inheritance_path}{self.target_name}" + + # 5. Save the test file + test_file_str = template.render( + file_path=f"{inheritance_path}.sol", + target_name=self.target_name, + amount=0, + tests=tests_list, + fuzzer=self.fuzzer.name, + ) + with open(f"{write_path}_{self.fuzzer.name}_Test.t.sol", "w", encoding="utf-8") as outfile: + outfile.write(test_file_str) + CryticPrint().print_success( + f"Generated a test file in {write_path}_{self.fuzzer.name}_Test.t.sol" + ) + + return test_file_str diff --git a/fuzz_utils/fuzzers/__init__.py b/fuzz_utils/generate/__init__.py similarity index 100% rename from fuzz_utils/fuzzers/__init__.py rename to fuzz_utils/generate/__init__.py diff --git a/fuzz_utils/fuzzers/Echidna.py b/fuzz_utils/generate/fuzzers/Echidna.py similarity index 94% rename from fuzz_utils/fuzzers/Echidna.py rename to fuzz_utils/generate/fuzzers/Echidna.py index ef4426b..05488a4 100644 --- a/fuzz_utils/fuzzers/Echidna.py +++ b/fuzz_utils/generate/fuzzers/Echidna.py @@ -18,6 +18,7 @@ from fuzz_utils.utils.error_handler import handle_exit +# pylint: disable=too-many-instance-attributes class Echidna: """ Handles the generation of Foundry test files from Echidna reproducers @@ -33,6 +34,7 @@ def __init__( self.reproducer_dir = f"{corpus_path}/reproducers" self.corpus_dirs = [f"{corpus_path}/coverage", self.reproducer_dir] self.named_inputs = named_inputs + self.declared_variables: set[tuple[str, str]] = set() def get_target_contract(self) -> Contract: """Finds and returns Slither Contract""" @@ -51,17 +53,26 @@ def parse_reproducer(self, file_path: str, calls: Any, index: int) -> str: call_list = [] end = len(calls) - 1 function_name = "" + has_low_level_call: bool = False + + # before each test case, we clear the declared variables, as those are locals + self.declared_variables = set() + # 1. For each object in the list process the call object and add it to the call list for idx, call in enumerate(calls): call_str, fn_name = self._parse_call_object(call) call_list.append(call_str) + has_low_level_call = has_low_level_call or ("(success, " in call_str) if idx == end: function_name = fn_name + "_" + str(index) # 2. Generate the test string and return it template = jinja2.Template(templates["TEST"]) return template.render( - function_name=function_name, call_list=call_list, file_path=file_path + function_name=function_name, + call_list=call_list, + file_path=file_path, + has_low_level_call=has_low_level_call, ) # pylint: disable=too-many-locals,too-many-branches @@ -119,7 +130,6 @@ def _parse_call_object(self, call_dict: dict[Any, Any]) -> tuple[str, str]: for idx, input_param in enumerate(slither_entry_point.parameters): call_definition[idx] = input_param.name + ": " + call_definition[idx] parameters_str = "{" + ", ".join(call_definition) + "}" - print(parameters_str) else: parameters_str = ", ".join(call_definition) @@ -307,5 +317,13 @@ def _get_memarr( input_type = input_parameter.type name = f"dyn{input_type}Arr_{index}" - declaration = f"{input_type}[] memory {name} = new {input_type}[]({length});\n" + + # If the variable was already declared, just assign the new value + if (input_type, name) in self.declared_variables: + declaration = f"{name} = new {input_type}[]({length});\n" + else: + declaration = f"{input_type}[] memory {name} = new {input_type}[]({length});\n" + + self.declared_variables.add((input_type, name)) + return name, declaration diff --git a/fuzz_utils/fuzzers/Medusa.py b/fuzz_utils/generate/fuzzers/Medusa.py similarity index 92% rename from fuzz_utils/fuzzers/Medusa.py rename to fuzz_utils/generate/fuzzers/Medusa.py index 94a01c8..f3a0ff1 100644 --- a/fuzz_utils/fuzzers/Medusa.py +++ b/fuzz_utils/generate/fuzzers/Medusa.py @@ -38,6 +38,7 @@ def __init__( self.reproducer_dir, ] self.named_inputs = named_inputs + self.declared_variables: set[tuple[str, str]] = set() def get_target_contract(self) -> Contract: """Finds and returns Slither Contract""" @@ -56,15 +57,24 @@ def parse_reproducer(self, file_path: str, calls: Any, index: int) -> str: call_list = [] end = len(calls) - 1 function_name = "" + has_low_level_call: bool = False + + # before each test case, we clear the declared variables, as those are locals + self.declared_variables = set() + for idx, call in enumerate(calls): call_str, fn_name = self._parse_call_object(call) call_list.append(call_str) + has_low_level_call = has_low_level_call or ("(success, " in call_str) if idx == end: function_name = fn_name + "_" + str(index) template = jinja2.Template(templates["TEST"]) return template.render( - function_name=function_name, call_list=call_list, file_path=file_path + function_name=function_name, + call_list=call_list, + file_path=file_path, + has_low_level_call=has_low_level_call, ) # 1. Take a reproducer list and create a test file based on the name of the last function of the list e.g. test_auto_$function_name # 2. For each object in the list process the call object and add it to the call list @@ -192,8 +202,8 @@ def _match_type(self, parameter: Any, values: Any) -> tuple[str, str, str]: # TODO make it work with multidim dynamic arrays if values: dyn_length = len(values) - array_type: str = "" + if isinstance( parameter.type.type, (Structure | StructureContract | Enum | EnumContract), @@ -201,7 +211,13 @@ def _match_type(self, parameter: Any, values: Any) -> tuple[str, str, str]: array_type = parameter.type.type.name else: array_type = parameter.type.type - var_def += f"{array_type}[] memory {parameter.name} = new {parameter.type.type}[]({dyn_length});\n" + # If dynamic array of the same name and type was already declared, reuse it. Else, declare a new one. + if (array_type, parameter.name) in self.declared_variables: + var_def += f"{parameter.name} = new {array_type}[]({dyn_length});\n" + else: + var_def += f"{array_type}[] memory {parameter.name} = new {array_type}[]({dyn_length});\n" + + self.declared_variables.add((array_type, parameter.name)) for idx, value in enumerate(values): _, matched_value, _ = self._match_type(parameter.type, value) diff --git a/fuzz_utils/generate/fuzzers/__init__.py b/fuzz_utils/generate/fuzzers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index 892f182..4409b5e 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -1,199 +1,27 @@ """ Generates a test file from Echidna reproducers """ -import os import sys -import json import argparse -from typing import Any -import jinja2 - from pkg_resources import require +from fuzz_utils.parsing.parser import define_subparsers, run_command -from slither import Slither -from slither.core.declarations.contract import Contract -from fuzz_utils.utils.crytic_print import CryticPrint -from fuzz_utils.templates.foundry_templates import templates -from fuzz_utils.fuzzers.Medusa import Medusa -from fuzz_utils.fuzzers.Echidna import Echidna -from fuzz_utils.utils.error_handler import handle_exit - - -class FoundryTest: # pylint: disable=too-many-instance-attributes - """ - Handles the generation of Foundry test files - """ - - def __init__( - self, - inheritance_path: str, - target_name: str, - corpus_path: str, - test_dir: str, - slither: Slither, - fuzzer: Echidna | Medusa, - all_sequences: bool, - ) -> None: - self.inheritance_path = inheritance_path - self.target_name = target_name - self.corpus_path = corpus_path - self.test_dir = test_dir - self.slither = slither - self.target = self.get_target_contract() - self.fuzzer = fuzzer - self.all_sequences = all_sequences - - def get_target_contract(self) -> Contract: - """Gets the Slither Contract object for the specified contract file""" - contracts = self.slither.get_contract_from_name(self.target_name) - # Loop in case slither fetches multiple contracts for some reason (e.g., similar names?) - for contract in contracts: - if contract.name == self.target_name: - return contract - - # TODO throw error if no contract found - sys.exit(-1) - - def create_poc(self) -> str: - """Takes in a directory path to the echidna reproducers and generates a test file""" - - file_list: list[dict[str, Any]] = [] - tests_list = [] - dir_list = [] - if self.all_sequences: - dir_list = self.fuzzer.corpus_dirs - else: - dir_list = [self.fuzzer.reproducer_dir] - - # 1. Iterate over each directory and reproducer file (open it) - for directory in dir_list: - for entry in os.listdir(directory): - full_path = os.path.join(directory, entry) - - if os.path.isfile(full_path): - try: - with open(full_path, "r", encoding="utf-8") as file: - file_list.append({"path": full_path, "content": json.load(file)}) - except Exception: # pylint: disable=broad-except - print(f"Fail on {full_path}") - - # 2. Parse each reproducer file and add each test function to the functions list - for idx, file_obj in enumerate(file_list): - try: - tests_list.append( - self.fuzzer.parse_reproducer(file_obj["path"], file_obj["content"], idx) - ) - except Exception: # pylint: disable=broad-except - print(f"Parsing fail on {file_obj['content']}: index: {idx}") - - # 4. Generate the test file - template = jinja2.Template(templates["CONTRACT"]) - write_path = f"{self.test_dir}{self.target_name}" - inheritance_path = f"{self.inheritance_path}{self.target_name}" - - # 5. Save the test file - test_file_str = template.render( - file_path=f"{inheritance_path}.sol", - target_name=self.target_name, - amount=0, - tests=tests_list, - fuzzer=self.fuzzer.name, - ) - with open(f"{write_path}_{self.fuzzer.name}_Test.t.sol", "w", encoding="utf-8") as outfile: - outfile.write(test_file_str) - CryticPrint().print_success( - f"Generated a test file in {write_path}_{self.fuzzer.name}_Test.t.sol" - ) - - return test_file_str - - +# pylint: disable=too-many-locals,too-many-statements def main() -> None: # type: ignore[func-returns-value] """The main entry point""" parser = argparse.ArgumentParser( prog="fuzz-utils", description="Generate test harnesses for Echidna failed properties." ) - parser.add_argument("file_path", help="Path to the Echidna test harness.") - parser.add_argument( - "-cd", "--corpus-dir", dest="corpus_dir", help="Path to the corpus directory", required=True - ) - parser.add_argument("-c", "--contract", dest="target_contract", help="Define the contract name") - parser.add_argument( - "-td", - "--test-directory", - dest="test_directory", - help="Define the directory that contains the Foundry tests.", - ) - parser.add_argument( - "-i", - "--inheritance-path", - dest="inheritance_path", - help="Define the relative path from the test directory to the directory src/contracts directory.", - ) - parser.add_argument( - "-f", - "--fuzzer", - dest="selected_fuzzer", - help="Define the fuzzer used. Valid inputs: 'echidna', 'medusa'", - ) parser.add_argument( "--version", help="displays the current version", version=require("fuzz-utils")[0].version, action="version", ) - parser.add_argument( - "--named-inputs", - dest="named_inputs", - help="Include function input names when making calls.", - default=False, - action="store_true", - ) - parser.add_argument( - "--all-sequences", - dest="all_sequences", - help="Include all corpus sequences when generating unit tests.", - default=False, - action="store_true", - ) - + subparsers = parser.add_subparsers(dest="command", help="sub-command help") + define_subparsers(subparsers) args = parser.parse_args() - - missing_args = [arg for arg, value in vars(args).items() if value is None] - if missing_args: + command_success: bool = run_command(args) + if not command_success: parser.print_help() - handle_exit(f"\n* Missing required arguments: {', '.join(missing_args)}") - - file_path = args.file_path - corpus_dir = args.corpus_dir - test_directory = args.test_directory - inheritance_path = args.inheritance_path - target_contract = args.target_contract - slither = Slither(file_path) - fuzzer: Echidna | Medusa - - match args.selected_fuzzer.lower(): - case "echidna": - fuzzer = Echidna(target_contract, corpus_dir, slither, args.named_inputs) - case "medusa": - fuzzer = Medusa(target_contract, corpus_dir, slither, args.named_inputs) - case _: - handle_exit( - f"\n* The requested fuzzer {args.selected_fuzzer} is not supported. Supported fuzzers: echidna, medusa." - ) - - CryticPrint().print_information( - f"Generating Foundry unit tests based on the {fuzzer.name} reproducers..." - ) - foundry_test = FoundryTest( - inheritance_path, - target_contract, - corpus_dir, - test_directory, - slither, - fuzzer, - args.all_sequences, - ) - foundry_test.create_poc() - CryticPrint().print_success("Done!") if __name__ == "__main__": diff --git a/fuzz_utils/parsing/__init__.py b/fuzz_utils/parsing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuzz_utils/parsing/commands/__init__.py b/fuzz_utils/parsing/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuzz_utils/parsing/commands/generate.py b/fuzz_utils/parsing/commands/generate.py new file mode 100644 index 0000000..86a943f --- /dev/null +++ b/fuzz_utils/parsing/commands/generate.py @@ -0,0 +1,116 @@ +"""Defines the flags and logic associated with the `generate` command""" +import json +from argparse import Namespace, ArgumentParser +from slither import Slither +from fuzz_utils.utils.crytic_print import CryticPrint +from fuzz_utils.generate.FoundryTest import FoundryTest +from fuzz_utils.generate.fuzzers.Medusa import Medusa +from fuzz_utils.generate.fuzzers.Echidna import Echidna +from fuzz_utils.utils.error_handler import handle_exit + + +def generate_flags(parser: ArgumentParser) -> None: + """The unit test generation parser flags""" + parser.add_argument( + "compilation_path", help="Path to the Echidna/Medusa test harness or Foundry directory." + ) + parser.add_argument( + "-cd", "--corpus-dir", dest="corpus_dir", help="Path to the corpus directory" + ) + parser.add_argument("-c", "--contract", dest="target_contract", help="Define the contract name") + parser.add_argument( + "-td", + "--test-directory", + dest="test_directory", + help="Define the directory that contains the Foundry tests.", + ) + parser.add_argument( + "-i", + "--inheritance-path", + dest="inheritance_path", + help="Define the relative path from the test directory to the directory src/contracts directory.", + ) + parser.add_argument( + "-f", + "--fuzzer", + dest="selected_fuzzer", + help="Define the fuzzer used. Valid inputs: 'echidna', 'medusa'", + ) + parser.add_argument( + "--named-inputs", + dest="named_inputs", + help="Include function input names when making calls.", + default=False, + action="store_true", + ) + parser.add_argument( + "--config", + dest="config", + help="Define the location of the config file.", + ) + parser.add_argument( + "--all-sequences", + dest="all_sequences", + help="Include all corpus sequences when generating unit tests.", + default=False, + action="store_true", + ) + + +def generate_command(args: Namespace) -> None: + """The execution logic of the `generate` command""" + config: dict = {} + # If the config file is defined, read it + if args.config: + with open(args.config, "r", encoding="utf-8") as readFile: + complete_config = json.load(readFile) + if "generate" in complete_config: + config = complete_config["generate"] + # Override the config with the CLI values + if args.compilation_path: + config["compilationPath"] = args.compilation_path + if args.test_directory: + config["testsDir"] = args.test_directory + if args.inheritance_path: + config["inheritancePath"] = args.inheritance_path + if args.selected_fuzzer: + config["fuzzer"] = args.selected_fuzzer.lower() + if args.corpus_dir: + config["corpusDir"] = args.corpus_dir + if args.target_contract: + config["targetContract"] = args.target_contract + if args.named_inputs: + config["namedInputs"] = args.named_inputs + else: + if "namedInputs" not in config: + config["namedInputs"] = False + if args.all_sequences: + config["allSequences"] = args.all_sequences + else: + if "allSequences" not in config: + config["allSequences"] = False + + CryticPrint().print_information("Running Slither...") + slither = Slither(args.file_path) + fuzzer: Echidna | Medusa + + match config["fuzzer"]: + case "echidna": + fuzzer = Echidna( + config["targetContract"], config["corpusDir"], slither, config["namedInputs"] + ) + case "medusa": + fuzzer = Medusa( + config["targetContract"], config["corpusDir"], slither, config["namedInputs"] + ) + case _: + handle_exit( + f"\n* The requested fuzzer {config['fuzzer']} is not supported. Supported fuzzers: echidna, medusa." + ) + + CryticPrint().print_information( + f"Generating Foundry unit tests based on the {fuzzer.name} reproducers..." + ) + foundry_test = FoundryTest(config, slither, fuzzer) + foundry_test.create_poc() + CryticPrint().print_success("Done!") diff --git a/fuzz_utils/parsing/commands/init.py b/fuzz_utils/parsing/commands/init.py new file mode 100644 index 0000000..f52cc47 --- /dev/null +++ b/fuzz_utils/parsing/commands/init.py @@ -0,0 +1,18 @@ +"""Defines the flags and logic associated with the `init` command""" +import json +from argparse import Namespace, ArgumentParser +from fuzz_utils.utils.crytic_print import CryticPrint +from fuzz_utils.templates.default_config import default_config + + +def init_flags(parser: ArgumentParser) -> None: # pylint: disable=unused-argument + """The `init` command flags""" + # No flags are defined for the `init` command + return None + + +def init_command(args: Namespace) -> None: # pylint: disable=unused-argument + """The execution logic of the `init` command""" + with open("fuzz-utils.json", "w", encoding="utf-8") as outfile: + outfile.write(json.dumps(default_config)) + CryticPrint().print_information("Initial config file saved to fuzz-utils.json") diff --git a/fuzz_utils/parsing/commands/template.py b/fuzz_utils/parsing/commands/template.py new file mode 100644 index 0000000..4c3940b --- /dev/null +++ b/fuzz_utils/parsing/commands/template.py @@ -0,0 +1,60 @@ +"""Defines the flags and logic associated with the `template` command""" +import os +import json +from argparse import Namespace, ArgumentParser +from slither import Slither +from fuzz_utils.template.HarnessGenerator import HarnessGenerator +from fuzz_utils.utils.crytic_print import CryticPrint +from fuzz_utils.utils.remappings import find_remappings + + +def template_flags(parser: ArgumentParser) -> None: + """The harness template generation parser flags""" + parser.add_argument("compilation_path", help="Path to the Solidity contract.") + parser.add_argument("-n", "--name", dest="name", help="Name of the harness contract.") + parser.add_argument( + "-c", + "--contracts", + dest="target_contracts", + nargs="+", + help="Define a list of target contracts for the harness.", + ) + parser.add_argument( + "-o", + "--output-dir", + dest="output_dir", + help="Define the output directory where the result will be saved.", + ) + parser.add_argument("--config", dest="config", help="Define the location of the config file.") + + +def template_command(args: Namespace) -> None: + """The execution logic of the `generate` command""" + config: dict = {} + if args.output_dir: + output_dir = os.path.join("./test", args.output_dir) + else: + output_dir = os.path.join("./test", "fuzzing") + if args.config: + with open(args.config, "r", encoding="utf-8") as readFile: + complete_config = json.load(readFile) + if "template" in complete_config: + config = complete_config["template"] + + if args.target_contracts: + config["targets"] = args.target_contracts + if args.compilation_path: + config["compilationPath"] = args.compilation_path + if args.name: + config["name"] = args.name + config["outputDir"] = output_dir + + CryticPrint().print_information("Running Slither...") + slither = Slither(config["compilationPath"]) + + # Check if dependencies are installed + include_attacks = bool("attacks" in config and len(config["attacks"]) > 0) + remappings = find_remappings(include_attacks) + + generator = HarnessGenerator(config, slither, remappings) + generator.generate_templates() diff --git a/fuzz_utils/parsing/parser.py b/fuzz_utils/parsing/parser.py new file mode 100644 index 0000000..92c176d --- /dev/null +++ b/fuzz_utils/parsing/parser.py @@ -0,0 +1,48 @@ +"""Defines all the parser commands""" +from typing import Any +from argparse import Namespace, _SubParsersAction +from fuzz_utils.parsing.commands.generate import generate_command, generate_flags +from fuzz_utils.parsing.commands.template import template_command, template_flags +from fuzz_utils.parsing.commands.init import init_command, init_flags + +parsers: dict[str, dict[str, Any]] = { + "init": { + "command": init_command, + "help": "Generate an initial configuration file.", + "flags": init_flags, + "subparser": None, + }, + "template": { + "command": template_command, + "help": "Generate an initial configuration file.", + "flags": template_flags, + "subparser": None, + }, + "generate": { + "command": generate_command, + "help": "Generate unit tests from fuzzer corpora sequences.", + "flags": generate_flags, + "subparser": None, + }, +} + + +def define_subparsers(subparser: _SubParsersAction) -> None: + """Defines the subparser flags and commands""" + + for key, value in parsers.items(): + # Initialize subparser + help_str: str = value["help"] + parser = subparser.add_parser(key, help=help_str) + value["subparser"] = parser + # Initialize subparser flags + value["flags"](parser) + + +def run_command(args: Namespace) -> bool: + """Runs the command associated with a particular subparser""" + if args.command in parsers: + parsers[args.command]["command"](args) + return True + + return False diff --git a/fuzz_utils/template/HarnessGenerator.py b/fuzz_utils/template/HarnessGenerator.py new file mode 100644 index 0000000..475d8b5 --- /dev/null +++ b/fuzz_utils/template/HarnessGenerator.py @@ -0,0 +1,576 @@ +""" Generates a template fuzzer harness for a smart contract target """ +# type: ignore[misc] # Ignores 'Any' input parameter +import os +import copy +from dataclasses import dataclass + +from slither import Slither +from slither.core.declarations.contract import Contract +from slither.core.declarations.function_contract import FunctionContract +from slither.core.solidity_types.user_defined_type import UserDefinedType +from slither.core.solidity_types.array_type import ArrayType +import jinja2 +from fuzz_utils.utils.crytic_print import CryticPrint +from fuzz_utils.utils.file_manager import check_and_create_dirs, save_file +from fuzz_utils.utils.error_handler import handle_exit +from fuzz_utils.templates.harness_templates import templates + +# pylint: disable=too-many-instance-attributes +@dataclass +class Actor: + """Class for storing Actor contract data""" + + name: str + constructor: str + dependencies: str + content: str + path: str + number: int + targets: list[Contract] + imports: list[str] + variables: list[str] + functions: list[str] + contract: Contract + + def set_content(self, content: str) -> None: + """Set the content field of the class""" + self.content = content + + def set_path(self, path: str) -> None: + """Set the path field of the class""" + self.path = path + + def set_contract(self, contract: Contract) -> None: + """Set the contract field of the class""" + self.contract = contract + + +@dataclass +class Harness: + """Class for storing Harness contract data""" + + name: str + constructor: str + dependencies: str + content: str + path: str + targets: list[Contract] + actors: list[Actor] + imports: list[str] + variables: list[str] + functions: list[str] + + def set_content(self, content: str) -> None: + """Sets the content field of the class""" + self.content = content + + def set_path(self, path: str) -> None: + """Sets the path field of the class""" + self.path = path + + +class HarnessGenerator: + """ + Handles the generation of Foundry test files from Echidna reproducers + """ + + config: dict = { + "name": "DefaultHarness", + "compilationPath": ".", + "targets": [], + "outputDir": "./test/fuzzing", + "actors": [ + { + "name": "Default", + "targets": [], + "number": 3, + "filters": { + "strict": False, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + }, + } + ], + "attacks": [], + } + + def __init__( + self, + config: dict, + slither: Slither, + remappings: dict, + ) -> None: + if "actors" in config: + config["actors"] = check_and_populate_actor_fields(config["actors"], config["targets"]) + else: + CryticPrint().print_warning("Using default values for the Actor.") + config["actors"] = self.config["actors"] + config["actors"][0]["targets"] = config["targets"] + + for key, value in config.items(): + if key in self.config and value: + self.config[key] = value + + if remappings: + self.remappings = remappings + + CryticPrint().print_no_format(f" Config: {self.config}") + + self.slither = slither + self.targets = [ + self.get_target_contract(slither, contract) for contract in self.config["targets"] + ] + self.output_dir = self.config["outputDir"] + + def generate_templates(self) -> None: + """Generates the Harness and Actor fuzzing templates""" + CryticPrint().print_information( + f"Generating the fuzzing Harness for contracts: {self.config['targets']}" + ) + + # Check if directories exists, if not, create them + check_and_create_dirs(self.output_dir, ["utils", "actors", "harnesses", "attacks"]) + # Generate the Actors + actors: list[Actor] = self._generate_actors() + CryticPrint().print_success(" Actors generated!") + # Generate the Attacks + attacks: list[Actor] = self._generate_attacks() + CryticPrint().print_success(" Attacks generated!") + # Generate the harness + self._generate_harness(actors, attacks) + CryticPrint().print_success(" Harness generated!") + CryticPrint().print_success(f"Files saved to {self.config['outputDir']}") + + # pylint: disable=too-many-locals + def _generate_harness(self, actors: list[Actor], attacks: list[Actor]) -> None: + CryticPrint().print_information(f"Generating {self.config['name']} Harness") + + # Generate inheritance and variables + imports: list[str] = [] + variables: list[str] = [] + + for contract in self.targets: + imports.append(f'import "{contract.source_mapping.filename.relative}";') + variables.append(f"{contract.name} {contract.name.lower()};") + + # Generate actor variables and imports + for actor in actors: + variables.append(f"Actor{actor.name}[] {actor.name}_actors;") + imports.append(f'import "{actor.path}";') + + # Generate attack variables and imports + for attack in attacks: + variables.append(f"Attack{attack.name} {attack.name.lower()}Attack;") + imports.append(f'import "{attack.path}";') + + # Generate constructor with contract, actor, and attack deployment + constructor = "constructor() {\n" + for contract in self.targets: + inputs: list[str] = [] + if contract.constructor: + constructor_parameters = contract.constructor.parameters + for param in constructor_parameters: + constructor += f" {param.type} {param.name};\n" + inputs.append(param.name) + inputs_str: str = ", ".join(inputs) + constructor += f" {contract.name.lower()} = new {contract.name}({inputs_str});\n" + + for actor in actors: + constructor += " for(uint256 i; i < 3; i++) {\n" + constructor_arguments = "" + if actor.contract and hasattr(actor.contract.constructor, "parameters"): + constructor_arguments = ", ".join( + [f"address({x.name.strip('_')})" for x in actor.contract.constructor.parameters] + ) + constructor += ( + f" {actor.name}_actors.push(new Actor{actor.name}({constructor_arguments}));\n" + + " }\n" + ) + + for attack in attacks: + constructor_arguments = "" + if attack.contract and hasattr(attack.contract.constructor, "parameters"): + constructor_arguments = ", ".join( + [f"address({x.name.strip('_')})" for x in actor.contract.constructor.parameters] + ) + constructor += f" {attack.name.lower()}Attack = new {attack.name}({constructor_arguments});\n" + constructor += " }\n" + # Generate dependencies + dependencies: str = "PropertiesAsserts" + + # Generate Functions + functions: list[str] = [] + for actor in actors: + function_body = f" {actor.contract.name} selectedActor = {actor.name}_actors[clampBetween(actorIndex, 0, {actor.name}_actors.length - 1)];\n" + temp_list = self._generate_functions( + actor.contract, None, ["uint256 actorIndex"], function_body, "selectedActor" + ) + functions.extend(temp_list) + + for attack in attacks: + temp_list = self._generate_functions( + attack.contract, None, [], None, f"{attack.name.lower()}Attack" + ) + functions.extend(temp_list) + + # Generate harness class + harness = Harness( + name=self.config["name"], + constructor=constructor, + dependencies=dependencies, + content="", + path="", + targets=self.targets, + actors=actors, + imports=imports, + variables=variables, + functions=functions, + ) + + content, path = self._render_template( + templates["HARNESS"], "harnesses", self.config["name"], harness + ) + harness.set_content(content) + harness.set_path(path) + + def _generate_attacks(self) -> list[Actor]: + CryticPrint().print_information("Generating Attack contracts:") + attacks: list[Actor] = [] + + # Check if dir exists, if not, create it + attack_output_path = os.path.join(self.output_dir, "attacks") + + for attack_config in self.config["attacks"]: + name = attack_config["name"] + if name in templates["ATTACKS"]: + CryticPrint().print_information(f" Attack: {name}...") + targets = [ + contract + for contract in self.targets + if contract.name in attack_config["targets"] + ] + attack: Actor = self._generate_actor(targets, attack_config, True) + + # Generate the templated string and append to list + content, path = self._render_template( + templates["ATTACKS"][name], "attacks", f"/Attack{name}", attack + ) + + # Save content and path to Actor + attack.set_content(content) + attack.set_path(path) + + attack_slither = Slither(f"{attack_output_path}/Attack{name}.sol") + attack.set_contract(self.get_target_contract(attack_slither, f"{name}Attack")) + + attacks.append(attack) + else: + CryticPrint().print_warning( + f"Attack `{name}` was skipped since it could not be found in the available templates" + ) + + return attacks + + def _generate_actor( + self, target_contracts: list[Contract], actor_config: dict, list_targets: bool + ) -> Actor: + imports: list[str] = [] + variables: list[str] = [] + functions: list[str] = [] + constructor_args: list[str] = [] + constructor = "" + + for contract in target_contracts: + # Generate inheritance + imports.append(f'import "{contract.source_mapping.filename.relative}";') + + # Generate variables + contract_name = contract.name + target_name = contract.name.lower() + variables.append(f"{contract_name} {target_name};") + + # Generate constructor + constructor_args.append(f"address _{target_name}") + constructor += f" {target_name} = {contract_name}(_{target_name});\n" + if list_targets: + constructor += f" targets.push(_{target_name});\n" + + # Generate Functions + + functions.extend( + self._generate_functions( + contract, actor_config["filters"], [], None, contract.name.lower() + ) + ) + + constructor = ( + f"constructor({', '.join(constructor_args)})" + "{\n" + constructor + " }\n" + ) + + return Actor( + name=actor_config["name"], + constructor=constructor, + imports=imports, + dependencies="PropertiesAsserts", + variables=variables, + functions=functions, + content="", + path="", + number=actor_config["number"] if "number" in actor_config else 1, + targets=target_contracts, + contract=None, + ) + + def _generate_actors(self) -> list[Actor]: + CryticPrint().print_information("Generating Actors:") + actor_contracts: list[Actor] = [] + + # Check if dir exists, if not, create it + actor_output_path = os.path.join(self.output_dir, "actors") # Input param: directory + + # Loop over actors list + for actor_config in self.config["actors"]: + name = actor_config["name"] + target_contracts: list[Contract] = [ + self.get_target_contract(self.slither, contract) + for contract in actor_config["targets"] + ] + + CryticPrint().print_information(f" Actor: {name}Actor...") + # Generate the Actor + actor: Actor = self._generate_actor(target_contracts, actor_config, False) + + content, path = self._render_template( + templates["ACTOR"], "actors", f"Actor{name}", actor + ) + + # Save content and path to Actor + actor.set_content(content) + actor.set_path(path) + + actor_slither = Slither(f"{actor_output_path}/Actor{name}.sol") + actor.set_contract(self.get_target_contract(actor_slither, f"Actor{name}")) + + actor_contracts.append(actor) + + # Return Actors list + return actor_contracts + + def _generate_functions( + self, + target_contract: Contract, + filters: dict | None, + prepend_variables: list[str], + function_body: str | None, + contract_name: str, + ) -> list[str]: + functions: list[str] = [] + contracts: list[Contract] = [target_contract] + if len(target_contract.inheritance) > 0: + contracts = list(set(contracts) | set(target_contract.inheritance)) + + for contract in contracts: + if should_skip_contract_functions(contract): + continue + temp_functions = self._fetch_contract_functions( + contract, filters, prepend_variables, function_body, contract_name + ) + if len(temp_functions) > 0: + functions.extend(temp_functions) + + return functions + + # pylint: disable=too-many-locals,too-many-branches,no-self-use + def _fetch_contract_functions( + self, + contract: Contract, + filters: dict | None, + prepend_variables: list[str], + function_body: str | None, + contract_name: str, + ) -> list[str]: + functions: list[str] = [] + + functions.append( + f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" + ) + + for entry in contract.functions_declared: + # Don't create wrappers for pure and view functions + if should_skip_function(entry, filters): + continue + + # Determine if payable + payable = " payable" if entry.payable else "" + unused_var = "notUsed" + # Loop over function inputs + inputs_with_types = "" + if isinstance(entry.parameters, list): + inputs_with_type_list = ( + copy.deepcopy(prepend_variables) if len(prepend_variables) > 0 else [] + ) + + for parameter in entry.parameters: + location = "" + if parameter.type.is_dynamic or isinstance( + parameter.type, (ArrayType, UserDefinedType) + ): + location = f" {parameter.location}" + # TODO change it so that we detect if address should be payable or not + elif "address" == parameter.type.type: + location = " payable" + inputs_with_type_list.append( + f"{parameter.type}{location} {parameter.name if parameter.name else unused_var}" + ) + + inputs_with_types = ", ".join(inputs_with_type_list) + # Loop over return types + return_types = "" + if isinstance(entry.return_type, list): + returns_list = [] + + for return_type in entry.return_type: + returns_list.append(f"{return_type.type}") + + return_types = f" returns ({', '.join(returns_list)})" + + # Generate function definition + definition = ( + f"function {entry.name}({inputs_with_types}) {entry.visibility}{payable}{return_types}" + + " {\n" + ) + if function_body: + definition += function_body + definition += ( + f" {contract_name}.{entry.name}({', '.join([ unused_var if not x.name else x.name for x in entry.parameters])});\n" + + " }\n" + ) + functions.append(definition) + + return functions + + def _render_template( + self, template_str: str, directory_name: str, file_name: str, target: Harness | Actor + ) -> tuple[str, str]: + output_path = os.path.join(self.output_dir, directory_name) + template = jinja2.Template(template_str) + content = template.render(target=target, remappings=self.remappings) + save_file(output_path, f"/{file_name}", ".sol", content) + + return content, f"../{directory_name}/{file_name}.sol" + + # pylint: disable=no-self-use + def get_target_contract(self, slither: Slither, target_name: str) -> Contract: + """Finds and returns Slither Contract""" + contracts = slither.get_contract_from_name(target_name) + # Loop in case slither fetches multiple contracts for some reason (e.g., similar names?) + for contract in contracts: + if contract.name == target_name: + return contract + + handle_exit(f"\n* Slither could not find the specified contract `{target_name}`.") + + +# Utility functions +def should_skip_contract_functions(contract: Contract) -> bool: + """Determines if the contract has applicable functions to include in a harness or actor. Returns bool""" + if not contract.functions_declared or contract.is_interface: + return True + + for entry in contract.functions_declared: + if ( + (entry.visibility in ("public", "external")) + and not entry.view + and not entry.pure + and not entry.is_constructor + ): + return False + + return True + + +# pylint: disable=too-many-branches +def should_skip_function(function: FunctionContract, config: dict | None) -> bool: + """Determines if a function matches the filters. Returns bool""" + # Don't create wrappers for pure and view functions + if ( + function.pure + or function.view + or function.is_constructor + or function.is_fallback + or function.is_receive + ): + return True + if function.visibility not in ("public", "external"): + return True + + any_match: list[bool] = [False, False, False] + empty: list[bool] = [False, False, False] + + if config: + if len(config["onlyModifiers"]) > 0: + inclusionSet = set(config["onlyModifiers"]) + modifierSet: set = {x.name for x in function.modifiers} + if inclusionSet & modifierSet: + any_match[0] = True + else: + empty[0] = True + + if config["onlyPayable"]: + if function.payable: + any_match[1] = True + else: + empty[1] = True + + if len(config["onlyExternalCalls"]) > 0: + functions = [] + for _, func in function.all_high_level_calls(): + functions.append(func) + + inclusionSet = set(config["onlyExternalCalls"]) + functionsSet = set(x.name for x in functions) + if inclusionSet & functionsSet: + any_match[2] = True + else: + empty[2] = True + + # If all are empty don't skip any functions: + if all(empty): + return False + + # If not strict and any one is a match, don't skip the function + if not config["strict"]: + if any(any_match): + return False + return True + + # If strict, ensure all non-empty have a match + result = [a or b for a, b in zip(empty, any_match)] + if all(result): + return False + # No match, skip function + return True + # If the config isn't defined, don't skip any functions + return False + + +def check_and_populate_actor_fields(actors_config: dict, default_targets: list[str]) -> dict: + """Check the Actor config fields and populates the missing ones with default values""" + for idx, actor in enumerate(actors_config): + if "name" not in actor or "targets" not in actor: + handle_exit("Actor is missing attributes") + if "number" not in actor: + actors_config[idx]["number"] = 3 + CryticPrint().print_warning("Missing number argument in actor, using 3 as default.") + if "filters" not in actor: + actors_config[idx]["filters"] = { + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + } + CryticPrint().print_warning("Missing filters argument in actor, using none as default.") + if "targets" not in actor or len(actor["targets"]) == 0: + actors_config[idx]["targets"] = default_targets + + return actors_config diff --git a/fuzz_utils/template/__init__.py b/fuzz_utils/template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fuzz_utils/templates/default_config.py b/fuzz_utils/templates/default_config.py new file mode 100644 index 0000000..d41416e --- /dev/null +++ b/fuzz_utils/templates/default_config.py @@ -0,0 +1,33 @@ +"""Default configuration file""" +default_config: dict = { + "generate": { + "targetContract": "", + "compilationPath": ".", + "corpusDir": "", + "fuzzer": "", + "testsDir": "", + "inheritancePath": "", + "namedInputs": False, + "allSequences": False, + }, + "template": { + "name": "DefaultHarness", + "targets": [], + "outputDir": "./test/fuzzing", + "compilationPath": ".", + "actors": [ + { + "name": "Default", + "targets": [], + "number": 3, + "filters": { + "strict": False, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + }, + } + ], + "attacks": [], + }, +} diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index 4cdca80..09d625f 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -2,6 +2,11 @@ __CONTRACT_TEMPLATE: str = """// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; + +/// -------------------------------------------------------------------- +/// @notice This file was automatically generated using fuzz-utils +/// -------------------------------------------------------------------- + import "forge-std/Test.sol"; import "forge-std/console2.sol"; import "{{file_path}}"; @@ -23,7 +28,7 @@ """ __CALL_TEMPLATE: str = """ - {%- if has_delay -%} + {%- if has_delay %} vm.warp(block.timestamp + {{time_delay}}); vm.roll(block.number + {{block_delay}}); {%- endif %} @@ -33,16 +38,15 @@ {%- else %} target.{{function_name}}({{function_parameters}}); {%- endif %} - """ __TRANSFER__TEMPLATE: str = """ - {%- if has_delay -%} + {%- if has_delay %} vm.warp(block.timestamp + {{time_delay}}); vm.roll(block.number + {{block_delay}}); {%- endif %} vm.prank({{caller}}); - (bool success, ) = payable(address(target)).call{value: {{value}}}(""); + (success, ) = payable(address(target)).call{value: {{value}}}(""); require(success, "Low level call failed."); """ @@ -54,7 +58,10 @@ __TEST_TEMPLATE: str = """ // Reproduced from: {{file_path}} - function test_auto_{{function_name}}() public { {% for call in call_list %} + function test_auto_{{function_name}}() public { + {%- if has_low_level_call %} + bool success; + {%- endif %} {% for call in call_list %} {{call}}{% endfor %} }""" diff --git a/fuzz_utils/templates/harness_templates.py b/fuzz_utils/templates/harness_templates.py new file mode 100644 index 0000000..d828fee --- /dev/null +++ b/fuzz_utils/templates/harness_templates.py @@ -0,0 +1,156 @@ +""" Defines the template strings used to generate fuzzing harnesses""" + +__PREFACE: str = """// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// -------------------------------------------------------------------- +/// @notice This file was automatically generated using fuzz-utils +/// +/// -- [ Prerequisites ] +/// 1. The generated contracts depend on crytic/properties utilities +/// which need to be installed, this can be done by running: +/// `forge install crytic/properties` +/// 2. Absolute paths are used for contract inheritance, requiring +/// the main directory that contains the contracts to be added to +/// the Foundry remappings. This can be done by adding: +/// `directoryName/=directoryName/` to foundry.toml or remappings.txt""" + +__HARNESS_TEMPLATE: str = ( + __PREFACE + + """ +/// +/// -- [ Running the fuzzers ] +/// * The below commands contain example values which you can modify based +/// on your needs. For further information on the configuration options +/// please reference the fuzzer documentation * +/// Echidna: echidna {{target.path}} --contract {{target.name}} --test-mode assertion --test-limit 100000 --corpus-dir echidna-corpora/corpus-{{target.name}} +/// Medusa: medusa fuzz --target {{target.path}} --assertion-mode --test-limit 100000 --deployment-order "{{target.name}}" --corpus-dir medusa-corpora/corpus-{{target.name}} +/// Foundry: forge test --match-contract {{target.name}} +/// -------------------------------------------------------------------- + +import "{{remappings["properties"]}}util/PropertiesHelper.sol"; +{% for import in target.imports -%} +{{import}} +{% endfor %} +contract {{target.name}} is {{target.dependencies}} { + {% for variable in target.variables -%} + {{variable}} + {% endfor %} + {{target.constructor}} + {%- for function in target.functions %} + {{function}} + {%- endfor -%} +} +""" +) + +__ACTOR_TEMPLATE: str = ( + __PREFACE + + """ + +import "{{remappings["properties"]}}util/PropertiesHelper.sol"; +{%- for import in target.imports %} +{{import}} +{% endfor -%} + +contract Actor{{target.name}} is {{target.dependencies}} { + {%- for variable in target.variables %} + {{variable}} + {% endfor -%} + + {{target.constructor}} + {%- for function in target.functions %} + {{function}} + {%- endfor -%} +} +""" +) + +__ATTACK_DONATE_TEMPLATE: str = ( + __PREFACE + + """ + +import "{{remappings["properties"]}}util/PropertiesHelper.sol"; +{%- for import in target.imports %} +{{import}} +{% endfor -%} + +contract SelfDestructor { + address owner; + + constructor() payable { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner!"); + _; + } + + function detonate(address payable to) public onlyOwner { + require(address(this).balance > 0, "Not enough ETH balance!"); + selfdestruct(to); + } + + receive() external payable { + // receive ETH + } + + fallback() external payable { + // receiveETH + } +} + +interface IERC20 { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); +} + +contract DonationAttack is {{target.dependencies}} { + {%- for variable in target.variables %} + {{variable}} + {% endfor -%} + address[] targets; + IERC20[] tokens; + + // Should approve all targets for all tokens + {{target.constructor}} + function donateETH(uint256 targetIndex) public payable { + require(msg.value > 0, "No value provided"); + address target = targets[clampBetween(targetIndex, 0, targets.length - 1)]; + (bool success,) = payable(target).call{value: msg.value}(""); + require(success, "Failed to donate ETH via a receive/fallback function."); + } + + function selfdestructDonation(uint256 targetIndex) public payable { + require(msg.value > 0, "No value provided"); + address target = targets[clampBetween(targetIndex, 0, targets.length - 1)]; + SelfDestructor bomb = new SelfDestructor{value: msg.value}(); + bomb.detonate(payable(target)); + } + + function tokenDonation(uint256 targetIndex, uint256 tokenIndex, uint256 amount) public { + address target = targets[clampBetween(targetIndex, 0, targets.length - 1)]; + IERC20 token = tokens[clampBetween(tokenIndex, 0, tokens.length - 1)]; + token.transfer(target, amount); + } + + {%- for function in target.functions %} + {{function}} + {%- endfor -%} +} +""" +) + +templates: dict = { + "HARNESS": __HARNESS_TEMPLATE, + "ACTOR": __ACTOR_TEMPLATE, + "ATTACKS": {"Donation": __ATTACK_DONATE_TEMPLATE}, +} diff --git a/fuzz_utils/utils/encoding.py b/fuzz_utils/utils/encoding.py index ec9359e..6b8652d 100644 --- a/fuzz_utils/utils/encoding.py +++ b/fuzz_utils/utils/encoding.py @@ -60,11 +60,6 @@ def octal_to_byte(match: re.Match) -> str: return s.encode().hex() -def parse_medusa_byte_string(s: str) -> str: - """Decode bytes* or string type from Medusa format to Solidity hex literal""" - return s.encode("utf-8").hex() - - def byte_to_escape_sequence(byte_data: bytes) -> str: """Generates unicode escaped string from bytes""" arr = [] diff --git a/fuzz_utils/utils/file_manager.py b/fuzz_utils/utils/file_manager.py new file mode 100644 index 0000000..4eee052 --- /dev/null +++ b/fuzz_utils/utils/file_manager.py @@ -0,0 +1,16 @@ +""" Manages creation of files and directories """ +import os + + +def check_and_create_dirs(base_path: str, dirnames: list[str]) -> None: + """Checks if the directories in the list exist, if not, creates them""" + for dirname in dirnames: + path = os.path.join(base_path, dirname) + if not os.path.exists(path): + os.makedirs(path) + + +def save_file(path: str, file_name: str, suffix: str, content: str) -> None: + """Saves a file""" + with open(f"{path}{file_name}{suffix}", "w", encoding="utf-8") as outfile: + outfile.write(content) diff --git a/fuzz_utils/utils/remappings.py b/fuzz_utils/utils/remappings.py new file mode 100644 index 0000000..4f3cb48 --- /dev/null +++ b/fuzz_utils/utils/remappings.py @@ -0,0 +1,62 @@ +"""Utility functions to handle Foundry remappings""" +import os +import subprocess +import re +from fuzz_utils.utils.crytic_print import CryticPrint +from fuzz_utils.utils.error_handler import handle_exit + + +def find_remappings(include_attacks: bool) -> dict: + """Finds the remappings used and returns a dict with the values""" + CryticPrint().print_information("Checking dependencies...") + openzeppelin = r"(\S+)=lib\/openzeppelin-contracts\/(?!\S*lib\/)(\S+)" + solmate = r"(\S+)=lib\/solmate\/(?!\S*lib\/)(\S+)" + properties = r"(\S+)=lib\/properties\/(?!\S*lib\/)(\S+)" + remappings: str = "" + + if os.path.exists("remappings.txt"): + with open("remappings.txt", "r", encoding="utf-8") as file: + remappings = file.read() + else: + output = subprocess.run(["forge", "remappings"], capture_output=True, text=True, check=True) + remappings = str(output) + + oz_matches = re.findall(openzeppelin, remappings) + sol_matches = re.findall(solmate, remappings) + prop_matches = re.findall(properties, remappings) + + if include_attacks and len(oz_matches) == 0 and len(sol_matches) == 0: + handle_exit( + "Please make sure that openzeppelin-contracts or solmate are installed if you're using the Attack templates." + ) + + if len(prop_matches) == 0: + handle_exit( + "Please make sure crytic/properties is installed before running the template command." + ) + + result = {} + if len(oz_matches) > 0: + default = ["contracts/"] + result["openzeppelin"] = "".join( + find_difference_between_list_and_tuple(default, oz_matches[0]) + ) + + if len(sol_matches) > 0: + default = ["src/"] + result["solmate"] = "".join(find_difference_between_list_and_tuple(default, sol_matches[0])) + + if len(prop_matches) > 0: + default = ["contracts/"] + result["properties"] = "".join( + find_difference_between_list_and_tuple(default, prop_matches[0]) + ) + + return result + + +def find_difference_between_list_and_tuple(default: list, my_tuple: tuple) -> list: + """Used to manage remapping paths based on difference between the remappings and the expected path""" + return [item for item in my_tuple if item not in default] + [ + item for item in default if item not in my_tuple + ] diff --git a/tests/conftest.py b/tests/conftest.py index b03efff..fd6554e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,24 +4,30 @@ import pytest from slither import Slither -from fuzz_utils.main import FoundryTest -from fuzz_utils.fuzzers.Echidna import Echidna -from fuzz_utils.fuzzers.Medusa import Medusa +from fuzz_utils.generate.FoundryTest import FoundryTest +from fuzz_utils.generate.fuzzers.Echidna import Echidna +from fuzz_utils.generate.fuzzers.Medusa import Medusa class TestGenerator: """Helper class for testing all fuzzers with the tool""" + __test__ = False + def __init__(self, target: str, target_path: str, corpus_dir: str): slither = Slither(target_path) echidna = Echidna(target, f"echidna-corpora/{corpus_dir}", slither, False) medusa = Medusa(target, f"medusa-corpora/{corpus_dir}", slither, False) - self.echidna_generator = FoundryTest( - "../src/", target, f"echidna-corpora/{corpus_dir}", "./test/", slither, echidna, False - ) - self.medusa_generator = FoundryTest( - "../src/", target, f"medusa-corpora/{corpus_dir}", "./test/", slither, medusa, False - ) + config = { + "targetContract": target, + "inheritancePath": "../src/", + "corpusDir": f"echidna-corpora/{corpus_dir}", + "testsDir": "./test/", + "allSequences": False, + } + self.echidna_generator = FoundryTest(config, slither, echidna) + config["corpusDir"] = f"medusa-corpora/{corpus_dir}" + self.medusa_generator = FoundryTest(config, slither, medusa) def echidna_generate_tests(self) -> None: """Runs the fuzz-utils tool for an Echidna corpus""" diff --git a/tests/test_data/echidna-corpora/corpus-dyn-arr/reproducers/dyn_array_variable_definition.json b/tests/test_data/echidna-corpora/corpus-dyn-arr/reproducers/dyn_array_variable_definition.json new file mode 100644 index 0000000..5dacdb4 --- /dev/null +++ b/tests/test_data/echidna-corpora/corpus-dyn-arr/reproducers/dyn_array_variable_definition.json @@ -0,0 +1,94 @@ +[ + { + "call": { + "contents": [ + "addIntArr", + [ + { + "contents": [ + { + "contents": 256, + "tag": "AbiIntType" + }, + [ + { + "contents": [ + 256, + "3" + ], + "tag": "AbiInt" + } + ] + ], + "tag": "AbiArrayDynamic" + } + ] + ], + "tag": "SolCall" + }, + "delay": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "dst": "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + "gas": 12500000, + "gasprice": "0x0000000000000000000000000000000000000000000000000000000000000000", + "src": "0x0000000000000000000000000000000000010000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "call": { + "contents": [ + "addIntArr", + [ + { + "contents": [ + { + "contents": 256, + "tag": "AbiIntType" + }, + [ + { + "contents": [ + 256, + "3" + ], + "tag": "AbiInt" + } + ] + ], + "tag": "AbiArrayDynamic" + } + ] + ], + "tag": "SolCall" + }, + "delay": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "dst": "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + "gas": 12500000, + "gasprice": "0x0000000000000000000000000000000000000000000000000000000000000000", + "src": "0x0000000000000000000000000000000000010000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "call": { + "contents": [ + "check_intDynArr", + [] + ], + "tag": "SolCall" + }, + "delay": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "dst": "0x00a329c0648769A73afAc7F9381E08FB43dBEA72", + "gas": 12500000, + "gasprice": "0x0000000000000000000000000000000000000000000000000000000000000000", + "src": "0x0000000000000000000000000000000000010000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + } +] \ No newline at end of file diff --git a/tests/test_data/lib/properties b/tests/test_data/lib/properties new file mode 160000 index 0000000..bb1b785 --- /dev/null +++ b/tests/test_data/lib/properties @@ -0,0 +1 @@ +Subproject commit bb1b78542b3f38e4ae56cf87389cd3ea94387f48 diff --git a/tests/test_data/lib/solmate b/tests/test_data/lib/solmate new file mode 160000 index 0000000..c892309 --- /dev/null +++ b/tests/test_data/lib/solmate @@ -0,0 +1 @@ +Subproject commit c892309933b25c03d32b1b0d674df7ae292ba925 diff --git a/tests/test_data/medusa-corpora/corpus-dyn-arr/test_results/dyn_array_variable_definition.json b/tests/test_data/medusa-corpora/corpus-dyn-arr/test_results/dyn_array_variable_definition.json new file mode 100755 index 0000000..1c2c800 --- /dev/null +++ b/tests/test_data/medusa-corpora/corpus-dyn-arr/test_results/dyn_array_variable_definition.json @@ -0,0 +1,99 @@ +[ + { + "call": { + "from": "0x0000000000000000000000000000000000010000", + "to": "0xa647ff3c36cfab592509e13860ab8c4f28781a66", + "nonce": 0, + "value": "0x0", + "gasLimit": 12500000, + "gasPrice": "0x1", + "gasFeeCap": "0x0", + "gasTipCap": "0x0", + "data": "0x9b8da3b70000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", + "dataAbiValues": { + "methodName": "addBoolArr", + "inputValues": [ + [ + true, + true, + true, + false, + false, + false, + true, + false, + true, + true, + true, + false, + false, + true + ] + ] + }, + "AccessList": null, + "SkipAccountChecks": false + }, + "blockNumberDelay": 23884, + "blockTimestampDelay": 320182 + }, + { + "call": { + "from": "0x0000000000000000000000000000000000010000", + "to": "0xa647ff3c36cfab592509e13860ab8c4f28781a66", + "nonce": 1, + "value": "0x0", + "gasLimit": 12500000, + "gasPrice": "0x1", + "gasFeeCap": "0x0", + "gasTipCap": "0x0", + "data": "0x9b8da3b70000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", + "dataAbiValues": { + "methodName": "addBoolArr", + "inputValues": [ + [ + true, + true, + true, + false, + false, + false, + true, + false, + true, + true, + true, + false, + false, + true + ] + ] + }, + "AccessList": null, + "SkipAccountChecks": false + }, + "blockNumberDelay": 23884, + "blockTimestampDelay": 320182 + }, + { + "call": { + "from": "0x0000000000000000000000000000000000010000", + "to": "0xa647ff3c36cfab592509e13860ab8c4f28781a66", + "nonce": 2, + "value": "0x0", + "gasLimit": 12500000, + "gasPrice": "0x1", + "gasFeeCap": "0x0", + "gasTipCap": "0x0", + "data": "0xfe08f9cd", + "dataAbiValues": { + "methodName": "check_boolArr", + "inputValues": [] + }, + "AccessList": null, + "SkipAccountChecks": false + }, + "blockNumberDelay": 47114, + "blockTimestampDelay": 360622 + } +] \ No newline at end of file diff --git a/tests/test_data/remappings.txt b/tests/test_data/remappings.txt new file mode 100644 index 0000000..9505c4c --- /dev/null +++ b/tests/test_data/remappings.txt @@ -0,0 +1,8 @@ +ERC4626/=lib/properties/lib/ERC4626/contracts/ +ds-test/=lib/forge-std/lib/ds-test/src/ +erc4626-tests/=lib/properties/lib/openzeppelin-contracts/lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ +openzeppelin-contracts/=lib/properties/lib/openzeppelin-contracts/ +properties/=lib/properties/contracts/ +solmate/=lib/solmate/src/ +src/=src/ diff --git a/tests/test_data/src/Filtering.sol b/tests/test_data/src/Filtering.sol new file mode 100644 index 0000000..6e3a9db --- /dev/null +++ b/tests/test_data/src/Filtering.sol @@ -0,0 +1,53 @@ +pragma solidity ^0.8.0; + +interface IMockERC20 { + function transfer(address to, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); +} + +contract Filtering { + address owner; + IMockERC20 token; + uint256 balance; + mapping(address => uint256) tokenBalances; + + constructor(address _token) { + owner = msg.sender; + token = IMockERC20(_token); + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner!"); + _; + } + + modifier enforceTransferFrom(uint256 amount) { + _; + token.transferFrom(msg.sender, address(this), amount); + } + + function iAmPayable() public payable { + balance += msg.value; + } + + function iAmRestricted() public onlyOwner { + (bool success,) = msg.sender.call{value: balance}(""); + require(success, "Failed transfer"); + balance = 0; + } + + function depositWithModifier(uint256 amount) public enforceTransferFrom(amount) { + tokenBalances[msg.sender] += amount; + } + + function depositNoModifier(uint256 amount) public { + tokenBalances[msg.sender] += amount; + token.transferFrom(msg.sender, address(this), amount); + } + + function withdraw(uint256 amount) public { + require(tokenBalances[msg.sender] >= amount, "Not enough balance"); + tokenBalances[msg.sender] -= amount; + token.transfer(msg.sender, amount); + } +} \ No newline at end of file diff --git a/tests/test_data/test/DynamicArrays_Echidna_Test.t.sol b/tests/test_data/test/DynamicArrays_Echidna_Test.t.sol index fac161f..bbbcfdf 100644 --- a/tests/test_data/test/DynamicArrays_Echidna_Test.t.sol +++ b/tests/test_data/test/DynamicArrays_Echidna_Test.t.sol @@ -10,6 +10,7 @@ contract DynamicArrays_Echidna_Test is Test { function setUp() public { target = new DynamicArrays(); } + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/-533708655584678499.json function test_auto_check_bytesArr_0() public { bytes[] memory dynbytesArr_0 = new bytes[](4); dynbytesArr_0[0] = bytes(hex"00"); @@ -20,10 +21,13 @@ contract DynamicArrays_Echidna_Test is Test { vm.prank(0x0000000000000000000000000000000000010000); target.addBytesArr(dynbytesArr_0); + vm.prank(0x0000000000000000000000000000000000010000); target.check_bytesArr(); + } + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/-7647997810271354783.json function test_auto_check_addressDynArr_1() public { address[] memory dynaddressArr_0 = new address[](8); dynaddressArr_0[0] = 0x00000000000000000000000000000000DeaDBeef; @@ -38,10 +42,13 @@ contract DynamicArrays_Echidna_Test is Test { vm.prank(0x0000000000000000000000000000000000010000); target.addAddressArr(dynaddressArr_0); + vm.prank(0x0000000000000000000000000000000000010000); target.check_addressDynArr(); + } + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/959716005390025023.json function test_auto_check_boolArr_2() public { bool[] memory dynboolArr_0 = new bool[](6); dynboolArr_0[0] = true; @@ -54,10 +61,13 @@ contract DynamicArrays_Echidna_Test is Test { vm.prank(0x0000000000000000000000000000000000010000); target.addBoolArr(dynboolArr_0); + vm.prank(0x0000000000000000000000000000000000010000); target.check_boolArr(); + } + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/5551649382488529349.json function test_auto_check_intDynArr_3() public { int256[] memory dynint256Arr_0 = new int256[](1); dynint256Arr_0[0] = int256(3); @@ -65,10 +75,13 @@ contract DynamicArrays_Echidna_Test is Test { vm.prank(0x0000000000000000000000000000000000010000); target.addIntArr(dynint256Arr_0); + vm.prank(0x0000000000000000000000000000000000010000); target.check_intDynArr(); + } + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/1997449955301376751.json function test_auto_check_strDynArr_4() public { string[] memory dynstringArr_0 = new string[](4); dynstringArr_0[0] = string(hex"00"); @@ -79,8 +92,30 @@ contract DynamicArrays_Echidna_Test is Test { vm.prank(0x0000000000000000000000000000000000010000); target.addStrArr(dynstringArr_0); + vm.prank(0x0000000000000000000000000000000000010000); target.check_strDynArr(); + + } + + // Reproduced from: echidna-corpora/corpus-dyn-arr/reproducers/dyn_array_variable_definition.json + function test_auto_check_intDynArr_5() public { + int256[] memory dynint256Arr_0 = new int256[](1); + dynint256Arr_0[0] = int256(3); + + vm.prank(0x0000000000000000000000000000000000010000); + target.addIntArr(dynint256Arr_0); + + dynint256Arr_0 = new int256[](1); + dynint256Arr_0[0] = int256(3); + + vm.prank(0x0000000000000000000000000000000000010000); + target.addIntArr(dynint256Arr_0); + + + vm.prank(0x0000000000000000000000000000000000010000); + target.check_intDynArr(); + } } diff --git a/tests/test_data/test/DynamicArrays_Medusa_Test.t.sol b/tests/test_data/test/DynamicArrays_Medusa_Test.t.sol index f5d6575..4587491 100644 --- a/tests/test_data/test/DynamicArrays_Medusa_Test.t.sol +++ b/tests/test_data/test/DynamicArrays_Medusa_Test.t.sol @@ -10,6 +10,7 @@ contract DynamicArrays_Medusa_Test is Test { function setUp() public { target = new DynamicArrays(); } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377304276517000-e15599be-8cab-4d01-8cd7-4cd785554cb6.json function test_auto_check_bytesArr_0() public { bytes[] memory input = new bytes[](16); input[0] = bytes(hex"45cee200427cbe"); @@ -28,16 +29,21 @@ contract DynamicArrays_Medusa_Test is Test { input[13] = bytes(hex"177a06df87b48eb029fac004381df3055eb4a5279704df9c4b568e832140d544417ac84055e0d065177f8f6189a51ca2766a706de805e448e5bc2ede94076e7cf64f93aa7009e9748295c803af487d4345002fc7767a6ce86ab7ed8ad8"); input[14] = bytes(hex"181190d108430b1f4c11aaffa722798e43b548ebc8df13e86197e940c400bf53938ddcba07"); input[15] = bytes(hex"70d858e348c650a00ed04ccdd960aa62e693e0607857e6884c86149e290a024e4b04e293326a5821eea93516db070ab06e2fbe5add6b0d5b1fc88da321a616e56f8b0a9bfe0361550f713f"); -vm.warp(block.timestamp + 319057); + + vm.warp(block.timestamp + 319057); vm.roll(block.number + 44673); vm.prank(0x0000000000000000000000000000000000020000); target.addBytesArr(input); + + vm.warp(block.timestamp + 4); vm.roll(block.number + 2); vm.prank(0x0000000000000000000000000000000000010000); target.check_bytesArr(); + } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377304277060000-e1fe2904-517b-4467-90b5-3af5a6574251.json function test_auto_check_bytesArr_1() public { bytes[] memory input = new bytes[](59); input[0] = bytes(hex"d7879cbd024fd72433d63401e4b12375e20a1656d130196847da066c794b77093af4a5ba77949331e1b41c76eec0d4ac98635e71443d6c52ec39a5844bd7b65cb9e83679c03bfa86ba8d60"); @@ -99,15 +105,19 @@ vm.warp(block.timestamp + 319057); input[56] = bytes(hex"b3bdcc2e3f6868833904255d37f126e94ff0704c8d488bbee95941aa4a93985e5721416dcf6a7716cec26b45bcecb33894c0c79fc5e039caff60ebfd8fc0ec0079a13c8db1bad03ba704e3e6956b7f6c717f"); input[57] = bytes(hex"2bbdeed85b3abce2035d24266757123c621de2807022c2f628d6800385d7add0a2fcd48cb7d62c23263687c4"); input[58] = bytes(hex"172c0ad19f98"); -vm.warp(block.timestamp + 269053); + + vm.warp(block.timestamp + 269053); vm.roll(block.number + 23881); vm.prank(0x0000000000000000000000000000000000020000); target.addBytesArr(input); + vm.prank(0x0000000000000000000000000000000000030000); target.check_bytesArr(); + } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377303350862000-7744abc8-f55a-4ab1-b4fc-43380bd596f8.json function test_auto_check_boolArr_2() public { bool[] memory input = new bool[](14); input[0] = true; @@ -124,16 +134,21 @@ vm.warp(block.timestamp + 269053); input[11] = false; input[12] = false; input[13] = true; -vm.warp(block.timestamp + 320182); + + vm.warp(block.timestamp + 320182); vm.roll(block.number + 23884); vm.prank(0x0000000000000000000000000000000000010000); target.addBoolArr(input); + + vm.warp(block.timestamp + 360622); vm.roll(block.number + 47114); vm.prank(0x0000000000000000000000000000000000010000); target.check_boolArr(); + } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377305545039000-e0720184-376d-45f2-9a03-673a130654e4.json function test_auto_check_strDynArr_3() public { string[] memory input = new string[](85); input[0] = unicode"\u00cd"; @@ -221,16 +236,21 @@ vm.warp(block.timestamp + 320182); input[82] = unicode"\u0053\u009d\u00c5\u00a0\u0091\u0020\u00ab\u008a\u00cd\u0073\u008f\u0048\u00c7\u00fb\u0064\u001b\u00b6\u0033\u00a7\u00aa\u0052"; input[83] = unicode"\u00f7\u0008\u00b6\u00b9\u00ab\u006c\u0049\u0092\u00ab\u0023\u0072\u0081\u008a\u00e2\u0072\u00bd\u00c7\u0057\u0063\u00c6\u0010\u007e\u000a\u000c\u00b9\u00c9\u006b\u006b\u00fa\u00f1\u00fa\u0034\u00e8\u0033\u00c8\u00a5\u0058\u0064\u00b8\u003b\u003b\u0026\u0073\u0073\u00f0\u000b\u00d0\u0020\u0063\u0028\u0056\u0084\u00da\u00ba\u00e0\u0061\u00b1\u0078\u00d4\u002b\u00e3\u00f0\u0077\u00ae\u0060\u004b\u00b7\u0066\u00a5\u0012\u00a3\u000f\u00d3"; input[84] = unicode"\u0019\u0037\u0096\u003d\u007a\u0080\u00b8\u0037\u001f\u00f8\u0096\u0011\u009f\u00f0\u006c\u00c6\u00c9\u0050\u0050\u0001\u00a0\u0096\u0063\u00c9\u006e\u003b\u0094\u00fc\u00ec\u0009\u004d\u00da\u007f\u00df\u001b\u002a\u0012\u00bd\u00b3\u005e\u000f\u0035\u0080\u0083\u0080\u009b\u00c6\u0005\u00d6\u0095\u0076\u009c\u00fb\u00f5\u0067\u00cd\u003f\u00c5\u0011\u0076\u0004\u00b0\u0080\u0071\u00e5\u0095\u00b3\u0030"; -vm.warp(block.timestamp + 360604); + + vm.warp(block.timestamp + 360604); vm.roll(block.number + 23884); vm.prank(0x0000000000000000000000000000000000030000); target.addStrArr(input); + + vm.warp(block.timestamp + 1); vm.roll(block.number + 0); vm.prank(0x0000000000000000000000000000000000020000); target.check_strDynArr(); + } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377303934867000-b91d964f-2912-4e40-b11c-807b89ad0c27.json function test_auto_check_addressDynArr_4() public { address[] memory input = new address[](68); input[0] = 0x1CB02a2fd56e000ce64811BfD083a1c134192892; @@ -301,16 +321,21 @@ vm.warp(block.timestamp + 360604); input[65] = 0xB0ddFd138a60EEEe3fc9f361192dAEFd8088e3e0; input[66] = 0x0000000000000000000000000000000000000005; input[67] = 0x0000000000000000000000000000000000000002; -vm.warp(block.timestamp + 360623); + + vm.warp(block.timestamp + 360623); vm.roll(block.number + 3); vm.prank(0x0000000000000000000000000000000000020000); target.addAddressArr(input); + + vm.warp(block.timestamp + 475435); vm.roll(block.number + 20); vm.prank(0x0000000000000000000000000000000000020000); target.check_addressDynArr(); + } + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/1704377304969856000-2f8481da-a733-472e-82b2-98f88ea4e703.json function test_auto_check_strDynArr_5() public { string[] memory input = new string[](39); input[0] = unicode"\u00f4\u00fb\u000f\u0030\u00af\u0083\u00a2\u00ed\u00eb\u00f4\u008f\u00d5\u00ea\u0086\u00ea\u006f\u00b8\u006f\u00fb\u0031\u004c\u009f\u00dc\u00fe"; @@ -352,14 +377,70 @@ vm.warp(block.timestamp + 360623); input[36] = unicode"\u008b\u0075\u00a5\u0057\u000d\u00ba\u007f\u00c4\u0052\u00e4\u00df\u0011\u00bd\u0063\u00f0\u0070\u00be\u00cf\u0023\u00a5\u007f\u0024\u006f\u0091\u00fa\u002e\u00ef\u0016\u00b6\u008b\u0018\u009d\u00df\u0075\u0002\u0071\u00bd\u0033\u000e\u001d\u0065\u0076"; input[37] = unicode"\u00f8\u008f\u000c\u00ca\u00f3\u0069\u00bf\u0026\u00e1\u0068\u008f\u0035\u003f\u00ae\u0022\u0071\u00f5\u0091\u0069\u00f5\u0072\u00f7\u00c8\u0083\u00e5\u0087\u0006\u001b\u007f\u0054\u006c\u00a9\u0003\u00a3\u00dd\u0017\u0077\u0085\u001c\u003e\u00ae\u0017\u005e\u00ce\u0017\u008a\u00fe\u0068\u0035\u002b\u0086\u00d5\u0036\u00eb\u002e\u00c4\u001e\u00c4\u00cf\u00a6\u00ae\u00e5\u0028\u0041\u00fc\u0003\u002a\u00b5\u00f1\u00a7\u000d\u00f9\u0031\u001c\u0037\u003a\u0086\u003a\u00e7\u0004\u00ce\u004c\u0027\u00fd\u002e\u0057\u00d1\u004f\u0030\u00fe\u005e\u0086\u008c\u00d5\u0059\u0040\u00e6\u003a\u001d\u0065"; input[38] = unicode"\u00bc\u0011\u0066\u0043\u0082\u009c\u00f8\u0072\u003d\u004f\u0058\u0055\u004f\u00d4\u0049\u0073\u00c5\u0000\u002c\u0018\u00d2\u0092\u0079\u0077\u0091\u00b9\u0044\u00e9\u0068\u002d\u0071\u00b6\u00c4\u0011\u00a4\u000d\u00ae\u001a\u0004\u00ce\u00c3\u002f\u0026\u0020\u0028\u005b\u0032\u00f6\u00b6\u009d\u008e\u005b\u0076\u00e5\u00be\u0091\u006f\u009e\u0041\u0046\u00f4\u00e6\u002d\u0077\u00df\u009d\u0017\u00a3\u0011\u004c\u0077\u00fa\u0076\u00c3\u007b\u0035\u008f\u0085\u005e\u009c\u0023\u0051\u0093\u00ca\u0022\u002f\u002a\u00c1\u0029\u0083\u0066\u00f1\u0095\u0050\u0010\u00a4"; -vm.warp(block.timestamp + 360624); + + vm.warp(block.timestamp + 360624); vm.roll(block.number + 23885); vm.prank(0x0000000000000000000000000000000000020000); target.addStrArr(input); + + vm.warp(block.timestamp + 322526); vm.roll(block.number + 0); vm.prank(0x0000000000000000000000000000000000020000); target.check_strDynArr(); + + } + + // Reproduced from: medusa-corpora/corpus-dyn-arr/test_results/dyn_array_variable_definition.json + function test_auto_check_boolArr_6() public { + bool[] memory input = new bool[](14); + input[0] = true; + input[1] = true; + input[2] = true; + input[3] = false; + input[4] = false; + input[5] = false; + input[6] = true; + input[7] = false; + input[8] = true; + input[9] = true; + input[10] = true; + input[11] = false; + input[12] = false; + input[13] = true; + + vm.warp(block.timestamp + 320182); + vm.roll(block.number + 23884); + vm.prank(0x0000000000000000000000000000000000010000); + target.addBoolArr(input); + + input = new bool[](14); + input[0] = true; + input[1] = true; + input[2] = true; + input[3] = false; + input[4] = false; + input[5] = false; + input[6] = true; + input[7] = false; + input[8] = true; + input[9] = true; + input[10] = true; + input[11] = false; + input[12] = false; + input[13] = true; + + vm.warp(block.timestamp + 320182); + vm.roll(block.number + 23884); + vm.prank(0x0000000000000000000000000000000000010000); + target.addBoolArr(input); + + + vm.warp(block.timestamp + 360622); + vm.roll(block.number + 47114); + vm.prank(0x0000000000000000000000000000000000010000); + target.check_boolArr(); + } } diff --git a/tests/test_data/test/fuzzing/actors/ActorDefault.sol b/tests/test_data/test/fuzzing/actors/ActorDefault.sol new file mode 100644 index 0000000..d4b8de3 --- /dev/null +++ b/tests/test_data/test/fuzzing/actors/ActorDefault.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// -------------------------------------------------------------------- +/// @notice This file was automatically generated using fuzz-utils +/// +/// -- [ Prerequisites ] +/// 1. The generated contracts depend on crytic/properties utilities +/// which need to be installed, this can be done by running: +/// `forge install crytic/properties` +/// 2. Absolute paths are used for contract inheritance, requiring +/// the main directory that contains the contracts to be added to +/// the Foundry remappings. This can be done by adding: +/// `directoryName/=directoryName/` to foundry.toml or remappings.txt + +import "properties/util/PropertiesHelper.sol"; +import "src/BasicTypes.sol"; +contract ActorDefault is PropertiesAsserts { + BasicTypes basictypes; + constructor(address _basictypes){ + basictypes = BasicTypes(_basictypes); + } + + // ------------------------------------- + // BasicTypes functions + // src/BasicTypes.sol + // ------------------------------------- + + function setBool(bool set) public { + basictypes.setBool(set); + } + + function check_bool() public { + basictypes.check_bool(); + } + + function setUint256(uint256 input) public { + basictypes.setUint256(input); + } + + function check_uint256() public { + basictypes.check_uint256(); + } + + function check_large_uint256() public { + basictypes.check_large_uint256(); + } + + function setInt256(int256 input) public { + basictypes.setInt256(input); + } + + function check_int256() public { + basictypes.check_int256(); + } + + function check_large_positive_int256() public { + basictypes.check_large_positive_int256(); + } + + function check_large_negative_int256() public { + basictypes.check_large_negative_int256(); + } + + function setAddress(address payable input) public { + basictypes.setAddress(input); + } + + function check_address() public { + basictypes.check_address(); + } + + function setString(string memory input) public { + basictypes.setString(input); + } + + function check_string() public { + basictypes.check_string(); + } + + function check_specific_string(string memory provided) public { + basictypes.check_specific_string(provided); + } + + function setBytes(bytes memory input) public { + basictypes.setBytes(input); + } + + function check_bytes() public { + basictypes.check_bytes(); + } + + function setCombination(bool bool_input, uint256 unsigned_input, int256 signed_input, address payable address_input, string memory str_input, bytes memory bytes_input) public { + basictypes.setCombination(bool_input, unsigned_input, signed_input, address_input, str_input, bytes_input); + } + + function check_combined_input() public { + basictypes.check_combined_input(); + } +} \ No newline at end of file diff --git a/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol b/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol new file mode 100644 index 0000000..ae0e436 --- /dev/null +++ b/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// -------------------------------------------------------------------- +/// @notice This file was automatically generated using fuzz-utils +/// +/// -- [ Prerequisites ] +/// 1. The generated contracts depend on crytic/properties utilities +/// which need to be installed, this can be done by running: +/// `forge install crytic/properties` +/// 2. Absolute paths are used for contract inheritance, requiring +/// the main directory that contains the contracts to be added to +/// the Foundry remappings. This can be done by adding: +/// `directoryName/=directoryName/` to foundry.toml or remappings.txt +/// +/// -- [ Running the fuzzers ] +/// * The below commands contain example values which you can modify based +/// on your needs. For further information on the configuration options +/// please reference the fuzzer documentation * +/// Echidna: echidna --contract DefaultHarness --test-mode assertion --test-limit 100000 --corpus-dir echidna-corpora/corpus-DefaultHarness +/// Medusa: medusa fuzz --target --assertion-mode --test-limit 100000 --deployment-order "DefaultHarness" --corpus-dir medusa-corpora/corpus-DefaultHarness +/// Foundry: forge test --match-contract DefaultHarness +/// -------------------------------------------------------------------- + +import "properties/util/PropertiesHelper.sol"; +import "src/BasicTypes.sol"; +import "../actors/ActorDefault.sol"; + +contract DefaultHarness is PropertiesAsserts { + BasicTypes basictypes; + ActorDefault[] Default_actors; + + constructor() { + basictypes = new BasicTypes(); + for(uint256 i; i < 3; i++) { + Default_actors.push(new ActorDefault(address(basictypes))); + } + } + + // ------------------------------------- + // ActorDefault functions + // test/fuzzing/actors/ActorDefault.sol + // ------------------------------------- + + function setBool(uint256 actorIndex, bool set) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setBool(set); + } + + function check_bool(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_bool(); + } + + function setUint256(uint256 actorIndex, uint256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setUint256(input); + } + + function check_uint256(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_uint256(); + } + + function check_large_uint256(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_uint256(); + } + + function setInt256(uint256 actorIndex, int256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setInt256(input); + } + + function check_int256(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_int256(); + } + + function check_large_positive_int256(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_positive_int256(); + } + + function check_large_negative_int256(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_negative_int256(); + } + + function setAddress(uint256 actorIndex, address payable input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setAddress(input); + } + + function check_address(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_address(); + } + + function setString(uint256 actorIndex, string memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setString(input); + } + + function check_string(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_string(); + } + + function check_specific_string(uint256 actorIndex, string memory provided) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_specific_string(provided); + } + + function setBytes(uint256 actorIndex, bytes memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setBytes(input); + } + + function check_bytes(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_bytes(); + } + + function setCombination(uint256 actorIndex, bool bool_input, uint256 unsigned_input, int256 signed_input, address payable address_input, string memory str_input, bytes memory bytes_input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setCombination(bool_input, unsigned_input, signed_input, address_input, str_input, bytes_input); + } + + function check_combined_input(uint256 actorIndex) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_combined_input(); + } +} \ No newline at end of file diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..aeabab9 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,215 @@ +""" Tests for generating fuzzing harnesses""" +from pathlib import Path +import copy +import subprocess +from slither import Slither +from slither.core.declarations.contract import Contract +from slither.core.declarations.function_contract import FunctionContract +from fuzz_utils.utils.remappings import find_remappings + +from fuzz_utils.template.HarnessGenerator import HarnessGenerator + +TEST_DATA_DIR = Path(__file__).resolve().parent / "test_data" +default_config = { + "name": "DefaultHarness", + "compilationPath": ".", + "targets": [], + "outputDir": "./test/fuzzing", + "actors": [ + { + "name": "Default", + "targets": [], + "number": 3, + "filters": { + "strict": False, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + }, + } + ], + "attacks": [], +} + + +def test_modifier_filtering() -> None: + """Test non-strict modifier filtering""" + filters = { + "strict": False, + "onlyModifiers": ["onlyOwner"], + "onlyPayable": False, + "onlyExternalCalls": [], + } + expected_functions = set(["iAmRestricted"]) + run_harness( + "ModifierHarness", + "Modifier", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_external_call_filtering() -> None: + """Test non-strict external call filtering""" + filters = { + "strict": False, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": ["transferFrom"], + } + expected_functions = set(["depositWithModifier", "depositNoModifier"]) + run_harness( + "TransferHarness", + "Transfer", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_payable_filtering() -> None: + """Test non-strict payable call filtering""" + filters = { + "strict": False, + "onlyModifiers": [], + "onlyPayable": True, + "onlyExternalCalls": [], + } + expected_functions = set(["iAmPayable"]) + run_harness( + "PayableHarness", + "Payable", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_modifier_and_external_call_filtering() -> None: + """Test non-strict modifier and external call filtering""" + filters = { + "strict": False, + "onlyModifiers": ["enforceTransferFrom"], + "onlyPayable": False, + "onlyExternalCalls": ["transferFrom"], + } + expected_functions = set(["depositWithModifier", "depositNoModifier"]) + run_harness( + "ModExHarness", + "ModEx", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_strict_modifier_and_external_call_filtering() -> None: + """Test strict modifier and external call filtering""" + filters = { + "strict": True, + "onlyModifiers": ["enforceTransferFrom"], + "onlyPayable": False, + "onlyExternalCalls": ["transferFrom"], + } + expected_functions = set(["depositWithModifier"]) + run_harness( + "StrictModExHarness", + "StrictModEx", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_multiple_external_calls_filtering() -> None: + """Test multiple external calls filtering""" + filters = { + "strict": True, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": ["transferFrom", "transfer"], + } + expected_functions = set(["depositWithModifier", "depositNoModifier", "withdraw"]) + run_harness( + "MulExHarness", + "MulEx", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def test_strict_multiple_external_calls_filtering() -> None: + """Test strict multiple external calls filtering""" + filters = { + "strict": True, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": ["transferFrom", "transfer"], + } + expected_functions = set(["depositWithModifier", "depositNoModifier", "withdraw"]) + run_harness( + "StrictMulExHarness", + "StrictMulEx", + "./src/Filtering.sol", + ["Filtering"], + filters, + expected_functions, + ) + + +def run_harness( + harness_name: str, + actor_name: str, + compilation_path: str, + targets: list, + filters: dict, + expected_functions: set[str], +) -> None: + """Sets up the HarnessGenerator""" + remappings = find_remappings(False) + config = copy.deepcopy(default_config) + slither = Slither(compilation_path) + + config["name"] = harness_name + config["compilationPath"] = compilation_path + config["targets"] = targets + config["actors"][0]["filters"] = filters # type: ignore[index] + config["actors"][0]["name"] = actor_name # type: ignore[index] + + generator = HarnessGenerator(config, slither, remappings) + generator.generate_templates() + + # Ensure the file can be compiled + subprocess.run(["forge", "build", "--build-info"], capture_output=True, text=True, check=True) + + # Ensure the harness only contains the functions we're expecting + slither = Slither(f"./test/fuzzing/harnesses/{harness_name}.sol") + target: Contract = generator.get_target_contract(slither, harness_name) + compare_with_declared_functions(target, set(expected_functions)) + + +def compare_with_declared_functions(target: Contract, expected: set) -> None: + """Compare expected functions set with declared set""" + declared: set = {entry.name for entry in target.functions_declared if include_function(entry)} + assert declared == expected + + +def include_function(function: FunctionContract) -> bool: + """Determines if a function should be included or not""" + if ( + function.pure + or function.view + or function.is_constructor + or function.is_fallback + or function.is_receive + ): + return False + return True