From 3e2aafaf3cfc2ad3f2ac2b3f4735db74d32a84fd Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 5 Mar 2024 11:43:34 +0100 Subject: [PATCH 01/22] add command for template generation along with basic agent template --- fuzz_utils/main.py | 101 ++++++++++++------- fuzz_utils/templates/HarnessGenerator.py | 115 ++++++++++++++++++++++ fuzz_utils/templates/foundry_templates.py | 43 ++++++++ 3 files changed, 223 insertions(+), 36 deletions(-) create mode 100644 fuzz_utils/templates/HarnessGenerator.py diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index 2b93933..2403ca3 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -13,6 +13,7 @@ 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.templates.HarnessGenerator import HarnessGenerator from fuzz_utils.utils.error_handler import handle_exit @@ -99,69 +100,97 @@ def main() -> None: # type: ignore[func-returns-value] 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( + subparsers = parser.add_subparsers(dest="command", help="sub-command help") + + # The command parser for generating unit tests + parser_generate = subparsers.add_parser( + "generate", help="Generate unit tests from fuzzer corpora sequences." + ) + parser_generate.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") + parser_generate.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( + parser_generate.add_argument( + "-c", "--contract", dest="target_contract", help="Define the contract name" + ) + parser_generate.add_argument( "-td", "--test-directory", dest="test_directory", help="Define the directory that contains the Foundry tests.", ) - parser.add_argument( + parser_generate.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( + parser_generate.add_argument( "-f", "--fuzzer", dest="selected_fuzzer", help="Define the fuzzer used. Valid inputs: 'echidna', 'medusa'", ) - parser.add_argument( + parser_generate.add_argument( "--version", help="displays the current version", version=require("fuzz-utils")[0].version, action="version", ) - args = parser.parse_args() - - missing_args = [arg for arg, value in vars(args).items() if value is None] - if missing_args: - parser.print_help() - handle_exit(f"\n* Missing required arguments: {', '.join(missing_args)}") + # The command parser for converting between corpus formats + parser_template = subparsers.add_parser( + "template", help="Generate a templated fuzzing harness." + ) + parser_template.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") + parser_template.add_argument( + "-c", "--contract", dest="target_contract", required=True, help="Define the contract name" + ) + parser_template.add_argument( + "-o", + "--output-dir", + dest="output_dir", + help="Define the output directory where the result will be saved.", + ) + args = parser.parse_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) - case "medusa": - fuzzer = Medusa(target_contract, corpus_dir, slither) - 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 - ) - foundry_test.create_poc() - CryticPrint().print_success("Done!") + + if args.command == "generate": + test_directory = args.test_directory + inheritance_path = args.inheritance_path + selected_fuzzer = args.selected_fuzzer.lower() + corpus_dir = args.corpus_dir + + fuzzer: Echidna | Medusa + + match selected_fuzzer: + case "echidna": + fuzzer = Echidna(target_contract, corpus_dir, slither) + case "medusa": + fuzzer = Medusa(target_contract, corpus_dir, slither) + case _: + handle_exit( + f"\n* The requested fuzzer {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 + ) + foundry_test.create_poc() + CryticPrint().print_success("Done!") + elif args.command == "template": + output_dir = args.output_dir + generator = HarnessGenerator(target_contract, slither) + generator.generate_harness() + # TODO + else: + parser.print_help() if __name__ == "__main__": diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py new file mode 100644 index 0000000..a900738 --- /dev/null +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -0,0 +1,115 @@ +""" Generates a template fuzzer harness for a smart contract target """ +# type: ignore[misc] # Ignores 'Any' input parameter +from slither import Slither +from slither.core.declarations.contract import Contract +from fuzz_utils.utils.error_handler import handle_exit +from fuzz_utils.templates.foundry_templates import templates +from dataclasses import dataclass +import jinja2 + + +@dataclass +class Actor: + """ Class for storing Actor contract data""" + name: str + constructor: str + dependencies: str + imports: list[str] + variables: list[str] + functions: list[str] + + +class HarnessGenerator: + """ + Handles the generation of Foundry test files from Echidna reproducers + """ + harness: dict + + def __init__(self, target_name: str, slither: Slither) -> None: + self.name = "Echidna" + self.target_name = target_name + self.slither = slither + self.target = self.get_target_contract(target_name) + self.harness = {} + + def generate_harness(self) -> None: + self._generate_actors([self.target_name], ["Basic"]) + + def _generate_actor(self, target_contract: Contract, name: str) -> Actor: + # Generate inheritance + imports: list[str] = [f'import "{target_contract.source_mapping.filename.relative}";'] + + # Generate variables + contract_name = target_contract.name + target_name = target_contract.name.lower() + variables: list[str] = [f"{contract_name} {target_name};"] + + # Generate constructor + f"{contract_name} {target_name};" + constructor = f"constructor(address _{target_name})" + "{\n" + constructor += f"{target_name} = {contract_name}(_{target_name});\n" + "}\n" + + # Generate Functions + entry_points = target_contract.functions_entry_points + functions: list[str] = [] + + for entry in entry_points: + # Don't create wrappers for pure and view functions + if entry.pure or entry.view or entry.is_constructor or entry.is_fallback or entry.is_receive: + continue + + # Determine if payable + payable = " payable" if entry.payable else "" + # Loop over function inputs + inputs_with_types = "" + if isinstance(entry.parameters, list): + inputs_with_type_list = [] + + for parameter in entry.parameters: + inputs_with_type_list.append(f"{parameter.type} {parameter.name}") + + 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" + definition += f"{target_name}.{entry.name}({', '.join([x.name for x in entry.parameters])});\n" + "}\n" + functions.append(definition) + + return Actor(name=name, constructor=constructor, imports=imports, dependencies="PropertiesAsserts", variables=variables, functions=functions) + + + def _generate_actors(self, targets: list[str], names: list[str]) -> None: + actor_contracts: list[str] = [] + + # Loop over all targets and generate an actor for each + for idx, target in enumerate(targets): + target_contract: Contract = self.get_target_contract(target) + # Generate the actor + actor: Actor = self._generate_actor(target_contract, names[idx]) + # Generate the templated string and append to list + template = jinja2.Template(templates["ACTOR"]) + actor_contracts.append(template.render(actor=actor)) + + # Save the files + print(actor_contracts) + + def get_target_contract(self, target_name: str) -> Contract: + """Finds and returns Slither Contract""" + contracts = self.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}`.") + diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index 0017f77..fc6aff3 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -67,6 +67,47 @@ } """ +__HARNESS_TEMPLATE: str = """// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +{%- for import in harness.imports -%} +{{import}} +{% endfor -%} + +{%- for actor in harness.actors -%} +{{actor.import}} +{% endfor -%} + +contract {{harness.name}} is {{inheritance}} { + {{harness.variables}} + + {{harness.constructor}} + + {{harness.functions}} +} +""" + +__ACTOR_TEMPLATE: str = """// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "crytic/properties/util/PropertiesHelper.sol"; +{%- for import in actor.imports %} +{{import}} +{% endfor -%} + +contract Actor{{actor.name}} is {{actor.dependencies}} { + {% for variable in actor.variables %} + {{variable}} + {% endfor -%} + + {{actor.constructor}} + {%- for function in actor.functions %} + {{function}} + {% endfor -%} +} + +""" + templates: dict = { "CONTRACT": __CONTRACT_TEMPLATE, "CALL": __CALL_TEMPLATE, @@ -74,4 +115,6 @@ "EMPTY_CALL": __EMPTY_CALL_TEMPLATE, "TEST": __TEST_TEMPLATE, "INTERFACE": __INTERFACE_TEMPLATE, + "HARNESS": __HARNESS_TEMPLATE, + "ACTOR": __ACTOR_TEMPLATE } From 88a69c5cd6fe696959739c66939cffca9bef05dc Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 5 Mar 2024 11:53:31 +0100 Subject: [PATCH 02/22] separate file saving into a util script --- fuzz_utils/templates/HarnessGenerator.py | 3 +++ fuzz_utils/utils/file_manager.py | 10 ++++++++++ 2 files changed, 13 insertions(+) create mode 100644 fuzz_utils/utils/file_manager.py diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index a900738..c36b240 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -1,7 +1,10 @@ """ Generates a template fuzzer harness for a smart contract target """ # type: ignore[misc] # Ignores 'Any' input parameter +import os + from slither import Slither from slither.core.declarations.contract import Contract +from fuzz_utils.utils.file_manager import check_and_create_dir, save_files from fuzz_utils.utils.error_handler import handle_exit from fuzz_utils.templates.foundry_templates import templates from dataclasses import dataclass diff --git a/fuzz_utils/utils/file_manager.py b/fuzz_utils/utils/file_manager.py new file mode 100644 index 0000000..3d26e63 --- /dev/null +++ b/fuzz_utils/utils/file_manager.py @@ -0,0 +1,10 @@ +""" Manages creation of files and directories """ +import os + +def check_and_create_dir(dirname: str) -> None: + if not os.path.exists(dirname): + os.makedirs(dirname) + +def save_files(path: str, file_name: str, suffix: str, content: str) -> None: + with open(f"{path}{file_name}{suffix}", "w", encoding="utf-8") as outfile: + outfile.write(content) \ No newline at end of file From 2653c62e279b7a4df9eb1741243f6a6aa66fffae Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Wed, 6 Mar 2024 10:44:23 +0100 Subject: [PATCH 03/22] add harness generation --- .gitmodules | 3 + fuzz_utils/main.py | 4 +- fuzz_utils/templates/HarnessGenerator.py | 378 ++++++++++++++++++---- fuzz_utils/templates/foundry_templates.py | 35 +- fuzz_utils/utils/file_manager.py | 18 +- tests/test_data/lib/properties | 1 + tests/test_data/remappings.txt | 8 + 7 files changed, 364 insertions(+), 83 deletions(-) create mode 160000 tests/test_data/lib/properties create mode 100644 tests/test_data/remappings.txt diff --git a/.gitmodules b/.gitmodules index 1f2488f..9b8a29f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [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 diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index 2403ca3..bfbc831 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -186,8 +186,8 @@ def main() -> None: # type: ignore[func-returns-value] CryticPrint().print_success("Done!") elif args.command == "template": output_dir = args.output_dir - generator = HarnessGenerator(target_contract, slither) - generator.generate_harness() + generator = HarnessGenerator(target_contract, slither, output_dir) + generator.generate_templates() # TODO else: parser.print_help() diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index c36b240..32f0d01 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -1,42 +1,162 @@ """ Generates a template fuzzer harness for a smart contract target """ # type: ignore[misc] # Ignores 'Any' input parameter import os +from dataclasses import dataclass from slither import Slither from slither.core.declarations.contract import Contract -from fuzz_utils.utils.file_manager import check_and_create_dir, save_files +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.file_manager import check_and_create_dirs, save_file from fuzz_utils.utils.error_handler import handle_exit from fuzz_utils.templates.foundry_templates import templates -from dataclasses import dataclass -import jinja2 @dataclass class Actor: - """ Class for storing Actor contract data""" + """Class for storing Actor contract data""" + name: str constructor: str dependencies: str + content: str + path: str + targets: list[Contract] imports: list[str] variables: list[str] functions: list[str] + contract: Contract | None + + 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 """ - harness: dict - def __init__(self, target_name: str, slither: Slither) -> None: - self.name = "Echidna" + def __init__(self, target_name: str, slither: Slither, output_dir: str) -> None: self.target_name = target_name self.slither = slither - self.target = self.get_target_contract(target_name) - self.harness = {} + self.target = self.get_target_contract(slither, target_name) + self.output_dir = output_dir + + def generate_templates(self) -> None: + """Generates the Harness and Actor fuzzing templates""" + # Check if directories exists, if not, create them + check_and_create_dirs(self.output_dir, ["utils", "actors", "harnesses"]) + # Generate the Actors + actors: list[Actor] = self._generate_actors([self.target_name], ["Basic"]) + # Generate the harness + self._generate_harness(actors, [self.target], f"{self.target_name}Harness") + + def _generate_harness( + self, actors: list[Actor], target_contracts: list[Contract], name: str + ) -> None: + # Generate inheritance and variables + imports: list[str] = [] + variables: list[str] = [] + + for contract in target_contracts: + imports.append(f'import "{contract.source_mapping.filename.relative}";') + variables.append(f"{contract.name} {contract.name.lower()};") + + # Generate actor arrays and imports + for actor in actors: + variables.append(f"Actor{actor.name}[] {actor.name}_actors;") + imports.append(f'import "{actor.path}";') + + # Generate constructor with contract and actor deployment + constructor = "constructor() {\n" + for contract in target_contracts: + 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.constructor.parameters: + constructor_arguments = ", ".join( + [x.name.strip("_") for x in actor.contract.constructor.parameters] + ) + constructor += ( + f" {actor.name}_actors.push(new Actor{actor.name}(address({constructor_arguments})));\n" + + " }\n" + ) + constructor += " }\n" + + # Generate dependencies + dependencies: str = "PropertiesAsserts" + + # TODO Generate functions + # Generate Functions + functions: list[str] = [] + for actor in actors: + temp_list = self._generate_harness_functions(actor) + functions.extend(temp_list) + + # Generate harness class + harness = Harness( + name=name, + constructor=constructor, + dependencies=dependencies, + content="", + path="", + targets=target_contracts, + actors=actors, + imports=imports, + variables=variables, + functions=functions, + ) + + # Generate harness content + template = jinja2.Template(templates["HARNESS"]) + harness_content = template.render(harness=harness) + harness.set_content(harness_content) - def generate_harness(self) -> None: - self._generate_actors([self.target_name], ["Basic"]) + # Save harness to file + harness_output_path = os.path.join(self.output_dir, "harnesses") + save_file(harness_output_path, f"/{name}", ".sol", harness_content) def _generate_actor(self, target_contract: Contract, name: str) -> Actor: # Generate inheritance @@ -48,71 +168,217 @@ def _generate_actor(self, target_contract: Contract, name: str) -> Actor: variables: list[str] = [f"{contract_name} {target_name};"] # Generate constructor - f"{contract_name} {target_name};" constructor = f"constructor(address _{target_name})" + "{\n" - constructor += f"{target_name} = {contract_name}(_{target_name});\n" + "}\n" + constructor += f" {target_name} = {contract_name}(_{target_name});\n" + " }\n" # Generate Functions - entry_points = target_contract.functions_entry_points + functions = self._generate_actor_functions(target_contract) + + return Actor( + name=name, + constructor=constructor, + imports=imports, + dependencies="PropertiesAsserts", + variables=variables, + functions=functions, + content="", + path="", + targets=[target_contract], + contract=None, + ) + + def _generate_actors(self, targets: list[str], names: list[str]) -> list[Actor]: + actor_contracts: list[Actor] = [] + + # Check if dir exists, if not, create it + actor_output_path = os.path.join(self.output_dir, "actors") + + # Loop over all targets and generate an actor for each + for idx, target in enumerate(targets): + target_contract: Contract = self.get_target_contract(self.slither, target) + # Generate the actor + actor: Actor = self._generate_actor(target_contract, names[idx]) + # Generate the templated string and append to list + template = jinja2.Template(templates["ACTOR"]) + actor_content = template.render(actor=actor) + # Save the file + save_file(actor_output_path, f"/Actor{names[idx]}", ".sol", actor_content) + + # Save content and path to Actor + actor.set_content(actor_content) + actor.set_path(f"../actors/Actor{names[idx]}.sol") + + actor_slither = Slither(f"{actor_output_path}/Actor{names[idx]}.sol") + actor.set_contract(self.get_target_contract(actor_slither, f"Actor{names[idx]}")) + + actor_contracts.append(actor) + # Return Actors list + return actor_contracts + + def _generate_actor_functions(self, target_contract: Contract) -> list[str]: functions: list[str] = [] + contracts: list[Contract] = [target_contract] + if len(target_contract.inheritance) > 0: + contracts = set(contracts) | set(target_contract.inheritance) + + for contract in contracts: + if not contract.functions_declared or contract.is_interface: + continue - for entry in entry_points: - # Don't create wrappers for pure and view functions - if entry.pure or entry.view or entry.is_constructor or entry.is_fallback or entry.is_receive: + has_public_fn: bool = False + for entry in contract.functions_declared: + if (entry.visibility in ("public", "external")) and not entry.is_constructor: + has_public_fn = True + if not has_public_fn: continue - # Determine if payable - payable = " payable" if entry.payable else "" - # Loop over function inputs - inputs_with_types = "" - if isinstance(entry.parameters, list): - inputs_with_type_list = [] + functions.append( + f"// -------------------------------------\n // {contract.name} functions\n // -------------------------------------\n" + ) - for parameter in entry.parameters: - inputs_with_type_list.append(f"{parameter.type} {parameter.name}") - - inputs_with_types = ", ".join(inputs_with_type_list) - - # Loop over return types - return_types = "" - if isinstance(entry.return_type, list): - returns_list = [] + for entry in contract.functions_declared: + # Don't create wrappers for pure and view functions + if ( + entry.pure + or entry.view + or entry.is_constructor + or entry.is_fallback + or entry.is_receive + ): + continue + if entry.visibility not in ("public", "external"): + continue - for return_type in entry.return_type: - returns_list.append(f"{return_type.type}") + # 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 = [] - return_types = f" returns ({', '.join(returns_list)})" + 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}" + ) - # Generate function definition - definition = f"function {entry.name}({inputs_with_types}) {entry.visibility}{payable}{return_types}" + " {\n" - definition += f"{target_name}.{entry.name}({', '.join([x.name for x in entry.parameters])});\n" + "}\n" - functions.append(definition) - - return Actor(name=name, constructor=constructor, imports=imports, dependencies="PropertiesAsserts", variables=variables, functions=functions) + inputs_with_types: str = ", ".join(inputs_with_type_list) + # Loop over return types + return_types = "" + if isinstance(entry.return_type, list): + returns_list = [] - def _generate_actors(self, targets: list[str], names: list[str]) -> None: - actor_contracts: list[str] = [] + for return_type in entry.return_type: + returns_list.append(f"{return_type.type}") - # Loop over all targets and generate an actor for each - for idx, target in enumerate(targets): - target_contract: Contract = self.get_target_contract(target) - # Generate the actor - actor: Actor = self._generate_actor(target_contract, names[idx]) - # Generate the templated string and append to list - template = jinja2.Template(templates["ACTOR"]) - actor_contracts.append(template.render(actor=actor)) + return_types = f" returns ({', '.join(returns_list)})" + + # Generate function definition + definition = ( + f"function {entry.name}({inputs_with_types}) {entry.visibility}{payable}{return_types}" + + " {\n" + ) + definition += ( + f" {self.target_name.lower()}.{entry.name}({', '.join([ unused_var if not x.name else x.name for x in entry.parameters])});\n" + + " }\n" + ) + functions.append(definition) - # Save the files - print(actor_contracts) + return functions - def get_target_contract(self, target_name: str) -> Contract: + def _generate_harness_functions(self, actor: Actor) -> list[str]: + functions: list[str] = [] + contracts: list[Contract] = [actor.contract] + + for contract in contracts: + if not contract.functions_declared or contract.is_interface: + continue + print("contract", contract.name) + + has_public_fn: bool = False + for entry in contract.functions_declared: + if (entry.visibility in ("public", "external")) and not entry.is_constructor: + has_public_fn = True + if not has_public_fn: + continue + + functions.append( + f"// -------------------------------------\n // {contract.name} functions\n // -------------------------------------\n" + ) + + for entry in contract.functions_declared: + # Don't create wrappers for pure and view functions + if ( + entry.pure + or entry.view + or entry.is_constructor + or entry.is_fallback + or entry.is_receive + ): + continue + if entry.visibility not in ("public", "external"): + continue + + # Determine if payable + payable = " payable" if entry.payable else "" + # Loop over function inputs + inputs_with_types = "" + if isinstance(entry.parameters, list): + inputs_with_type_list = ["uint256 actorIndex"] + + 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}") + + inputs_with_types: str = ", ".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)})" + + actor_array_var = f"{actor.name}_actors" + # Generate function definition + definition = ( + f"function {entry.name}({inputs_with_types}) {entry.visibility}{payable}{return_types}" + + " {\n" + ) + definition += f" {contract.name} selectedActor = {actor_array_var}[clampBetween(actorIndex, 0, {actor_array_var}.length - 1)];\n" + definition += ( + f" selectedActor.{entry.name}({', '.join([x.name for x in entry.parameters if x.name])});\n" + + " }\n" + ) + functions.append(definition) + + return functions + + def get_target_contract(self, slither: Slither, target_name: str) -> Contract: """Finds and returns Slither Contract""" - contracts = self.slither.get_contract_from_name(target_name) + 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}`.") - diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index fc6aff3..67b0a05 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -68,44 +68,41 @@ """ __HARNESS_TEMPLATE: str = """// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity ^0.8.0; -{%- for import in harness.imports -%} +import "properties/util/PropertiesHelper.sol"; +{% for import in harness.imports -%} {{import}} -{% endfor -%} - -{%- for actor in harness.actors -%} -{{actor.import}} -{% endfor -%} - -contract {{harness.name}} is {{inheritance}} { - {{harness.variables}} - +{% endfor %} +contract {{harness.name}} is {{harness.dependencies}} { + {% for variable in harness.variables -%} + {{variable}} + {% endfor %} {{harness.constructor}} - - {{harness.functions}} + {%- for function in harness.functions %} + {{function}} + {%- endfor -%} } """ __ACTOR_TEMPLATE: str = """// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity ^0.8.0; -import "crytic/properties/util/PropertiesHelper.sol"; +import "properties/util/PropertiesHelper.sol"; {%- for import in actor.imports %} {{import}} {% endfor -%} contract Actor{{actor.name}} is {{actor.dependencies}} { - {% for variable in actor.variables %} + {%- for variable in actor.variables %} {{variable}} {% endfor -%} {{actor.constructor}} {%- for function in actor.functions %} {{function}} - {% endfor -%} + {%- endfor -%} } - """ templates: dict = { @@ -116,5 +113,5 @@ "TEST": __TEST_TEMPLATE, "INTERFACE": __INTERFACE_TEMPLATE, "HARNESS": __HARNESS_TEMPLATE, - "ACTOR": __ACTOR_TEMPLATE + "ACTOR": __ACTOR_TEMPLATE, } diff --git a/fuzz_utils/utils/file_manager.py b/fuzz_utils/utils/file_manager.py index 3d26e63..4eee052 100644 --- a/fuzz_utils/utils/file_manager.py +++ b/fuzz_utils/utils/file_manager.py @@ -1,10 +1,16 @@ """ Manages creation of files and directories """ import os -def check_and_create_dir(dirname: str) -> None: - if not os.path.exists(dirname): - os.makedirs(dirname) - -def save_files(path: str, file_name: str, suffix: str, content: str) -> None: + +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) \ No newline at end of file + outfile.write(content) 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/remappings.txt b/tests/test_data/remappings.txt new file mode 100644 index 0000000..bc79e96 --- /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/properties/lib/solmate/src/ +src/=src/ From b73eef1c754a48cfba201a7a1493af11343d7f8b Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Wed, 6 Mar 2024 16:38:45 +0100 Subject: [PATCH 04/22] Add default output dir and comments to generated contracts --- fuzz_utils/main.py | 6 +++- fuzz_utils/templates/HarnessGenerator.py | 9 ++++-- fuzz_utils/templates/foundry_templates.py | 34 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index bfbc831..f4cb54e 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -185,7 +185,11 @@ def main() -> None: # type: ignore[func-returns-value] foundry_test.create_poc() CryticPrint().print_success("Done!") elif args.command == "template": - output_dir = args.output_dir + if args.output_dir: + output_dir = os.path.join("./test", args.output_dir) + else: + output_dir = os.path.join("./test", "fuzzing") + generator = HarnessGenerator(target_contract, slither, output_dir) generator.generate_templates() # TODO diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index 32f0d01..90daa03 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -148,6 +148,10 @@ def _generate_harness( variables=variables, functions=functions, ) + + # Get output path + harness_output_path = os.path.join(self.output_dir, "harnesses") + harness.set_path(f"{harness_output_path}/{name}.sol") # Generate harness content template = jinja2.Template(templates["HARNESS"]) @@ -155,7 +159,6 @@ def _generate_harness( harness.set_content(harness_content) # Save harness to file - harness_output_path = os.path.join(self.output_dir, "harnesses") save_file(harness_output_path, f"/{name}", ".sol", harness_content) def _generate_actor(self, target_contract: Contract, name: str) -> Actor: @@ -233,7 +236,7 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: continue functions.append( - f"// -------------------------------------\n // {contract.name} functions\n // -------------------------------------\n" + f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" ) for entry in contract.functions_declared: @@ -312,7 +315,7 @@ def _generate_harness_functions(self, actor: Actor) -> list[str]: continue functions.append( - f"// -------------------------------------\n // {contract.name} functions\n // -------------------------------------\n" + f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" ) for entry in contract.functions_declared: diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index 67b0a05..c5909a4 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -70,6 +70,27 @@ __HARNESS_TEMPLATE: 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 +/// +/// -- [ 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 {{harness.path}} --contract {{harness.name}} --test-mode assertion --test-limit 100000 --corpus-dir echidna-corpora/corpus-{{harness.name}} +/// Medusa: medusa fuzz --target {{harness.path}} --assertion-mode --test-limit 100000 --deployment-order "{{harness.name}}" --corpus-dir medusa-corpora/corpus-{{harness.name}} +/// Foundry: forge test --match-contract {{harness.name}} +/// -------------------------------------------------------------------- + import "properties/util/PropertiesHelper.sol"; {% for import in harness.imports -%} {{import}} @@ -88,6 +109,19 @@ __ACTOR_TEMPLATE: 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 +/// -------------------------------------------------------------------- + import "properties/util/PropertiesHelper.sol"; {%- for import in actor.imports %} {{import}} From d7311ee3dac3758604e47b85d4c21fa8be056818 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Thu, 7 Mar 2024 13:14:57 +0100 Subject: [PATCH 05/22] add config option, actors with multiple targets --- fuzz_utils/main.py | 14 ++- fuzz_utils/templates/HarnessGenerator.py | 107 +++++++++++++++-------- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index f4cb54e..cdc2fca 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -142,9 +142,9 @@ def main() -> None: # type: ignore[func-returns-value] parser_template = subparsers.add_parser( "template", help="Generate a templated fuzzing harness." ) - parser_template.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") + parser_template.add_argument("file_path", help="Path to the Solidity contract.") parser_template.add_argument( - "-c", "--contract", dest="target_contract", required=True, help="Define the contract name" + "-c", "--contracts", dest="target_contracts", nargs='+', help="Define a list of target contracts for the harness." ) parser_template.add_argument( "-o", @@ -152,10 +152,10 @@ def main() -> None: # type: ignore[func-returns-value] dest="output_dir", help="Define the output directory where the result will be saved.", ) + parser_template.add_argument("--config", dest="config", help="Define the location of the config file.") args = parser.parse_args() file_path = args.file_path - target_contract = args.target_contract slither = Slither(file_path) if args.command == "generate": @@ -163,6 +163,7 @@ def main() -> None: # type: ignore[func-returns-value] inheritance_path = args.inheritance_path selected_fuzzer = args.selected_fuzzer.lower() corpus_dir = args.corpus_dir + target_contract = args.target_contract fuzzer: Echidna | Medusa @@ -185,12 +186,17 @@ def main() -> None: # type: ignore[func-returns-value] foundry_test.create_poc() CryticPrint().print_success("Done!") elif args.command == "template": + 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: + config = json.load(readFile) + print(config) - generator = HarnessGenerator(target_contract, slither, output_dir) + generator = HarnessGenerator(file_path, args.target_contracts, slither, output_dir, config) generator.generate_templates() # TODO else: diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index 90daa03..2444706 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -69,25 +69,46 @@ class HarnessGenerator: """ Handles the generation of Foundry test files from Echidna reproducers """ + config: dict = {"name": "Default", "compilationPath": ".", "targets": [], "outputDir": "./test/fuzzing", "actors": []} + + def __init__(self, compilation_path: str, targets: list[str], slither: Slither, output_dir: str, config: dict) -> None: + for key, value in config.items(): + if key == "actors": + for actor in config[key]: + if not "name" in actor or not "targets" in actor: + handle_exit("Actor is missing attributes") + if not actor["name"] or not actor["targets"]: + handle_exit("One or multiple actor attributes are are empty") + + if key in self.config and value: + self.config[key] = value + + if targets: + self.config["targets"] = targets + if output_dir: + self.config["outputDir"] = output_dir + if compilation_path: + self.config["compilationPath"] = compilation_path + + print("Using config", self.config) - def __init__(self, target_name: str, slither: Slither, output_dir: str) -> None: - self.target_name = target_name self.slither = slither - self.target = self.get_target_contract(slither, target_name) - self.output_dir = output_dir + 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""" # Check if directories exists, if not, create them check_and_create_dirs(self.output_dir, ["utils", "actors", "harnesses"]) # Generate the Actors - actors: list[Actor] = self._generate_actors([self.target_name], ["Basic"]) + actors: list[Actor] = self._generate_actors() # Generate the harness - self._generate_harness(actors, [self.target], f"{self.target_name}Harness") + self._generate_harness(actors) def _generate_harness( - self, actors: list[Actor], target_contracts: list[Contract], name: str + self, actors: list[Actor] ) -> None: + target_contracts = self.targets # Generate inheritance and variables imports: list[str] = [] variables: list[str] = [] @@ -117,10 +138,10 @@ def _generate_harness( constructor_arguments = "" if actor.contract.constructor.parameters: constructor_arguments = ", ".join( - [x.name.strip("_") for x in actor.contract.constructor.parameters] + [f"address({x.name.strip('_')})" for x in actor.contract.constructor.parameters] ) constructor += ( - f" {actor.name}_actors.push(new Actor{actor.name}(address({constructor_arguments})));\n" + f" {actor.name}_actors.push(new Actor{actor.name}({constructor_arguments}));\n" + " }\n" ) constructor += " }\n" @@ -137,7 +158,7 @@ def _generate_harness( # Generate harness class harness = Harness( - name=name, + name=self.config["name"], constructor=constructor, dependencies=dependencies, content="", @@ -151,7 +172,7 @@ def _generate_harness( # Get output path harness_output_path = os.path.join(self.output_dir, "harnesses") - harness.set_path(f"{harness_output_path}/{name}.sol") + harness.set_path(f"{harness_output_path}/{self.config['name']}.sol") # Generate harness content template = jinja2.Template(templates["HARNESS"]) @@ -159,23 +180,32 @@ def _generate_harness( harness.set_content(harness_content) # Save harness to file - save_file(harness_output_path, f"/{name}", ".sol", harness_content) + save_file(harness_output_path, f"/{self.config['name']}", ".sol", harness_content) - def _generate_actor(self, target_contract: Contract, name: str) -> Actor: - # Generate inheritance - imports: list[str] = [f'import "{target_contract.source_mapping.filename.relative}";'] + def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: + imports: list[str] = [] + variables: list[str] = [] + functions: list[str] = [] + constructor_args: list[str] = [] + constructor = "" - # Generate variables - contract_name = target_contract.name - target_name = target_contract.name.lower() - variables: list[str] = [f"{contract_name} {target_name};"] + for contract in target_contracts: + # Generate inheritance + imports.append(f'import "{contract.source_mapping.filename.relative}";') - # Generate constructor - constructor = f"constructor(address _{target_name})" + "{\n" - constructor += f" {target_name} = {contract_name}(_{target_name});\n" + " }\n" + # Generate variables + contract_name = contract.name + target_name = contract.name.lower() + variables.append(f"{contract_name} {target_name};") - # Generate Functions - functions = self._generate_actor_functions(target_contract) + # Generate constructor + constructor_args.append(f"address _{target_name}") + constructor += f" {target_name} = {contract_name}(_{target_name});\n" + + # Generate Functions + functions.extend(self._generate_actor_functions(contract)) + + constructor = f"constructor({', '.join(constructor_args)})" + "{\n" + constructor + " }\n" return Actor( name=name, @@ -186,35 +216,38 @@ def _generate_actor(self, target_contract: Contract, name: str) -> Actor: functions=functions, content="", path="", - targets=[target_contract], + targets=target_contracts, contract=None, ) - def _generate_actors(self, targets: list[str], names: list[str]) -> list[Actor]: + def _generate_actors(self) -> list[Actor]: actor_contracts: list[Actor] = [] # Check if dir exists, if not, create it actor_output_path = os.path.join(self.output_dir, "actors") - # Loop over all targets and generate an actor for each - for idx, target in enumerate(targets): - target_contract: Contract = self.get_target_contract(self.slither, target) - # Generate the actor - actor: Actor = self._generate_actor(target_contract, names[idx]) + # Loop over actors list + for actor_config in self.config["actors"]: + print("config", actor_config) + name = actor_config["name"] + target_contracts: list[Contract] = [self.get_target_contract(self.slither, contract) for contract in actor_config["targets"]] + # Generate the Actor + actor: Actor = self._generate_actor(target_contracts, name) # Generate the templated string and append to list template = jinja2.Template(templates["ACTOR"]) actor_content = template.render(actor=actor) # Save the file - save_file(actor_output_path, f"/Actor{names[idx]}", ".sol", actor_content) + save_file(actor_output_path, f"/Actor{name}", ".sol", actor_content) # Save content and path to Actor actor.set_content(actor_content) - actor.set_path(f"../actors/Actor{names[idx]}.sol") + actor.set_path(f"../actors/Actor{name}.sol") - actor_slither = Slither(f"{actor_output_path}/Actor{names[idx]}.sol") - actor.set_contract(self.get_target_contract(actor_slither, f"Actor{names[idx]}")) + 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 @@ -230,7 +263,7 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: has_public_fn: bool = False for entry in contract.functions_declared: - if (entry.visibility in ("public", "external")) and not entry.is_constructor: + if (entry.visibility in ("public", "external")) and not entry.view and not entry.pure and not entry.is_constructor: has_public_fn = True if not has_public_fn: continue @@ -291,7 +324,7 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: + " {\n" ) definition += ( - f" {self.target_name.lower()}.{entry.name}({', '.join([ unused_var if not x.name else x.name for x in entry.parameters])});\n" + f" {target_contract.name.lower()}.{entry.name}({', '.join([ unused_var if not x.name else x.name for x in entry.parameters])});\n" + " }\n" ) functions.append(definition) From 3b3564c1a191c87aa25917ce8f632abb494ef84c Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Fri, 8 Mar 2024 11:08:19 +0100 Subject: [PATCH 06/22] print progress messages --- fuzz_utils/main.py | 14 ++-- fuzz_utils/templates/HarnessGenerator.py | 83 ++++++++++++++++++------ 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index cdc2fca..ee9b45f 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -95,6 +95,7 @@ def create_poc(self) -> str: return test_file_str +# pylint: disable=too-many-locals def main() -> None: # type: ignore[func-returns-value] """The main entry point""" parser = argparse.ArgumentParser( @@ -144,7 +145,11 @@ def main() -> None: # type: ignore[func-returns-value] ) parser_template.add_argument("file_path", help="Path to the Solidity contract.") parser_template.add_argument( - "-c", "--contracts", dest="target_contracts", nargs='+', help="Define a list of target contracts for the harness." + "-c", + "--contracts", + dest="target_contracts", + nargs="+", + help="Define a list of target contracts for the harness.", ) parser_template.add_argument( "-o", @@ -152,10 +157,12 @@ def main() -> None: # type: ignore[func-returns-value] dest="output_dir", help="Define the output directory where the result will be saved.", ) - parser_template.add_argument("--config", dest="config", help="Define the location of the config file.") - + parser_template.add_argument( + "--config", dest="config", help="Define the location of the config file." + ) args = parser.parse_args() file_path = args.file_path + CryticPrint().print_information("Running Slither...") slither = Slither(file_path) if args.command == "generate": @@ -194,7 +201,6 @@ def main() -> None: # type: ignore[func-returns-value] if args.config: with open(args.config, "r", encoding="utf-8") as readFile: config = json.load(readFile) - print(config) generator = HarnessGenerator(file_path, args.target_contracts, slither, output_dir, config) generator.generate_templates() diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index 2444706..dd2966f 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -8,11 +8,12 @@ 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.foundry_templates import templates - +# pylint: disable=too-many-instance-attributes @dataclass class Actor: """Class for storing Actor contract data""" @@ -69,16 +70,30 @@ class HarnessGenerator: """ Handles the generation of Foundry test files from Echidna reproducers """ - config: dict = {"name": "Default", "compilationPath": ".", "targets": [], "outputDir": "./test/fuzzing", "actors": []} - def __init__(self, compilation_path: str, targets: list[str], slither: Slither, output_dir: str, config: dict) -> None: + config: dict = { + "name": "Default", + "compilationPath": ".", + "targets": [], + "outputDir": "./test/fuzzing", + "actors": [], + } + + def __init__( + self, + compilation_path: str, + targets: list[str], + slither: Slither, + output_dir: str, + config: dict, + ) -> None: for key, value in config.items(): if key == "actors": for actor in config[key]: if not "name" in actor or not "targets" in actor: handle_exit("Actor is missing attributes") if not actor["name"] or not actor["targets"]: - handle_exit("One or multiple actor attributes are are empty") + handle_exit("One or multiple actor attributes are empty") if key in self.config and value: self.config[key] = value @@ -89,25 +104,37 @@ def __init__(self, compilation_path: str, targets: list[str], slither: Slither, self.config["outputDir"] = output_dir if compilation_path: self.config["compilationPath"] = compilation_path - - print("Using config", self.config) + if not self.config["actors"]: + self.config["actors"] = [{"name": "Default", "targets": self.config["targets"]}] + + 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.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"]) # Generate the Actors actors: list[Actor] = self._generate_actors() + CryticPrint().print_success(" Actors generated!") # Generate the harness self._generate_harness(actors) + 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]) -> None: + CryticPrint().print_information(f"Generating {self.config['name']} Harness") - def _generate_harness( - self, actors: list[Actor] - ) -> None: target_contracts = self.targets # Generate inheritance and variables imports: list[str] = [] @@ -136,7 +163,7 @@ def _generate_harness( for actor in actors: constructor += " for(uint256 i; i < 3; i++) {\n" constructor_arguments = "" - if actor.contract.constructor.parameters: + if hasattr(actor.contract.constructor, "parameters"): constructor_arguments = ", ".join( [f"address({x.name.strip('_')})" for x in actor.contract.constructor.parameters] ) @@ -169,7 +196,7 @@ def _generate_harness( variables=variables, functions=functions, ) - + # Get output path harness_output_path = os.path.join(self.output_dir, "harnesses") harness.set_path(f"{harness_output_path}/{self.config['name']}.sol") @@ -204,8 +231,10 @@ def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: # Generate Functions functions.extend(self._generate_actor_functions(contract)) - - constructor = f"constructor({', '.join(constructor_args)})" + "{\n" + constructor + " }\n" + + constructor = ( + f"constructor({', '.join(constructor_args)})" + "{\n" + constructor + " }\n" + ) return Actor( name=name, @@ -221,6 +250,7 @@ def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: ) def _generate_actors(self) -> list[Actor]: + CryticPrint().print_information("Generating Actors:") actor_contracts: list[Actor] = [] # Check if dir exists, if not, create it @@ -228,9 +258,13 @@ def _generate_actors(self) -> list[Actor]: # Loop over actors list for actor_config in self.config["actors"]: - print("config", actor_config) name = actor_config["name"] - target_contracts: list[Contract] = [self.get_target_contract(self.slither, contract) for contract in actor_config["targets"]] + 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, name) # Generate the templated string and append to list @@ -251,11 +285,12 @@ def _generate_actors(self) -> list[Actor]: # Return Actors list return actor_contracts + # pylint: disable=too-many-locals,too-many-branches,no-self-use def _generate_actor_functions(self, target_contract: Contract) -> list[str]: functions: list[str] = [] contracts: list[Contract] = [target_contract] if len(target_contract.inheritance) > 0: - contracts = set(contracts) | set(target_contract.inheritance) + contracts = list(set(contracts) | set(target_contract.inheritance)) for contract in contracts: if not contract.functions_declared or contract.is_interface: @@ -263,7 +298,12 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: has_public_fn: bool = False 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: + if ( + (entry.visibility in ("public", "external")) + and not entry.view + and not entry.pure + and not entry.is_constructor + ): has_public_fn = True if not has_public_fn: continue @@ -306,7 +346,7 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: f"{parameter.type}{location} {parameter.name if parameter.name else unused_var}" ) - inputs_with_types: str = ", ".join(inputs_with_type_list) + inputs_with_types = ", ".join(inputs_with_type_list) # Loop over return types return_types = "" @@ -331,6 +371,7 @@ def _generate_actor_functions(self, target_contract: Contract) -> list[str]: return functions + # pylint: disable=too-many-locals,no-self-use,too-many-branches def _generate_harness_functions(self, actor: Actor) -> list[str]: functions: list[str] = [] contracts: list[Contract] = [actor.contract] @@ -338,7 +379,6 @@ def _generate_harness_functions(self, actor: Actor) -> list[str]: for contract in contracts: if not contract.functions_declared or contract.is_interface: continue - print("contract", contract.name) has_public_fn: bool = False for entry in contract.functions_declared: @@ -382,7 +422,7 @@ def _generate_harness_functions(self, actor: Actor) -> list[str]: location = " payable" inputs_with_type_list.append(f"{parameter.type}{location} {parameter.name}") - inputs_with_types: str = ", ".join(inputs_with_type_list) + inputs_with_types = ", ".join(inputs_with_type_list) # Loop over return types return_types = "" @@ -409,6 +449,7 @@ def _generate_harness_functions(self, actor: Actor) -> list[str]: return functions + # 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) From bd76c867cafdeef175104420d6d93e5ea8a43adc Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Wed, 20 Mar 2024 17:48:59 +0100 Subject: [PATCH 07/22] add config, attack generation, lint and format files, refactor to reduce code duplication --- .gitmodules | 3 + fuzz_utils/main.py | 67 ++- fuzz_utils/templates/HarnessGenerator.py | 494 +++++++++++++--------- fuzz_utils/templates/harness_templates.py | 156 +++++++ tests/test_data/lib/solmate | 1 + tests/test_data/remappings.txt | 2 +- tests/test_data/template.json | 8 + 7 files changed, 538 insertions(+), 193 deletions(-) create mode 100644 fuzz_utils/templates/harness_templates.py create mode 160000 tests/test_data/lib/solmate create mode 100644 tests/test_data/template.json diff --git a/.gitmodules b/.gitmodules index 9b8a29f..24909ad 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [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/fuzz_utils/main.py b/fuzz_utils/main.py index ee9b45f..901a58d 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -3,6 +3,8 @@ import sys import json import argparse +import subprocess +import re import jinja2 from pkg_resources import require @@ -95,6 +97,62 @@ def create_poc(self) -> str: return test_file_str +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 + ] + + # pylint: disable=too-many-locals def main() -> None: # type: ignore[func-returns-value] """The main entry point""" @@ -202,9 +260,14 @@ def main() -> None: # type: ignore[func-returns-value] with open(args.config, "r", encoding="utf-8") as readFile: config = json.load(readFile) - generator = HarnessGenerator(file_path, args.target_contracts, slither, output_dir, config) + # Check if dependencies are installed + include_attacks = bool("attacks" in config and len(config["attacks"]) > 0) + remappings = find_remappings(include_attacks) + + generator = HarnessGenerator( + file_path, args.target_contracts, slither, output_dir, config, remappings + ) generator.generate_templates() - # TODO else: parser.print_help() diff --git a/fuzz_utils/templates/HarnessGenerator.py b/fuzz_utils/templates/HarnessGenerator.py index dd2966f..83e22f9 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/templates/HarnessGenerator.py @@ -5,13 +5,14 @@ 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.foundry_templates import templates +from fuzz_utils.templates.harness_templates import templates # pylint: disable=too-many-instance-attributes @dataclass @@ -23,11 +24,12 @@ class Actor: dependencies: str content: str path: str + number: int targets: list[Contract] imports: list[str] variables: list[str] functions: list[str] - contract: Contract | None + contract: Contract def set_content(self, content: str) -> None: """Set the content field of the class""" @@ -72,11 +74,24 @@ class HarnessGenerator: """ config: dict = { - "name": "Default", + "name": "DefaultHarness", "compilationPath": ".", "targets": [], "outputDir": "./test/fuzzing", - "actors": [], + "actors": [ + { + "name": "Default", + "targets": [], + "number": 3, + "filters": { + "strict": False, + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + }, + } + ], + "attacks": [], } def __init__( @@ -86,17 +101,30 @@ def __init__( slither: Slither, output_dir: str, config: dict, + remappings: dict, ) -> None: for key, value in config.items(): + if key in self.config and value: + self.config[key] = value + # TODO add checks for attack config if key == "actors": - for actor in config[key]: + for idx, actor in enumerate(config[key]): if not "name" in actor or not "targets" in actor: handle_exit("Actor is missing attributes") - if not actor["name"] or not actor["targets"]: - handle_exit("One or multiple actor attributes are empty") - - if key in self.config and value: - self.config[key] = value + if not "number" in actor: + self.config["actors"][idx]["number"] = 3 + CryticPrint().print_warning( + "Missing number argument in actor, using 3 as default." + ) + if not "filters" in actor: + self.config["actors"][idx]["filters"] = { + "onlyModifiers": [], + "onlyPayable": False, + "onlyExternalCalls": [], + } + CryticPrint().print_warning( + "Missing filters argument in actor, using none as default." + ) if targets: self.config["targets"] = targets @@ -104,8 +132,10 @@ def __init__( self.config["outputDir"] = output_dir if compilation_path: self.config["compilationPath"] = compilation_path - if not self.config["actors"]: - self.config["actors"] = [{"name": "Default", "targets": self.config["targets"]}] + if "actors" not in config: + self.config["actors"][0]["targets"] = self.config["targets"] + if remappings: + self.remappings = remappings CryticPrint().print_no_format(f" Config: {self.config}") @@ -122,36 +152,43 @@ def generate_templates(self) -> None: ) # Check if directories exists, if not, create them - check_and_create_dirs(self.output_dir, ["utils", "actors", "harnesses"]) + 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) + 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]) -> None: + def _generate_harness(self, actors: list[Actor], attacks: list[Actor]) -> None: CryticPrint().print_information(f"Generating {self.config['name']} Harness") - target_contracts = self.targets # Generate inheritance and variables imports: list[str] = [] variables: list[str] = [] - for contract in target_contracts: + for contract in self.targets: imports.append(f'import "{contract.source_mapping.filename.relative}";') variables.append(f"{contract.name} {contract.name.lower()};") - # Generate actor arrays and imports + # 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 constructor with contract and actor deployment + # 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 target_contracts: + for contract in self.targets: inputs: list[str] = [] if contract.constructor: constructor_parameters = contract.constructor.parameters @@ -160,10 +197,11 @@ def _generate_harness(self, actors: list[Actor]) -> None: 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 hasattr(actor.contract.constructor, "parameters"): + if actor.contract and hasattr(actor.contract.constructor, "parameters"): constructor_arguments = ", ".join( [f"address({x.name.strip('_')})" for x in actor.contract.constructor.parameters] ) @@ -171,16 +209,31 @@ def _generate_harness(self, actors: list[Actor]) -> None: f" {actor.name}_actors.push(new Actor{actor.name}({constructor_arguments}));\n" + " }\n" ) - constructor += " }\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" - # TODO Generate functions # Generate Functions functions: list[str] = [] for actor in actors: - temp_list = self._generate_harness_functions(actor) + 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 @@ -190,26 +243,60 @@ def _generate_harness(self, actors: list[Actor]) -> None: dependencies=dependencies, content="", path="", - targets=target_contracts, + targets=self.targets, actors=actors, imports=imports, variables=variables, functions=functions, ) - # Get output path - harness_output_path = os.path.join(self.output_dir, "harnesses") - harness.set_path(f"{harness_output_path}/{self.config['name']}.sol") + content, path = self._render_template( + templates["HARNESS"], "harnesses", self.config["name"], harness + ) + harness.set_content(content) + harness.set_path(path) - # Generate harness content - template = jinja2.Template(templates["HARNESS"]) - harness_content = template.render(harness=harness) - harness.set_content(harness_content) + def _generate_attacks(self) -> list[Actor]: + CryticPrint().print_information("Generating Attack contracts:") + attacks: list[Actor] = [] - # Save harness to file - save_file(harness_output_path, f"/{self.config['name']}", ".sol", harness_content) + # 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 + ) - def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: + # 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] = [] @@ -228,16 +315,23 @@ def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: # 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_actor_functions(contract)) + + 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=name, + name=actor_config["name"], constructor=constructor, imports=imports, dependencies="PropertiesAsserts", @@ -245,6 +339,7 @@ def _generate_actor(self, target_contracts: list[Contract], name: str) -> Actor: functions=functions, content="", path="", + number=actor_config["number"] if "number" in actor_config else 1, targets=target_contracts, contract=None, ) @@ -254,7 +349,7 @@ def _generate_actors(self) -> list[Actor]: actor_contracts: list[Actor] = [] # Check if dir exists, if not, create it - actor_output_path = os.path.join(self.output_dir, "actors") + actor_output_path = os.path.join(self.output_dir, "actors") # Input param: directory # Loop over actors list for actor_config in self.config["actors"]: @@ -266,16 +361,15 @@ def _generate_actors(self) -> list[Actor]: CryticPrint().print_information(f" Actor: {name}Actor...") # Generate the Actor - actor: Actor = self._generate_actor(target_contracts, name) - # Generate the templated string and append to list - template = jinja2.Template(templates["ACTOR"]) - actor_content = template.render(actor=actor) - # Save the file - save_file(actor_output_path, f"/Actor{name}", ".sol", actor_content) + 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(actor_content) - actor.set_path(f"../actors/Actor{name}.sol") + 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}")) @@ -285,170 +379,108 @@ def _generate_actors(self) -> list[Actor]: # Return Actors list return actor_contracts - # pylint: disable=too-many-locals,too-many-branches,no-self-use - def _generate_actor_functions(self, target_contract: Contract) -> list[str]: + 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 not contract.functions_declared or contract.is_interface: - continue - - has_public_fn: bool = False - 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 - ): - has_public_fn = True - if not has_public_fn: + if should_skip_contract_functions(contract): continue - - functions.append( - f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" + temp_functions = self._fetch_contract_functions( + contract, filters, prepend_variables, function_body, contract_name ) - - for entry in contract.functions_declared: - # Don't create wrappers for pure and view functions - if ( - entry.pure - or entry.view - or entry.is_constructor - or entry.is_fallback - or entry.is_receive - ): - continue - if entry.visibility not in ("public", "external"): - 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 = [] - - 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" - ) - definition += ( - f" {target_contract.name.lower()}.{entry.name}({', '.join([ unused_var if not x.name else x.name for x in entry.parameters])});\n" - + " }\n" - ) - functions.append(definition) + if len(temp_functions) > 0: + functions.extend(temp_functions) return functions - # pylint: disable=too-many-locals,no-self-use,too-many-branches - def _generate_harness_functions(self, actor: Actor) -> list[str]: + # 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] = [] - contracts: list[Contract] = [actor.contract] - for contract in contracts: - if not contract.functions_declared or contract.is_interface: - continue + functions.append( + f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" + ) - has_public_fn: bool = False - for entry in contract.functions_declared: - if (entry.visibility in ("public", "external")) and not entry.is_constructor: - has_public_fn = True - if not has_public_fn: + for entry in contract.functions_declared: + # Don't create wrappers for pure and view functions + if should_skip_function(entry, filters): continue - functions.append( - f"// -------------------------------------\n // {contract.name} functions\n // {contract.source_mapping.filename.relative}\n // -------------------------------------\n" + # 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 = 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" ) - - for entry in contract.functions_declared: - # Don't create wrappers for pure and view functions - if ( - entry.pure - or entry.view - or entry.is_constructor - or entry.is_fallback - or entry.is_receive - ): - continue - if entry.visibility not in ("public", "external"): - continue - - # Determine if payable - payable = " payable" if entry.payable else "" - # Loop over function inputs - inputs_with_types = "" - if isinstance(entry.parameters, list): - inputs_with_type_list = ["uint256 actorIndex"] - - 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}") - - 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)})" - - actor_array_var = f"{actor.name}_actors" - # Generate function definition - definition = ( - f"function {entry.name}({inputs_with_types}) {entry.visibility}{payable}{return_types}" - + " {\n" - ) - definition += f" {contract.name} selectedActor = {actor_array_var}[clampBetween(actorIndex, 0, {actor_array_var}.length - 1)];\n" - definition += ( - f" selectedActor.{entry.name}({', '.join([x.name for x in entry.parameters if x.name])});\n" - + " }\n" - ) - functions.append(definition) + 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""" @@ -459,3 +491,85 @@ def get_target_contract(self, slither: Slither, target_name: str) -> Contract: 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 = {[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 config["strict"]: + if False in empty: + if False in any_match: + return True + # If all are empty and at least one condition is false, skip function + return False + + # If at least one is non-empty and at least one condition is true, don't skip function + if False in empty: + if True in any_match: + return False + return True + + # All are empty, don't skip any function + return False + + return False 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/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/remappings.txt b/tests/test_data/remappings.txt index bc79e96..9505c4c 100644 --- a/tests/test_data/remappings.txt +++ b/tests/test_data/remappings.txt @@ -4,5 +4,5 @@ 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/properties/lib/solmate/src/ +solmate/=lib/solmate/src/ src/=src/ diff --git a/tests/test_data/template.json b/tests/test_data/template.json new file mode 100644 index 0000000..6133b07 --- /dev/null +++ b/tests/test_data/template.json @@ -0,0 +1,8 @@ +{ + "name": "DefaultHarness", + "compilationPath": ".", + "targets": ["BasicTypes"], + "outputDir": "./test/fuzzing", + "actors": [{"name": "Default", "targets": ["BasicTypes"], "number": 3, "filters": {"strict": false, "onlyModifiers": [], "onlyPayable": false, "onlyExternalCalls": []}}], + "attacks": [{"name": "Donation", "targets": ["BasicTypes"], "filters": {"strict": true, "onlyModifiers": [], "onlyPayable": false, "onlyExternalCalls": ["transferFrom"]}}] +} \ No newline at end of file From 12840391e5a96d8ee42393f43b9127f9d9fa0469 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Wed, 20 Mar 2024 18:29:32 +0100 Subject: [PATCH 08/22] merge main to branch --- fuzz_utils/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index 1cb7a76..9c065d2 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -204,7 +204,7 @@ def main() -> None: # type: ignore[func-returns-value] action="store_true", ) - # The command parser for converting between corpus formats + # The command parser for generating fuzzing harnesses parser_template = subparsers.add_parser( "template", help="Generate a templated fuzzing harness." ) From a38c71f1bbd823e0829fde660fcc6f5b00a5dd25 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Wed, 20 Mar 2024 18:31:20 +0100 Subject: [PATCH 09/22] lint --- fuzz_utils/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fuzz_utils/main.py b/fuzz_utils/main.py index 9c065d2..bf47ecd 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -153,7 +153,7 @@ def find_difference_between_list_and_tuple(default: list, my_tuple: tuple) -> li ] -# pylint: disable=too-many-locals +# pylint: disable=too-many-locals,too-many-statements def main() -> None: # type: ignore[func-returns-value] """The main entry point""" parser = argparse.ArgumentParser( @@ -241,9 +241,9 @@ def main() -> None: # type: ignore[func-returns-value] match selected_fuzzer: case "echidna": - fuzzer = Echidna(target_contract, corpus_dir, slither) + fuzzer = Echidna(target_contract, corpus_dir, slither, args.named_inputs) case "medusa": - fuzzer = Medusa(target_contract, corpus_dir, slither) + fuzzer = Medusa(target_contract, corpus_dir, slither, args.named_inputs) case _: handle_exit( f"\n* The requested fuzzer {selected_fuzzer} is not supported. Supported fuzzers: echidna, medusa." From 0617d89fb4a3c286ce614559f0fd724c331f5ecb Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Mon, 25 Mar 2024 12:45:14 +0100 Subject: [PATCH 10/22] remove duplicate templates --- fuzz_utils/templates/foundry_templates.py | 76 +---------------------- 1 file changed, 1 insertion(+), 75 deletions(-) diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index c5909a4..fc4f95c 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -67,85 +67,11 @@ } """ -__HARNESS_TEMPLATE: 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 -/// -/// -- [ 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 {{harness.path}} --contract {{harness.name}} --test-mode assertion --test-limit 100000 --corpus-dir echidna-corpora/corpus-{{harness.name}} -/// Medusa: medusa fuzz --target {{harness.path}} --assertion-mode --test-limit 100000 --deployment-order "{{harness.name}}" --corpus-dir medusa-corpora/corpus-{{harness.name}} -/// Foundry: forge test --match-contract {{harness.name}} -/// -------------------------------------------------------------------- - -import "properties/util/PropertiesHelper.sol"; -{% for import in harness.imports -%} -{{import}} -{% endfor %} -contract {{harness.name}} is {{harness.dependencies}} { - {% for variable in harness.variables -%} - {{variable}} - {% endfor %} - {{harness.constructor}} - {%- for function in harness.functions %} - {{function}} - {%- endfor -%} -} -""" - -__ACTOR_TEMPLATE: 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 -/// -------------------------------------------------------------------- - -import "properties/util/PropertiesHelper.sol"; -{%- for import in actor.imports %} -{{import}} -{% endfor -%} - -contract Actor{{actor.name}} is {{actor.dependencies}} { - {%- for variable in actor.variables %} - {{variable}} - {% endfor -%} - - {{actor.constructor}} - {%- for function in actor.functions %} - {{function}} - {%- endfor -%} -} -""" - templates: dict = { "CONTRACT": __CONTRACT_TEMPLATE, "CALL": __CALL_TEMPLATE, "TRANSFER": __TRANSFER__TEMPLATE, "EMPTY_CALL": __EMPTY_CALL_TEMPLATE, "TEST": __TEST_TEMPLATE, - "INTERFACE": __INTERFACE_TEMPLATE, - "HARNESS": __HARNESS_TEMPLATE, - "ACTOR": __ACTOR_TEMPLATE, + "INTERFACE": __INTERFACE_TEMPLATE } From 48eab9742cdff7aade57914dbca56f05f2b36996 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Mon, 25 Mar 2024 12:46:01 +0100 Subject: [PATCH 11/22] template formatting --- fuzz_utils/templates/foundry_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index fc4f95c..0017f77 100644 --- a/fuzz_utils/templates/foundry_templates.py +++ b/fuzz_utils/templates/foundry_templates.py @@ -73,5 +73,5 @@ "TRANSFER": __TRANSFER__TEMPLATE, "EMPTY_CALL": __EMPTY_CALL_TEMPLATE, "TEST": __TEST_TEMPLATE, - "INTERFACE": __INTERFACE_TEMPLATE + "INTERFACE": __INTERFACE_TEMPLATE, } From bf9a9e0dd2a41393aee2fe2b90fbfd9677ffc7c5 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Mon, 25 Mar 2024 17:34:47 +0100 Subject: [PATCH 12/22] refactor parser, reduce code duplication --- fuzz_utils/generate/FoundryTest.py | 88 ++++++ fuzz_utils/{fuzzers => generate}/__init__.py | 0 fuzz_utils/{ => generate}/fuzzers/Echidna.py | 0 fuzz_utils/{ => generate}/fuzzers/Medusa.py | 0 fuzz_utils/generate/fuzzers/__init__.py | 0 fuzz_utils/main.py | 270 +----------------- fuzz_utils/parsing/__init__.py | 0 fuzz_utils/parsing/commands/__init__.py | 0 fuzz_utils/parsing/commands/generate.py | 104 +++++++ fuzz_utils/parsing/commands/init.py | 18 ++ fuzz_utils/parsing/commands/template.py | 60 ++++ fuzz_utils/parsing/parser.py | 48 ++++ .../HarnessGenerator.py | 15 +- fuzz_utils/template/__init__.py | 0 fuzz_utils/templates/default_config.py | 34 +++ fuzz_utils/utils/encoding.py | 5 - fuzz_utils/utils/remappings.py | 62 ++++ tests/conftest.py | 23 +- 18 files changed, 434 insertions(+), 293 deletions(-) create mode 100644 fuzz_utils/generate/FoundryTest.py rename fuzz_utils/{fuzzers => generate}/__init__.py (100%) rename fuzz_utils/{ => generate}/fuzzers/Echidna.py (100%) rename fuzz_utils/{ => generate}/fuzzers/Medusa.py (100%) create mode 100644 fuzz_utils/generate/fuzzers/__init__.py create mode 100644 fuzz_utils/parsing/__init__.py create mode 100644 fuzz_utils/parsing/commands/__init__.py create mode 100644 fuzz_utils/parsing/commands/generate.py create mode 100644 fuzz_utils/parsing/commands/init.py create mode 100644 fuzz_utils/parsing/commands/template.py create mode 100644 fuzz_utils/parsing/parser.py rename fuzz_utils/{templates => template}/HarnessGenerator.py (98%) create mode 100644 fuzz_utils/template/__init__.py create mode 100644 fuzz_utils/templates/default_config.py create mode 100644 fuzz_utils/utils/remappings.py diff --git a/fuzz_utils/generate/FoundryTest.py b/fuzz_utils/generate/FoundryTest.py new file mode 100644 index 0000000..14cdb21 --- /dev/null +++ b/fuzz_utils/generate/FoundryTest.py @@ -0,0 +1,88 @@ +"""The FoundryTest class that handles generation of unit tests from call sequences""" +import os +import sys +import json +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: + """ + 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.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 = [] + tests_list = [] + # 1. Iterate over each reproducer file (open it) + for entry in os.listdir(self.fuzzer.reproducer_dir): + full_path = os.path.join(self.fuzzer.reproducer_dir, entry) + + if os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as file: + file_list.append(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 in enumerate(file_list): + try: + tests_list.append(self.fuzzer.parse_reproducer(file, idx)) + except Exception: # pylint: disable=broad-except + print(f"Parsing fail on {file}: 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 100% rename from fuzz_utils/fuzzers/Echidna.py rename to fuzz_utils/generate/fuzzers/Echidna.py diff --git a/fuzz_utils/fuzzers/Medusa.py b/fuzz_utils/generate/fuzzers/Medusa.py similarity index 100% rename from fuzz_utils/fuzzers/Medusa.py rename to fuzz_utils/generate/fuzzers/Medusa.py 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 bf47ecd..9e38d7b 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -1,157 +1,7 @@ """ Generates a test file from Echidna reproducers """ -import os import sys -import json import argparse -import subprocess -import re -import jinja2 - -from pkg_resources import require - -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.templates.HarnessGenerator import HarnessGenerator -from fuzz_utils.utils.error_handler import handle_exit - - -class FoundryTest: - """ - 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, - ) -> 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 - - 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 = [] - tests_list = [] - # 1. Iterate over each reproducer file (open it) - for entry in os.listdir(self.fuzzer.reproducer_dir): - full_path = os.path.join(self.fuzzer.reproducer_dir, entry) - - if os.path.isfile(full_path): - try: - with open(full_path, "r", encoding="utf-8") as file: - file_list.append(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 in enumerate(file_list): - try: - tests_list.append(self.fuzzer.parse_reproducer(file, idx)) - except Exception: # pylint: disable=broad-except - print(f"Parsing fail on {file}: 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 - - -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 - ] - +from fuzz_utils.parsing.parser import define_subparsers, run_command # pylint: disable=too-many-locals,too-many-statements def main() -> None: # type: ignore[func-returns-value] @@ -160,122 +10,10 @@ def main() -> None: # type: ignore[func-returns-value] prog="fuzz-utils", description="Generate test harnesses for Echidna failed properties." ) subparsers = parser.add_subparsers(dest="command", help="sub-command help") - - # The command parser for generating unit tests - parser_generate = subparsers.add_parser( - "generate", help="Generate unit tests from fuzzer corpora sequences." - ) - parser_generate.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") - parser_generate.add_argument( - "-cd", "--corpus-dir", dest="corpus_dir", help="Path to the corpus directory", required=True - ) - parser_generate.add_argument( - "-c", "--contract", dest="target_contract", help="Define the contract name" - ) - parser_generate.add_argument( - "-td", - "--test-directory", - dest="test_directory", - help="Define the directory that contains the Foundry tests.", - ) - parser_generate.add_argument( - "-i", - "--inheritance-path", - dest="inheritance_path", - help="Define the relative path from the test directory to the directory src/contracts directory.", - ) - parser_generate.add_argument( - "-f", - "--fuzzer", - dest="selected_fuzzer", - help="Define the fuzzer used. Valid inputs: 'echidna', 'medusa'", - ) - parser_generate.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", - ) - - # The command parser for generating fuzzing harnesses - parser_template = subparsers.add_parser( - "template", help="Generate a templated fuzzing harness." - ) - parser_template.add_argument("file_path", help="Path to the Solidity contract.") - parser_template.add_argument( - "-c", - "--contracts", - dest="target_contracts", - nargs="+", - help="Define a list of target contracts for the harness.", - ) - parser_template.add_argument( - "-o", - "--output-dir", - dest="output_dir", - help="Define the output directory where the result will be saved.", - ) - parser_template.add_argument( - "--config", dest="config", help="Define the location of the config file." - ) + define_subparsers(subparsers) args = parser.parse_args() - file_path = args.file_path - CryticPrint().print_information("Running Slither...") - slither = Slither(file_path) - - if args.command == "generate": - test_directory = args.test_directory - inheritance_path = args.inheritance_path - selected_fuzzer = args.selected_fuzzer.lower() - corpus_dir = args.corpus_dir - target_contract = args.target_contract - - fuzzer: Echidna | Medusa - - match selected_fuzzer: - 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 {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 - ) - foundry_test.create_poc() - CryticPrint().print_success("Done!") - elif args.command == "template": - 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: - config = json.load(readFile) - - # Check if dependencies are installed - include_attacks = bool("attacks" in config and len(config["attacks"]) > 0) - remappings = find_remappings(include_attacks) - - generator = HarnessGenerator( - file_path, args.target_contracts, slither, output_dir, config, remappings - ) - generator.generate_templates() - else: + command_success: bool = run_command(args) + if not command_success: parser.print_help() 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..f07a6a3 --- /dev/null +++ b/fuzz_utils/parsing/commands/generate.py @@ -0,0 +1,104 @@ +"""Defines the flags and logic associated with the `generate` command""" +import json +from argparse import Namespace, ArgumentParser +from pkg_resources import require +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("file_path", help="Path to the Echidna/Medusa 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( + "--config", + dest="config", + help="Define the location of the config file.", + ) + + +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.file_path: + config["compilationPath"] = args.file_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 + + 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, args.named_inputs + ) + case "medusa": + fuzzer = Medusa( + config["targetContract"], config["corpusDir"], slither, args.named_inputs + ) + 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..51b3e12 --- /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("file_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.file_path: + config["compilationPath"] = args.file_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/templates/HarnessGenerator.py b/fuzz_utils/template/HarnessGenerator.py similarity index 98% rename from fuzz_utils/templates/HarnessGenerator.py rename to fuzz_utils/template/HarnessGenerator.py index 83e22f9..7ca0d2e 100644 --- a/fuzz_utils/templates/HarnessGenerator.py +++ b/fuzz_utils/template/HarnessGenerator.py @@ -96,11 +96,8 @@ class HarnessGenerator: def __init__( self, - compilation_path: str, - targets: list[str], - slither: Slither, - output_dir: str, config: dict, + slither: Slither, remappings: dict, ) -> None: for key, value in config.items(): @@ -125,15 +122,9 @@ def __init__( CryticPrint().print_warning( "Missing filters argument in actor, using none as default." ) + if not "targets" in actor or len(actor["targets"]) == 0: + self.config["actors"][idx]["targets"] = self.config["targets"] - if targets: - self.config["targets"] = targets - if output_dir: - self.config["outputDir"] = output_dir - if compilation_path: - self.config["compilationPath"] = compilation_path - if "actors" not in config: - self.config["actors"][0]["targets"] = self.config["targets"] if remappings: self.remappings = remappings 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..0e0a31b --- /dev/null +++ b/fuzz_utils/templates/default_config.py @@ -0,0 +1,34 @@ +"""Default configuration file""" +default_config: dict = { + "compilationPath": ".", + "corpusDir": "", + "generate": { + "targetContract": "", + "compilationPath": "", + "corpusDir": "", + "fuzzer": "", + "testsDir": "", + "inheritancePath": "", + "namedInputs": 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/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/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 d47f554..7c4c5e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,24 +4,27 @@ 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 - ) - self.medusa_generator = FoundryTest( - "../src/", target, f"medusa-corpora/{corpus_dir}", "./test/", slither, medusa - ) + config = { + "targetContract": target, + "inheritancePath": "../src/", + "corpusDir": f"echidna-corpora/{corpus_dir}", + "testsDir": "./test/", + } + 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""" From 365844e3ec640251dc8dd7ff2fa91727bcc08391 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Mon, 25 Mar 2024 18:05:38 +0100 Subject: [PATCH 13/22] Merge main into template-generation Merge main and resolve conflicts, add notice on top of generated test files that they were auto-generated with fuzz-utils. --- fuzz_utils/generate/fuzzers/Echidna.py | 1 - fuzz_utils/parsing/commands/generate.py | 23 ++++++++++++++++++++--- fuzz_utils/templates/default_config.py | 5 ++--- fuzz_utils/templates/foundry_templates.py | 6 +++++- tests/conftest.py | 1 + tests/test_data/template.json | 8 -------- 6 files changed, 28 insertions(+), 16 deletions(-) delete mode 100644 tests/test_data/template.json diff --git a/fuzz_utils/generate/fuzzers/Echidna.py b/fuzz_utils/generate/fuzzers/Echidna.py index ef4426b..7ac239a 100644 --- a/fuzz_utils/generate/fuzzers/Echidna.py +++ b/fuzz_utils/generate/fuzzers/Echidna.py @@ -119,7 +119,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) diff --git a/fuzz_utils/parsing/commands/generate.py b/fuzz_utils/parsing/commands/generate.py index f07a6a3..00687ec 100644 --- a/fuzz_utils/parsing/commands/generate.py +++ b/fuzz_utils/parsing/commands/generate.py @@ -14,7 +14,7 @@ def generate_flags(parser: ArgumentParser) -> None: """The unit test generation parser flags""" parser.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") parser.add_argument( - "-cd", "--corpus-dir", dest="corpus_dir", help="Path to the corpus directory", required=True + "-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( @@ -53,6 +53,13 @@ def generate_flags(parser: ArgumentParser) -> None: 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: @@ -77,6 +84,16 @@ def generate_command(args: Namespace) -> None: 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) @@ -85,11 +102,11 @@ def generate_command(args: Namespace) -> None: match config["fuzzer"]: case "echidna": fuzzer = Echidna( - config["targetContract"], config["corpusDir"], slither, args.named_inputs + config["targetContract"], config["corpusDir"], slither, config["namedInputs"] ) case "medusa": fuzzer = Medusa( - config["targetContract"], config["corpusDir"], slither, args.named_inputs + config["targetContract"], config["corpusDir"], slither, config["namedInputs"] ) case _: handle_exit( diff --git a/fuzz_utils/templates/default_config.py b/fuzz_utils/templates/default_config.py index 0e0a31b..d41416e 100644 --- a/fuzz_utils/templates/default_config.py +++ b/fuzz_utils/templates/default_config.py @@ -1,15 +1,14 @@ """Default configuration file""" default_config: dict = { - "compilationPath": ".", - "corpusDir": "", "generate": { "targetContract": "", - "compilationPath": "", + "compilationPath": ".", "corpusDir": "", "fuzzer": "", "testsDir": "", "inheritancePath": "", "namedInputs": False, + "allSequences": False, }, "template": { "name": "DefaultHarness", diff --git a/fuzz_utils/templates/foundry_templates.py b/fuzz_utils/templates/foundry_templates.py index 4cdca80..5e9d4f7 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}}"; @@ -33,7 +38,6 @@ {%- else %} target.{{function_name}}({{function_parameters}}); {%- endif %} - """ __TRANSFER__TEMPLATE: str = """ diff --git a/tests/conftest.py b/tests/conftest.py index 7358cc7..fd6554e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ def __init__(self, target: str, target_path: str, corpus_dir: str): "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}" diff --git a/tests/test_data/template.json b/tests/test_data/template.json deleted file mode 100644 index 6133b07..0000000 --- a/tests/test_data/template.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "DefaultHarness", - "compilationPath": ".", - "targets": ["BasicTypes"], - "outputDir": "./test/fuzzing", - "actors": [{"name": "Default", "targets": ["BasicTypes"], "number": 3, "filters": {"strict": false, "onlyModifiers": [], "onlyPayable": false, "onlyExternalCalls": []}}], - "attacks": [{"name": "Donation", "targets": ["BasicTypes"], "filters": {"strict": true, "onlyModifiers": [], "onlyPayable": false, "onlyExternalCalls": ["transferFrom"]}}] -} \ No newline at end of file From 7a0610200eb9032893cf6dabf3d4a2382f7b4077 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 12:11:43 +0100 Subject: [PATCH 14/22] update flags, readme, add harness example --- CONTRIBUTING.md | 8 +- README.md | 121 ++++++++++++++-- fuzz_utils/main.py | 7 + fuzz_utils/parsing/commands/generate.py | 15 +- fuzz_utils/parsing/commands/template.py | 6 +- fuzz_utils/template/HarnessGenerator.py | 50 ++++--- .../test/fuzzing/actors/ActorDefault.sol | 100 +++++++++++++ .../test/fuzzing/harnesses/DefaultHarness.sol | 134 ++++++++++++++++++ 8 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 tests/test_data/test/fuzzing/actors/ActorDefault.sol create mode 100644 tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol 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/README.md b/README.md index 106fbae..cbe477c 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,109 @@ 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/main.py b/fuzz_utils/main.py index 9e38d7b..4409b5e 100644 --- a/fuzz_utils/main.py +++ b/fuzz_utils/main.py @@ -1,6 +1,7 @@ """ Generates a test file from Echidna reproducers """ import sys import argparse +from pkg_resources import require from fuzz_utils.parsing.parser import define_subparsers, run_command # pylint: disable=too-many-locals,too-many-statements @@ -9,6 +10,12 @@ def main() -> None: # type: ignore[func-returns-value] parser = argparse.ArgumentParser( prog="fuzz-utils", description="Generate test harnesses for Echidna failed properties." ) + parser.add_argument( + "--version", + help="displays the current version", + version=require("fuzz-utils")[0].version, + action="version", + ) subparsers = parser.add_subparsers(dest="command", help="sub-command help") define_subparsers(subparsers) args = parser.parse_args() diff --git a/fuzz_utils/parsing/commands/generate.py b/fuzz_utils/parsing/commands/generate.py index 00687ec..86a943f 100644 --- a/fuzz_utils/parsing/commands/generate.py +++ b/fuzz_utils/parsing/commands/generate.py @@ -1,7 +1,6 @@ """Defines the flags and logic associated with the `generate` command""" import json from argparse import Namespace, ArgumentParser -from pkg_resources import require from slither import Slither from fuzz_utils.utils.crytic_print import CryticPrint from fuzz_utils.generate.FoundryTest import FoundryTest @@ -12,7 +11,9 @@ def generate_flags(parser: ArgumentParser) -> None: """The unit test generation parser flags""" - parser.add_argument("file_path", help="Path to the Echidna/Medusa test harness.") + 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" ) @@ -35,12 +36,6 @@ def generate_flags(parser: ArgumentParser) -> None: 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", @@ -72,8 +67,8 @@ def generate_command(args: Namespace) -> None: if "generate" in complete_config: config = complete_config["generate"] # Override the config with the CLI values - if args.file_path: - config["compilationPath"] = args.file_path + if args.compilation_path: + config["compilationPath"] = args.compilation_path if args.test_directory: config["testsDir"] = args.test_directory if args.inheritance_path: diff --git a/fuzz_utils/parsing/commands/template.py b/fuzz_utils/parsing/commands/template.py index 51b3e12..4c3940b 100644 --- a/fuzz_utils/parsing/commands/template.py +++ b/fuzz_utils/parsing/commands/template.py @@ -10,7 +10,7 @@ def template_flags(parser: ArgumentParser) -> None: """The harness template generation parser flags""" - parser.add_argument("file_path", help="Path to the Solidity contract.") + 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", @@ -43,8 +43,8 @@ def template_command(args: Namespace) -> None: if args.target_contracts: config["targets"] = args.target_contracts - if args.file_path: - config["compilationPath"] = args.file_path + if args.compilation_path: + config["compilationPath"] = args.compilation_path if args.name: config["name"] = args.name config["outputDir"] = output_dir diff --git a/fuzz_utils/template/HarnessGenerator.py b/fuzz_utils/template/HarnessGenerator.py index 7ca0d2e..7e1694d 100644 --- a/fuzz_utils/template/HarnessGenerator.py +++ b/fuzz_utils/template/HarnessGenerator.py @@ -100,30 +100,16 @@ def __init__( 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 - # TODO add checks for attack config - if key == "actors": - for idx, actor in enumerate(config[key]): - if not "name" in actor or not "targets" in actor: - handle_exit("Actor is missing attributes") - if not "number" in actor: - self.config["actors"][idx]["number"] = 3 - CryticPrint().print_warning( - "Missing number argument in actor, using 3 as default." - ) - if not "filters" in actor: - self.config["actors"][idx]["filters"] = { - "onlyModifiers": [], - "onlyPayable": False, - "onlyExternalCalls": [], - } - CryticPrint().print_warning( - "Missing filters argument in actor, using none as default." - ) - if not "targets" in actor or len(actor["targets"]) == 0: - self.config["actors"][idx]["targets"] = self.config["targets"] if remappings: self.remappings = remappings @@ -564,3 +550,25 @@ def should_skip_function(function: FunctionContract, config: dict | None) -> boo return False 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): + print(idx, actor) + if "name" not in actor or "targets" not in actor: + handle_exit("Actor is missing attributes") + if "number" not in actor: + actors_config["actors"][idx]["number"] = 3 + CryticPrint().print_warning("Missing number argument in actor, using 3 as default.") + if "filters" not in actor: + actors_config["actors"][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["actors"][idx]["targets"] = default_targets + + return actors_config 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..4568cc6 --- /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, bool set) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_bool(); + } + + function setUint256(uint256 actorIndex, bool set, uint256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setUint256(input); + } + + function check_uint256(uint256 actorIndex, bool set, uint256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_uint256(); + } + + function check_large_uint256(uint256 actorIndex, bool set, uint256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_uint256(); + } + + function setInt256(uint256 actorIndex, bool set, uint256 input, int256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setInt256(input); + } + + function check_int256(uint256 actorIndex, bool set, uint256 input, int256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_int256(); + } + + function check_large_positive_int256(uint256 actorIndex, bool set, uint256 input, int256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_positive_int256(); + } + + function check_large_negative_int256(uint256 actorIndex, bool set, uint256 input, int256 input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_large_negative_int256(); + } + + function setAddress(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setAddress(input); + } + + function check_address(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_address(); + } + + function setString(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setString(input); + } + + function check_string(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_string(); + } + + function check_specific_string(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_specific_string(provided); + } + + function setBytes(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.setBytes(input); + } + + function check_bytes(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input) public { + ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; + selectedActor.check_bytes(); + } + + function setCombination(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input, 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, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input, 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.check_combined_input(); + } +} \ No newline at end of file From 7cd31e5adc568f9a5dfc42e7b6e504c5246a77b1 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 12:14:06 +0100 Subject: [PATCH 15/22] readme typo --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cbe477c..4740e7e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The `template` command is used to generate a fuzzing harness. The harness can in - `--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 From 23176793457d09e9ba4308f365782027667e6d5a Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 12:31:32 +0100 Subject: [PATCH 16/22] fix repeated inputs in harness --- fuzz_utils/template/HarnessGenerator.py | 7 ++-- .../test/fuzzing/harnesses/DefaultHarness.sol | 34 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/fuzz_utils/template/HarnessGenerator.py b/fuzz_utils/template/HarnessGenerator.py index 7e1694d..509e7b9 100644 --- a/fuzz_utils/template/HarnessGenerator.py +++ b/fuzz_utils/template/HarnessGenerator.py @@ -1,6 +1,7 @@ """ 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 @@ -406,7 +407,9 @@ def _fetch_contract_functions( # Loop over function inputs inputs_with_types = "" if isinstance(entry.parameters, list): - inputs_with_type_list = prepend_variables if len(prepend_variables) > 0 else [] + inputs_with_type_list = ( + copy.deepcopy(prepend_variables) if len(prepend_variables) > 0 else [] + ) for parameter in entry.parameters: location = "" @@ -422,7 +425,6 @@ def _fetch_contract_functions( ) inputs_with_types = ", ".join(inputs_with_type_list) - # Loop over return types return_types = "" if isinstance(entry.return_type, list): @@ -555,7 +557,6 @@ def should_skip_function(function: FunctionContract, config: dict | None) -> boo 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): - print(idx, actor) if "name" not in actor or "targets" not in actor: handle_exit("Actor is missing attributes") if "number" not in actor: diff --git a/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol b/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol index 4568cc6..ae0e436 100644 --- a/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol +++ b/tests/test_data/test/fuzzing/harnesses/DefaultHarness.sol @@ -47,87 +47,87 @@ contract DefaultHarness is PropertiesAsserts { selectedActor.setBool(set); } - function check_bool(uint256 actorIndex, bool set) public { + function check_bool(uint256 actorIndex) public { ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; selectedActor.check_bool(); } - function setUint256(uint256 actorIndex, bool set, uint256 input) public { + 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, bool set, uint256 input) public { + 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, bool set, uint256 input) public { + 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, bool set, uint256 input, int256 input) public { + 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, bool set, uint256 input, int256 input) public { + 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, bool set, uint256 input, int256 input) public { + 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, bool set, uint256 input, int256 input) public { + 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, bool set, uint256 input, int256 input, address payable input) public { + 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, bool set, uint256 input, int256 input, address payable input) public { + function check_address(uint256 actorIndex) public { ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; selectedActor.check_address(); } - function setString(uint256 actorIndex, bool set, uint256 input, int256 input, address payable input, string memory input) public { + 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, bool set, uint256 input, int256 input, address payable input, string memory input) public { + 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, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided) public { + 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, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input) public { + 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, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input) public { + 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 set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input, bool bool_input, uint256 unsigned_input, int256 signed_input, address payable address_input, string memory str_input, bytes memory bytes_input) public { + 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, bool set, uint256 input, int256 input, address payable input, string memory input, string memory provided, bytes memory input, bool bool_input, uint256 unsigned_input, int256 signed_input, address payable address_input, string memory str_input, bytes memory bytes_input) public { + function check_combined_input(uint256 actorIndex) public { ActorDefault selectedActor = Default_actors[clampBetween(actorIndex, 0, Default_actors.length - 1)]; selectedActor.check_combined_input(); } From 2eeb63e8451e843d93b9a40502bd58a12d84297b Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 14:56:04 +0100 Subject: [PATCH 17/22] Add harness generation tests Add filtering tests for harness generation. Fix filtering cases and incorrect set comprehension --- fuzz_utils/template/HarnessGenerator.py | 31 ++-- tests/test_data/src/Filtering.sol | 53 ++++++ tests/test_harness.py | 214 ++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 tests/test_data/src/Filtering.sol create mode 100644 tests/test_harness.py diff --git a/fuzz_utils/template/HarnessGenerator.py b/fuzz_utils/template/HarnessGenerator.py index 509e7b9..475d8b5 100644 --- a/fuzz_utils/template/HarnessGenerator.py +++ b/fuzz_utils/template/HarnessGenerator.py @@ -511,7 +511,7 @@ def should_skip_function(function: FunctionContract, config: dict | None) -> boo if config: if len(config["onlyModifiers"]) > 0: inclusionSet = set(config["onlyModifiers"]) - modifierSet = {[x.name for x in function.modifiers]} + modifierSet: set = {x.name for x in function.modifiers} if inclusionSet & modifierSet: any_match[0] = True else: @@ -535,22 +535,23 @@ def should_skip_function(function: FunctionContract, config: dict | None) -> boo else: empty[2] = True - if config["strict"]: - if False in empty: - if False in any_match: - return True - # If all are empty and at least one condition is false, skip function + # If all are empty don't skip any functions: + if all(empty): return False - # If at least one is non-empty and at least one condition is true, don't skip function - if False in empty: - if True in any_match: + # 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 - # All are empty, don't skip any function - return False - + # 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 @@ -560,16 +561,16 @@ def check_and_populate_actor_fields(actors_config: dict, default_targets: list[s if "name" not in actor or "targets" not in actor: handle_exit("Actor is missing attributes") if "number" not in actor: - actors_config["actors"][idx]["number"] = 3 + actors_config[idx]["number"] = 3 CryticPrint().print_warning("Missing number argument in actor, using 3 as default.") if "filters" not in actor: - actors_config["actors"][idx]["filters"] = { + 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["actors"][idx]["targets"] = default_targets + actors_config[idx]["targets"] = default_targets return actors_config 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_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..e3ab3c9 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,214 @@ +""" 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.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": [], +} +remappings = {"properties": "properties/", "solmate": "solmate/"} + + +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", + "ModifierActor", + "./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", + "TransferActor", + "./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", + "PayableActor", + "./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", + "ModExActor", + "./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", + "StrictModExActor", + "./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", + "MulExActor", + "./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", + "StrictMulExActor", + "./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: list[str], +) -> None: + """Sets up the HarnessGenerator""" + 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 + config["actors"][0]["name"] = actor_name + + 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 From e2a5cc5ff6b6fa5cc7d510a6446d8b55df13ba84 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 15:12:41 +0100 Subject: [PATCH 18/22] fix mypy errors --- tests/test_harness.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_harness.py b/tests/test_harness.py index e3ab3c9..2efe166 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -43,7 +43,7 @@ def test_modifier_filtering() -> None: expected_functions = set(["iAmRestricted"]) run_harness( "ModifierHarness", - "ModifierActor", + "Modifier", "./src/Filtering.sol", ["Filtering"], filters, @@ -62,7 +62,7 @@ def test_external_call_filtering() -> None: expected_functions = set(["depositWithModifier", "depositNoModifier"]) run_harness( "TransferHarness", - "TransferActor", + "Transfer", "./src/Filtering.sol", ["Filtering"], filters, @@ -81,7 +81,7 @@ def test_payable_filtering() -> None: expected_functions = set(["iAmPayable"]) run_harness( "PayableHarness", - "PayableActor", + "Payable", "./src/Filtering.sol", ["Filtering"], filters, @@ -100,7 +100,7 @@ def test_modifier_and_external_call_filtering() -> None: expected_functions = set(["depositWithModifier", "depositNoModifier"]) run_harness( "ModExHarness", - "ModExActor", + "ModEx", "./src/Filtering.sol", ["Filtering"], filters, @@ -119,7 +119,7 @@ def test_strict_modifier_and_external_call_filtering() -> None: expected_functions = set(["depositWithModifier"]) run_harness( "StrictModExHarness", - "StrictModExActor", + "StrictModEx", "./src/Filtering.sol", ["Filtering"], filters, @@ -138,7 +138,7 @@ def test_multiple_external_calls_filtering() -> None: expected_functions = set(["depositWithModifier", "depositNoModifier", "withdraw"]) run_harness( "MulExHarness", - "MulExActor", + "MulEx", "./src/Filtering.sol", ["Filtering"], filters, @@ -157,7 +157,7 @@ def test_strict_multiple_external_calls_filtering() -> None: expected_functions = set(["depositWithModifier", "depositNoModifier", "withdraw"]) run_harness( "StrictMulExHarness", - "StrictMulExActor", + "StrictMulEx", "./src/Filtering.sol", ["Filtering"], filters, @@ -171,7 +171,7 @@ def run_harness( compilation_path: str, targets: list, filters: dict, - expected_functions: list[str], + expected_functions: set[str], ) -> None: """Sets up the HarnessGenerator""" config = copy.deepcopy(default_config) @@ -180,8 +180,8 @@ def run_harness( config["name"] = harness_name config["compilationPath"] = compilation_path config["targets"] = targets - config["actors"][0]["filters"] = filters - config["actors"][0]["name"] = actor_name + 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() From 8b6418012840e13c54941581e5536a9723e69bea Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 15:58:41 +0100 Subject: [PATCH 19/22] install properties in pytest workflow, fetch remappings in harness tests --- .github/workflows/pytest.yml | 6 ++++++ tests/test_harness.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 81a8c54..64fa448 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -41,6 +41,12 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Foundry dependencies + uses: forge install crytic/properties + + - name: Set remappings + uses: forge remappings > remappings.txt - name: Run tests run: | diff --git a/tests/test_harness.py b/tests/test_harness.py index 2efe166..aeabab9 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -5,6 +5,7 @@ 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 @@ -29,7 +30,6 @@ ], "attacks": [], } -remappings = {"properties": "properties/", "solmate": "solmate/"} def test_modifier_filtering() -> None: @@ -174,6 +174,7 @@ def run_harness( expected_functions: set[str], ) -> None: """Sets up the HarnessGenerator""" + remappings = find_remappings(False) config = copy.deepcopy(default_config) slither = Slither(compilation_path) From 1120fb6096b8bba28d91f49492a105404ec5bb5a Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 16:02:15 +0100 Subject: [PATCH 20/22] fix pytest workflow --- .github/workflows/pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 64fa448..4263f3d 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -43,10 +43,10 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Install Foundry dependencies - uses: forge install crytic/properties + run: forge install crytic/properties - name: Set remappings - uses: forge remappings > remappings.txt + run: forge remappings > remappings.txt - name: Run tests run: | From c832780d89b21e1c28805e5a05d9ddfb7088d7f6 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 16:22:44 +0100 Subject: [PATCH 21/22] pytest workflow --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 4263f3d..6e545f2 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -43,7 +43,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Install Foundry dependencies - run: forge install crytic/properties + run: forge install crytic/properties --no-commit - name: Set remappings run: forge remappings > remappings.txt From 3f65f70a5617297af623e8f03b4f3d5364eae244 Mon Sep 17 00:00:00 2001 From: tuturu-tech Date: Tue, 26 Mar 2024 17:15:27 +0100 Subject: [PATCH 22/22] update make test, update pytest workflow --- .github/workflows/pytest.yml | 6 ------ Makefile | 5 ++++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6e545f2..81a8c54 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -41,12 +41,6 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - - - name: Install Foundry dependencies - run: forge install crytic/properties --no-commit - - - name: Set remappings - run: forge remappings > remappings.txt - name: Run tests run: | 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