diff --git a/scripts/ci_test_printers.sh b/scripts/ci_test_printers.sh index d13932b816..3306c134e2 100755 --- a/scripts/ci_test_printers.sh +++ b/scripts/ci_test_printers.sh @@ -5,7 +5,7 @@ cd tests/e2e/solc_parsing/test_data/compile/ || exit # Do not test the evm printer,as it needs a refactoring -ALL_PRINTERS="cfg,constructor-calls,contract-summary,data-dependency,echidna,function-id,function-summary,modifiers,call-graph,human-summary,inheritance,inheritance-graph,loc,slithir,slithir-ssa,vars-and-auth,require,variable-order,declaration" +ALL_PRINTERS="cfg,constructor-calls,contract-summary,data-dependency,echidna,function-id,function-summary,modifiers,call-graph,halstead,human-summary,inheritance,inheritance-graph,loc,martin,slithir,slithir-ssa,vars-and-auth,require,variable-order,declaration,ck" # Only test 0.5.17 to limit test time for file in *0.5.17-compact.zip; do diff --git a/slither/core/variables/__init__.py b/slither/core/variables/__init__.py index 638f0f3a4e..53872853aa 100644 --- a/slither/core/variables/__init__.py +++ b/slither/core/variables/__init__.py @@ -1,2 +1,8 @@ from .state_variable import StateVariable from .variable import Variable +from .local_variable_init_from_tuple import LocalVariableInitFromTuple +from .local_variable import LocalVariable +from .top_level_variable import TopLevelVariable +from .event_variable import EventVariable +from .function_type_variable import FunctionTypeVariable +from .structure_variable import StructureVariable diff --git a/slither/core/variables/local_variable.py b/slither/core/variables/local_variable.py index 427f004082..9baf804457 100644 --- a/slither/core/variables/local_variable.py +++ b/slither/core/variables/local_variable.py @@ -2,7 +2,6 @@ from slither.core.variables.variable import Variable from slither.core.solidity_types.user_defined_type import UserDefinedType -from slither.core.solidity_types.array_type import ArrayType from slither.core.solidity_types.mapping_type import MappingType from slither.core.solidity_types.elementary_type import ElementaryType @@ -51,6 +50,9 @@ def is_storage(self) -> bool: Returns: (bool) """ + # pylint: disable=import-outside-toplevel + from slither.core.solidity_types.array_type import ArrayType + if self.location == "memory": return False if self.location == "calldata": diff --git a/slither/printers/all_printers.py b/slither/printers/all_printers.py index 5555fe7082..3edd5325b6 100644 --- a/slither/printers/all_printers.py +++ b/slither/printers/all_printers.py @@ -9,6 +9,8 @@ from .summary.slithir import PrinterSlithIR from .summary.slithir_ssa import PrinterSlithIRSSA from .summary.human_summary import PrinterHumanSummary +from .summary.ck import CK +from .summary.halstead import Halstead from .functions.cfg import CFG from .summary.function_ids import FunctionIds from .summary.variable_order import VariableOrder @@ -21,3 +23,4 @@ from .summary.when_not_paused import PrinterWhenNotPaused from .summary.declaration import Declaration from .functions.dominator import Dominator +from .summary.martin import Martin diff --git a/slither/printers/summary/ck.py b/slither/printers/summary/ck.py new file mode 100644 index 0000000000..78da23756b --- /dev/null +++ b/slither/printers/summary/ck.py @@ -0,0 +1,58 @@ +""" + CK Metrics are a suite of six software metrics proposed by Chidamber and Kemerer in 1994. + These metrics are used to measure the complexity of a class. + https://en.wikipedia.org/wiki/Programming_complexity + + - Response For a Class (RFC) is a metric that measures the number of unique method calls within a class. + - Number of Children (NOC) is a metric that measures the number of children a class has. + - Depth of Inheritance Tree (DIT) is a metric that measures the number of parent classes a class has. + - Coupling Between Object Classes (CBO) is a metric that measures the number of classes a class is coupled to. + + Not implemented: + - Lack of Cohesion of Methods (LCOM) is a metric that measures the lack of cohesion in methods. + - Weighted Methods per Class (WMC) is a metric that measures the complexity of a class. + + During the calculation of the metrics above, there are a number of other intermediate metrics that are calculated. + These are also included in the output: + - State variables: total number of state variables + - Constants: total number of constants + - Immutables: total number of immutables + - Public: total number of public functions + - External: total number of external functions + - Internal: total number of internal functions + - Private: total number of private functions + - Mutating: total number of state mutating functions + - View: total number of view functions + - Pure: total number of pure functions + - External mutating: total number of external mutating functions + - No auth or onlyOwner: total number of functions without auth or onlyOwner modifiers + - No modifiers: total number of functions without modifiers + - Ext calls: total number of external calls + +""" +from slither.printers.abstract_printer import AbstractPrinter +from slither.utils.ck import CKMetrics +from slither.utils.output import Output + + +class CK(AbstractPrinter): + ARGUMENT = "ck" + HELP = "Chidamber and Kemerer (CK) complexity metrics and related function attributes" + + WIKI = "https://github.com/trailofbits/slither/wiki/Printer-documentation#ck" + + def output(self, _filename: str) -> Output: + if len(self.contracts) == 0: + return self.generate_output("No contract found") + + ck = CKMetrics(self.contracts) + + res = self.generate_output(ck.full_text) + res.add_pretty_table(ck.auxiliary1.pretty_table, ck.auxiliary1.title) + res.add_pretty_table(ck.auxiliary2.pretty_table, ck.auxiliary2.title) + res.add_pretty_table(ck.auxiliary3.pretty_table, ck.auxiliary3.title) + res.add_pretty_table(ck.auxiliary4.pretty_table, ck.auxiliary4.title) + res.add_pretty_table(ck.core.pretty_table, ck.core.title) + self.info(ck.full_text) + + return res diff --git a/slither/printers/summary/halstead.py b/slither/printers/summary/halstead.py new file mode 100644 index 0000000000..d3c3557db9 --- /dev/null +++ b/slither/printers/summary/halstead.py @@ -0,0 +1,49 @@ +""" + Halstead complexity metrics + https://en.wikipedia.org/wiki/Halstead_complexity_measures + + 12 metrics based on the number of unique operators and operands: + + Core metrics: + n1 = the number of distinct operators + n2 = the number of distinct operands + N1 = the total number of operators + N2 = the total number of operands + + Extended metrics1: + n = n1 + n2 # Program vocabulary + N = N1 + N2 # Program length + S = n1 * log2(n1) + n2 * log2(n2) # Estimated program length + V = N * log2(n) # Volume + + Extended metrics2: + D = (n1 / 2) * (N2 / n2) # Difficulty + E = D * V # Effort + T = E / 18 seconds # Time required to program + B = (E^(2/3)) / 3000 # Number of delivered bugs + +""" +from slither.printers.abstract_printer import AbstractPrinter +from slither.utils.halstead import HalsteadMetrics +from slither.utils.output import Output + + +class Halstead(AbstractPrinter): + ARGUMENT = "halstead" + HELP = "Computes the Halstead complexity metrics for each contract" + + WIKI = "https://github.com/trailofbits/slither/wiki/Printer-documentation#halstead" + + def output(self, _filename: str) -> Output: + if len(self.contracts) == 0: + return self.generate_output("No contract found") + + halstead = HalsteadMetrics(self.contracts) + + res = self.generate_output(halstead.full_text) + res.add_pretty_table(halstead.core.pretty_table, halstead.core.title) + res.add_pretty_table(halstead.extended1.pretty_table, halstead.extended1.title) + res.add_pretty_table(halstead.extended2.pretty_table, halstead.extended2.title) + self.info(halstead.full_text) + + return res diff --git a/slither/printers/summary/martin.py b/slither/printers/summary/martin.py new file mode 100644 index 0000000000..a0f1bbfcb1 --- /dev/null +++ b/slither/printers/summary/martin.py @@ -0,0 +1,32 @@ +""" + Robert "Uncle Bob" Martin - Agile software metrics + https://en.wikipedia.org/wiki/Software_package_metrics + + Efferent Coupling (Ce): Number of contracts that the contract depends on + Afferent Coupling (Ca): Number of contracts that depend on a contract + Instability (I): Ratio of efferent coupling to total coupling (Ce / (Ce + Ca)) + Abstractness (A): Number of abstract contracts / total number of contracts + Distance from the Main Sequence (D): abs(A + I - 1) + +""" +from slither.printers.abstract_printer import AbstractPrinter +from slither.utils.martin import MartinMetrics +from slither.utils.output import Output + + +class Martin(AbstractPrinter): + ARGUMENT = "martin" + HELP = "Martin agile software metrics (Ca, Ce, I, A, D)" + + WIKI = "https://github.com/trailofbits/slither/wiki/Printer-documentation#martin" + + def output(self, _filename: str) -> Output: + if len(self.contracts) == 0: + return self.generate_output("No contract found") + + martin = MartinMetrics(self.contracts) + + res = self.generate_output(martin.full_text) + res.add_pretty_table(martin.core.pretty_table, martin.core.title) + self.info(martin.full_text) + return res diff --git a/slither/utils/ck.py b/slither/utils/ck.py new file mode 100644 index 0000000000..ffba663ad2 --- /dev/null +++ b/slither/utils/ck.py @@ -0,0 +1,348 @@ +""" + CK Metrics are a suite of six software metrics proposed by Chidamber and Kemerer in 1994. + These metrics are used to measure the complexity of a class. + https://en.wikipedia.org/wiki/Programming_complexity + + - Response For a Class (RFC) is a metric that measures the number of unique method calls within a class. + - Number of Children (NOC) is a metric that measures the number of children a class has. + - Depth of Inheritance Tree (DIT) is a metric that measures the number of parent classes a class has. + - Coupling Between Object Classes (CBO) is a metric that measures the number of classes a class is coupled to. + + Not implemented: + - Lack of Cohesion of Methods (LCOM) is a metric that measures the lack of cohesion in methods. + - Weighted Methods per Class (WMC) is a metric that measures the complexity of a class. + + During the calculation of the metrics above, there are a number of other intermediate metrics that are calculated. + These are also included in the output: + - State variables: total number of state variables + - Constants: total number of constants + - Immutables: total number of immutables + - Public: total number of public functions + - External: total number of external functions + - Internal: total number of internal functions + - Private: total number of private functions + - Mutating: total number of state mutating functions + - View: total number of view functions + - Pure: total number of pure functions + - External mutating: total number of external mutating functions + - No auth or onlyOwner: total number of functions without auth or onlyOwner modifiers + - No modifiers: total number of functions without modifiers + - Ext calls: total number of external calls + +""" +from collections import OrderedDict +from typing import Tuple, List, Dict +from dataclasses import dataclass, field +from slither.utils.colors import bold +from slither.core.declarations import Contract +from slither.utils.myprettytable import make_pretty_table, MyPrettyTable +from slither.utils.martin import MartinMetrics +from slither.slithir.operations.high_level_call import HighLevelCall + + +# Utility functions + + +def compute_dit(contract: Contract, depth: int = 0) -> int: + """ + Recursively compute the depth of inheritance tree (DIT) of a contract + Args: + contract(core.declarations.contract.Contract): contract to compute DIT for + depth(int): current depth of the contract + Returns: + int: depth of the contract + """ + if not contract.inheritance: + return depth + max_dit = depth + for inherited_contract in contract.inheritance: + dit = compute_dit(inherited_contract, depth + 1) + max_dit = max(max_dit, dit) + return max_dit + + +def has_auth(func) -> bool: + """ + Check if a function has no auth or only_owner modifiers + Args: + func(core.declarations.function.Function): function to check + Returns: + bool True if it does have auth or only_owner modifiers + """ + for modifier in func.modifiers: + if "auth" in modifier.name or "only_owner" in modifier.name: + return True + return False + + +# Utility classes for calculating CK metrics + + +@dataclass +# pylint: disable=too-many-instance-attributes +class CKContractMetrics: + """Class to hold the CK metrics for a single contract.""" + + contract: Contract + + # Used to calculate CBO - should be passed in as a constructor arg + martin_metrics: Dict + + # Used to calculate NOC + dependents: Dict + + state_variables: int = 0 + constants: int = 0 + immutables: int = 0 + public: int = 0 + external: int = 0 + internal: int = 0 + private: int = 0 + mutating: int = 0 + view: int = 0 + pure: int = 0 + external_mutating: int = 0 + no_auth_or_only_owner: int = 0 + no_modifiers: int = 0 + ext_calls: int = 0 + rfc: int = 0 + noc: int = 0 + dit: int = 0 + cbo: int = 0 + + def __post_init__(self) -> None: + if not hasattr(self.contract, "functions"): + return + self.count_variables() + self.noc = len(self.dependents[self.contract.name]) + self.dit = compute_dit(self.contract) + self.cbo = ( + self.martin_metrics[self.contract.name].ca + self.martin_metrics[self.contract.name].ce + ) + self.calculate_metrics() + + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + def calculate_metrics(self) -> None: + """Calculate the metrics for a contract""" + rfc = self.public # initialize with public getter count + for func in self.contract.functions: + if func.name == "constructor": + continue + pure = func.pure + view = not pure and func.view + mutating = not pure and not view + external = func.visibility == "external" + public = func.visibility == "public" + internal = func.visibility == "internal" + private = func.visibility == "private" + external_public_mutating = external or public and mutating + external_no_auth = external_public_mutating and not has_auth(func) + external_no_modifiers = external_public_mutating and len(func.modifiers) == 0 + if external or public: + rfc += 1 + + high_level_calls = [ + ir for node in func.nodes for ir in node.irs_ssa if isinstance(ir, HighLevelCall) + ] + + # convert irs to string with target function and contract name + external_calls = [] + for high_level_call in high_level_calls: + if isinstance(high_level_call.destination, Contract): + destination_contract = high_level_call.destination.name + elif isinstance(high_level_call.destination, str): + destination_contract = high_level_call.destination + elif not hasattr(high_level_call.destination, "type"): + continue + elif isinstance(high_level_call.destination.type, Contract): + destination_contract = high_level_call.destination.type.name + elif isinstance(high_level_call.destination.type, str): + destination_contract = high_level_call.destination.type + elif not hasattr(high_level_call.destination.type, "type"): + continue + elif isinstance(high_level_call.destination.type.type, Contract): + destination_contract = high_level_call.destination.type.type.name + elif isinstance(high_level_call.destination.type.type, str): + destination_contract = high_level_call.destination.type.type + else: + continue + external_calls.append(f"{high_level_call.function_name}{destination_contract}") + rfc += len(set(external_calls)) + + self.public += public + self.external += external + self.internal += internal + self.private += private + + self.mutating += mutating + self.view += view + self.pure += pure + + self.external_mutating += external_public_mutating + self.no_auth_or_only_owner += external_no_auth + self.no_modifiers += external_no_modifiers + + self.ext_calls += len(external_calls) + self.rfc = rfc + + def count_variables(self) -> None: + """Count the number of variables in a contract""" + state_variable_count = 0 + constant_count = 0 + immutable_count = 0 + public_getter_count = 0 + for variable in self.contract.variables: + if variable.is_constant: + constant_count += 1 + elif variable.is_immutable: + immutable_count += 1 + else: + state_variable_count += 1 + if variable.visibility == "Public": + public_getter_count += 1 + self.state_variables = state_variable_count + self.constants = constant_count + self.immutables = immutable_count + + # initialize RFC with public getter count + # self.public is used count public functions not public variables + self.rfc = public_getter_count + + def to_dict(self) -> Dict[str, float]: + """Return the metrics as a dictionary.""" + return OrderedDict( + { + "State variables": self.state_variables, + "Constants": self.constants, + "Immutables": self.immutables, + "Public": self.public, + "External": self.external, + "Internal": self.internal, + "Private": self.private, + "Mutating": self.mutating, + "View": self.view, + "Pure": self.pure, + "External mutating": self.external_mutating, + "No auth or onlyOwner": self.no_auth_or_only_owner, + "No modifiers": self.no_modifiers, + "Ext calls": self.ext_calls, + "RFC": self.rfc, + "NOC": self.noc, + "DIT": self.dit, + "CBO": self.cbo, + } + ) + + +@dataclass +class SectionInfo: + """Class to hold the information for a section of the report.""" + + title: str + pretty_table: MyPrettyTable + txt: str + + +@dataclass +# pylint: disable=too-many-instance-attributes +class CKMetrics: + """Class to hold the CK metrics for all contracts. Contains methods useful for reporting. + + There are 5 sections in the report: + 1. Variable count by type (state, constant, immutable) + 2. Function count by visibility (public, external, internal, private) + 3. Function count by mutability (mutating, view, pure) + 4. External mutating function count by modifier (external mutating, no auth or onlyOwner, no modifiers) + 5. CK metrics (RFC, NOC, DIT, CBO) + """ + + contracts: List[Contract] = field(default_factory=list) + contract_metrics: OrderedDict = field(default_factory=OrderedDict) + title: str = "CK complexity metrics" + full_text: str = "" + auxiliary1: SectionInfo = field(default=SectionInfo) + auxiliary2: SectionInfo = field(default=SectionInfo) + auxiliary3: SectionInfo = field(default=SectionInfo) + auxiliary4: SectionInfo = field(default=SectionInfo) + core: SectionInfo = field(default=SectionInfo) + AUXILIARY1_KEYS = ( + "State variables", + "Constants", + "Immutables", + ) + AUXILIARY2_KEYS = ( + "Public", + "External", + "Internal", + "Private", + ) + AUXILIARY3_KEYS = ( + "Mutating", + "View", + "Pure", + ) + AUXILIARY4_KEYS = ( + "External mutating", + "No auth or onlyOwner", + "No modifiers", + ) + CORE_KEYS = ( + "Ext calls", + "RFC", + "NOC", + "DIT", + "CBO", + ) + SECTIONS: Tuple[Tuple[str, str, Tuple[str]]] = ( + ("Variables", "auxiliary1", AUXILIARY1_KEYS), + ("Function visibility", "auxiliary2", AUXILIARY2_KEYS), + ("State mutability", "auxiliary3", AUXILIARY3_KEYS), + ("External mutating functions", "auxiliary4", AUXILIARY4_KEYS), + ("Core", "core", CORE_KEYS), + ) + + def __post_init__(self) -> None: + martin_metrics = MartinMetrics(self.contracts).contract_metrics + dependents = { + inherited.name: { + contract.name + for contract in self.contracts + if inherited.name in contract.inheritance + } + for inherited in self.contracts + } + for contract in self.contracts: + self.contract_metrics[contract.name] = CKContractMetrics( + contract=contract, martin_metrics=martin_metrics, dependents=dependents + ) + + # Create the table and text for each section. + data = { + contract.name: self.contract_metrics[contract.name].to_dict() + for contract in self.contracts + } + + subtitle = "" + # Update each section + for (title, attr, keys) in self.SECTIONS: + if attr == "core": + # Special handling for core section + totals_enabled = False + subtitle += bold("RFC: Response For a Class\n") + subtitle += bold("NOC: Number of Children\n") + subtitle += bold("DIT: Depth of Inheritance Tree\n") + subtitle += bold("CBO: Coupling Between Object Classes\n") + else: + totals_enabled = True + subtitle = "" + + pretty_table = make_pretty_table(["Contract", *keys], data, totals=totals_enabled) + section_title = f"{self.title} ({title})" + txt = f"\n\n{section_title}:\n{subtitle}{pretty_table}\n" + self.full_text += txt + setattr( + self, + attr, + SectionInfo(title=section_title, pretty_table=pretty_table, txt=txt), + ) diff --git a/slither/utils/encoding.py b/slither/utils/encoding.py new file mode 100644 index 0000000000..288b581505 --- /dev/null +++ b/slither/utils/encoding.py @@ -0,0 +1,202 @@ +from typing import Union + +from slither.core import variables +from slither.core.declarations import ( + SolidityVariable, + SolidityVariableComposed, + Structure, + Enum, + Contract, +) +from slither.core import solidity_types +from slither.slithir import operations +from slither.slithir import variables as SlitherIRVariable + + +# pylint: disable=too-many-branches +def ntype(_type: Union[solidity_types.Type, str]) -> str: + if isinstance(_type, solidity_types.ElementaryType): + _type = str(_type) + elif isinstance(_type, solidity_types.ArrayType): + if isinstance(_type.type, solidity_types.ElementaryType): + _type = str(_type) + else: + _type = "user_defined_array" + elif isinstance(_type, Structure): + _type = str(_type) + elif isinstance(_type, Enum): + _type = str(_type) + elif isinstance(_type, solidity_types.MappingType): + _type = str(_type) + elif isinstance(_type, solidity_types.UserDefinedType): + if isinstance(_type.type, Contract): + _type = f"contract({_type.type.name})" + elif isinstance(_type.type, Structure): + _type = f"struct({_type.type.name})" + elif isinstance(_type.type, Enum): + _type = f"enum({_type.type.name})" + else: + _type = str(_type) + + _type = _type.replace(" memory", "") + _type = _type.replace(" storage ref", "") + + if "struct" in _type: + return "struct" + if "enum" in _type: + return "enum" + if "tuple" in _type: + return "tuple" + if "contract" in _type: + return "contract" + if "mapping" in _type: + return "mapping" + return _type.replace(" ", "_") + + +# pylint: disable=too-many-branches +def encode_var_for_compare(var: Union[variables.Variable, SolidityVariable]) -> str: + + # variables + if isinstance(var, SlitherIRVariable.Constant): + return f"constant({ntype(var.type)},{var.value})" + if isinstance(var, SolidityVariableComposed): + return f"solidity_variable_composed({var.name})" + if isinstance(var, SolidityVariable): + return f"solidity_variable{var.name}" + if isinstance(var, SlitherIRVariable.TemporaryVariable): + return "temporary_variable" + if isinstance(var, SlitherIRVariable.ReferenceVariable): + return f"reference({ntype(var.type)})" + if isinstance(var, variables.LocalVariable): + return f"local_solc_variable({ntype(var.type)},{var.location})" + if isinstance(var, variables.StateVariable): + if not (var.is_constant or var.is_immutable): + try: + slot, _ = var.contract.compilation_unit.storage_layout_of(var.contract, var) + except KeyError: + slot = var.name + else: + slot = var.name + return f"state_solc_variable({ntype(var.type)},{slot})" + if isinstance(var, variables.LocalVariableInitFromTuple): + return "local_variable_init_tuple" + if isinstance(var, SlitherIRVariable.TupleVariable): + return "tuple_variable" + + # default + return "" + + +# pylint: disable=too-many-branches +def encode_ir_for_upgradeability_compare(ir: operations.Operation) -> str: + # operations + if isinstance(ir, operations.Assignment): + return f"({encode_var_for_compare(ir.lvalue)}):=({encode_var_for_compare(ir.rvalue)})" + if isinstance(ir, operations.Index): + return f"index({ntype(ir.variable_right.type)})" + if isinstance(ir, operations.Member): + return "member" # .format(ntype(ir._type)) + if isinstance(ir, operations.Length): + return "length" + if isinstance(ir, operations.Binary): + return f"binary({encode_var_for_compare(ir.variable_left)}{ir.type}{encode_var_for_compare(ir.variable_right)})" + if isinstance(ir, operations.Unary): + return f"unary({str(ir.type)})" + if isinstance(ir, operations.Condition): + return f"condition({encode_var_for_compare(ir.value)})" + if isinstance(ir, operations.NewStructure): + return "new_structure" + if isinstance(ir, operations.NewContract): + return "new_contract" + if isinstance(ir, operations.NewArray): + return f"new_array({ntype(ir.array_type)})" + if isinstance(ir, operations.NewElementaryType): + return f"new_elementary({ntype(ir.type)})" + if isinstance(ir, operations.Delete): + return f"delete({encode_var_for_compare(ir.lvalue)},{encode_var_for_compare(ir.variable)})" + if isinstance(ir, operations.SolidityCall): + return f"solidity_call({ir.function.full_name})" + if isinstance(ir, operations.InternalCall): + return f"internal_call({ntype(ir.type_call)})" + if isinstance(ir, operations.EventCall): # is this useful? + return "event" + if isinstance(ir, operations.LibraryCall): + return "library_call" + if isinstance(ir, operations.InternalDynamicCall): + return "internal_dynamic_call" + if isinstance(ir, operations.HighLevelCall): # TODO: improve + return "high_level_call" + if isinstance(ir, operations.LowLevelCall): # TODO: improve + return "low_level_call" + if isinstance(ir, operations.TypeConversion): + return f"type_conversion({ntype(ir.type)})" + if isinstance(ir, operations.Return): # this can be improved using values + return "return" # .format(ntype(ir.type)) + if isinstance(ir, operations.Transfer): + return f"transfer({encode_var_for_compare(ir.call_value)})" + if isinstance(ir, operations.Send): + return f"send({encode_var_for_compare(ir.call_value)})" + if isinstance(ir, operations.Unpack): # TODO: improve + return "unpack" + if isinstance(ir, operations.InitArray): # TODO: improve + return "init_array" + + # default + return "" + + +def encode_ir_for_halstead(ir: operations.Operation) -> str: + # operations + if isinstance(ir, operations.Assignment): + return "assignment" + if isinstance(ir, operations.Index): + return "index" + if isinstance(ir, operations.Member): + return "member" # .format(ntype(ir._type)) + if isinstance(ir, operations.Length): + return "length" + if isinstance(ir, operations.Binary): + return f"binary({str(ir.type)})" + if isinstance(ir, operations.Unary): + return f"unary({str(ir.type)})" + if isinstance(ir, operations.Condition): + return f"condition({encode_var_for_compare(ir.value)})" + if isinstance(ir, operations.NewStructure): + return "new_structure" + if isinstance(ir, operations.NewContract): + return "new_contract" + if isinstance(ir, operations.NewArray): + return f"new_array({ntype(ir.array_type)})" + if isinstance(ir, operations.NewElementaryType): + return f"new_elementary({ntype(ir.type)})" + if isinstance(ir, operations.Delete): + return "delete" + if isinstance(ir, operations.SolidityCall): + return f"solidity_call({ir.function.full_name})" + if isinstance(ir, operations.InternalCall): + return f"internal_call({ntype(ir.type_call)})" + if isinstance(ir, operations.EventCall): # is this useful? + return "event" + if isinstance(ir, operations.LibraryCall): + return "library_call" + if isinstance(ir, operations.InternalDynamicCall): + return "internal_dynamic_call" + if isinstance(ir, operations.HighLevelCall): # TODO: improve + return "high_level_call" + if isinstance(ir, operations.LowLevelCall): # TODO: improve + return "low_level_call" + if isinstance(ir, operations.TypeConversion): + return f"type_conversion({ntype(ir.type)})" + if isinstance(ir, operations.Return): # this can be improved using values + return "return" # .format(ntype(ir.type)) + if isinstance(ir, operations.Transfer): + return "transfer" + if isinstance(ir, operations.Send): + return "send" + if isinstance(ir, operations.Unpack): # TODO: improve + return "unpack" + if isinstance(ir, operations.InitArray): # TODO: improve + return "init_array" + # default + raise NotImplementedError(f"encode_ir_for_halstead: {ir}") diff --git a/slither/utils/halstead.py b/slither/utils/halstead.py new file mode 100644 index 0000000000..9ec952e486 --- /dev/null +++ b/slither/utils/halstead.py @@ -0,0 +1,233 @@ +""" + Halstead complexity metrics + https://en.wikipedia.org/wiki/Halstead_complexity_measures + + 12 metrics based on the number of unique operators and operands: + + Core metrics: + n1 = the number of distinct operators + n2 = the number of distinct operands + N1 = the total number of operators + N2 = the total number of operands + + Extended metrics1: + n = n1 + n2 # Program vocabulary + N = N1 + N2 # Program length + S = n1 * log2(n1) + n2 * log2(n2) # Estimated program length + V = N * log2(n) # Volume + + Extended metrics2: + D = (n1 / 2) * (N2 / n2) # Difficulty + E = D * V # Effort + T = E / 18 seconds # Time required to program + B = (E^(2/3)) / 3000 # Number of delivered bugs + + +""" +import math +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Tuple, List, Dict + +from slither.core.declarations import Contract +from slither.slithir.variables.temporary import TemporaryVariable +from slither.utils.encoding import encode_ir_for_halstead +from slither.utils.myprettytable import make_pretty_table, MyPrettyTable + + +# pylint: disable=too-many-branches + + +@dataclass +# pylint: disable=too-many-instance-attributes +class HalsteadContractMetrics: + """Class to hold the Halstead metrics for a single contract.""" + + contract: Contract + all_operators: List[str] = field(default_factory=list) + all_operands: List[str] = field(default_factory=list) + n1: int = 0 + n2: int = 0 + N1: int = 0 + N2: int = 0 + n: int = 0 + N: int = 0 + S: float = 0 + V: float = 0 + D: float = 0 + E: float = 0 + T: float = 0 + B: float = 0 + + def __post_init__(self) -> None: + """Operators and operands can be passed in as constructor args to avoid computing + them based on the contract. Useful for computing metrics for ALL_CONTRACTS""" + + if len(self.all_operators) == 0: + if not hasattr(self.contract, "functions"): + return + self.populate_operators_and_operands() + if len(self.all_operators) > 0: + self.compute_metrics() + + def to_dict(self) -> Dict[str, float]: + """Return the metrics as a dictionary.""" + return OrderedDict( + { + "Total Operators": self.N1, + "Unique Operators": self.n1, + "Total Operands": self.N2, + "Unique Operands": self.n2, + "Vocabulary": str(self.n1 + self.n2), + "Program Length": str(self.N1 + self.N2), + "Estimated Length": f"{self.S:.0f}", + "Volume": f"{self.V:.0f}", + "Difficulty": f"{self.D:.0f}", + "Effort": f"{self.E:.0f}", + "Time": f"{self.T:.0f}", + "Estimated Bugs": f"{self.B:.3f}", + } + ) + + def populate_operators_and_operands(self) -> None: + """Populate the operators and operands lists.""" + operators = [] + operands = [] + + for func in self.contract.functions: + for node in func.nodes: + for operation in node.irs: + # use operation.expression.type to get the unique operator type + encoded_operator = encode_ir_for_halstead(operation) + operators.append(encoded_operator) + + # use operation.used to get the operands of the operation ignoring the temporary variables + operands.extend( + [op for op in operation.used if not isinstance(op, TemporaryVariable)] + ) + self.all_operators.extend(operators) + self.all_operands.extend(operands) + + def compute_metrics(self, all_operators=None, all_operands=None) -> None: + """Compute the Halstead metrics.""" + if all_operators is None: + all_operators = self.all_operators + all_operands = self.all_operands + + # core metrics + self.n1 = len(set(all_operators)) + self.n2 = len(set(all_operands)) + self.N1 = len(all_operators) + self.N2 = len(all_operands) + if any(number <= 0 for number in [self.n1, self.n2, self.N1, self.N2]): + raise ValueError("n1 and n2 must be greater than 0") + + # extended metrics 1 + self.n = self.n1 + self.n2 + self.N = self.N1 + self.N2 + self.S = self.n1 * math.log2(self.n1) + self.n2 * math.log2(self.n2) + self.V = self.N * math.log2(self.n) + + # extended metrics 2 + self.D = (self.n1 / 2) * (self.N2 / self.n2) + self.E = self.D * self.V + self.T = self.E / 18 + self.B = (self.E ** (2 / 3)) / 3000 + + +@dataclass +class SectionInfo: + """Class to hold the information for a section of the report.""" + + title: str + pretty_table: MyPrettyTable + txt: str + + +@dataclass +# pylint: disable=too-many-instance-attributes +class HalsteadMetrics: + """Class to hold the Halstead metrics for all contracts. Contains methods useful for reporting. + + There are 3 sections in the report: + 1. Core metrics (n1, n2, N1, N2) + 2. Extended metrics 1 (n, N, S, V) + 3. Extended metrics 2 (D, E, T, B) + + """ + + contracts: List[Contract] = field(default_factory=list) + contract_metrics: OrderedDict = field(default_factory=OrderedDict) + title: str = "Halstead complexity metrics" + full_text: str = "" + core: SectionInfo = field(default=SectionInfo) + extended1: SectionInfo = field(default=SectionInfo) + extended2: SectionInfo = field(default=SectionInfo) + CORE_KEYS = ( + "Total Operators", + "Unique Operators", + "Total Operands", + "Unique Operands", + ) + EXTENDED1_KEYS = ( + "Vocabulary", + "Program Length", + "Estimated Length", + "Volume", + ) + EXTENDED2_KEYS = ( + "Difficulty", + "Effort", + "Time", + "Estimated Bugs", + ) + SECTIONS: Tuple[Tuple[str, str, Tuple[str]]] = ( + ("Core", "core", CORE_KEYS), + ("Extended 1/2", "extended1", EXTENDED1_KEYS), + ("Extended 2/2", "extended2", EXTENDED2_KEYS), + ) + + def __post_init__(self) -> None: + # Compute the metrics for each contract and for all contracts. + self.update_contract_metrics() + self.add_all_contracts_metrics() + self.update_reporting_sections() + + def update_contract_metrics(self) -> None: + for contract in self.contracts: + self.contract_metrics[contract.name] = HalsteadContractMetrics(contract=contract) + + def add_all_contracts_metrics(self) -> None: + # If there are more than 1 contract, compute the metrics for all contracts. + if len(self.contracts) <= 1: + return + all_operators = [ + operator + for contract in self.contracts + for operator in self.contract_metrics[contract.name].all_operators + ] + all_operands = [ + operand + for contract in self.contracts + for operand in self.contract_metrics[contract.name].all_operands + ] + self.contract_metrics["ALL CONTRACTS"] = HalsteadContractMetrics( + None, all_operators=all_operators, all_operands=all_operands + ) + + def update_reporting_sections(self) -> None: + # Create the table and text for each section. + data = { + contract.name: self.contract_metrics[contract.name].to_dict() + for contract in self.contracts + } + for (title, attr, keys) in self.SECTIONS: + pretty_table = make_pretty_table(["Contract", *keys], data, False) + section_title = f"{self.title} ({title})" + txt = f"\n\n{section_title}:\n{pretty_table}\n" + self.full_text += txt + setattr( + self, + attr, + SectionInfo(title=section_title, pretty_table=pretty_table, txt=txt), + ) diff --git a/slither/utils/martin.py b/slither/utils/martin.py new file mode 100644 index 0000000000..c336227fd2 --- /dev/null +++ b/slither/utils/martin.py @@ -0,0 +1,157 @@ +""" + Robert "Uncle Bob" Martin - Agile software metrics + https://en.wikipedia.org/wiki/Software_package_metrics + + Efferent Coupling (Ce): Number of contracts that the contract depends on + Afferent Coupling (Ca): Number of contracts that depend on a contract + Instability (I): Ratio of efferent coupling to total coupling (Ce / (Ce + Ca)) + Abstractness (A): Number of abstract contracts / total number of contracts + Distance from the Main Sequence (D): abs(A + I - 1) + +""" +from typing import Tuple, List, Dict +from dataclasses import dataclass, field +from collections import OrderedDict +from slither.slithir.operations.high_level_call import HighLevelCall +from slither.core.declarations import Contract +from slither.utils.myprettytable import make_pretty_table, MyPrettyTable + + +@dataclass +class MartinContractMetrics: + contract: Contract + ca: int + ce: int + abstractness: float + i: float = 0.0 + d: float = 0.0 + + def __post_init__(self) -> None: + if self.ce + self.ca > 0: + self.i = float(self.ce / (self.ce + self.ca)) + self.d = float(abs(self.i - self.abstractness)) + + def to_dict(self) -> Dict: + return { + "Dependents": self.ca, + "Dependencies": self.ce, + "Instability": f"{self.i:.2f}", + "Distance from main sequence": f"{self.d:.2f}", + } + + +@dataclass +class SectionInfo: + """Class to hold the information for a section of the report.""" + + title: str + pretty_table: MyPrettyTable + txt: str + + +@dataclass +class MartinMetrics: + contracts: List[Contract] = field(default_factory=list) + abstractness: float = 0.0 + contract_metrics: OrderedDict = field(default_factory=OrderedDict) + title: str = "Martin complexity metrics" + full_text: str = "" + core: SectionInfo = field(default=SectionInfo) + CORE_KEYS = ( + "Dependents", + "Dependencies", + "Instability", + "Distance from main sequence", + ) + SECTIONS: Tuple[Tuple[str, str, Tuple[str]]] = (("Core", "core", CORE_KEYS),) + + def __post_init__(self) -> None: + self.update_abstractness() + self.update_coupling() + self.update_reporting_sections() + + def update_reporting_sections(self) -> None: + # Create the table and text for each section. + data = { + contract.name: self.contract_metrics[contract.name].to_dict() + for contract in self.contracts + } + for (title, attr, keys) in self.SECTIONS: + pretty_table = make_pretty_table(["Contract", *keys], data, False) + section_title = f"{self.title} ({title})" + txt = f"\n\n{section_title}:\n" + txt = "Martin agile software metrics\n" + txt += "Efferent Coupling (Ce) - Number of contracts that a contract depends on\n" + txt += "Afferent Coupling (Ca) - Number of contracts that depend on the contract\n" + txt += ( + "Instability (I) - Ratio of efferent coupling to total coupling (Ce / (Ce + Ca))\n" + ) + txt += "Abstractness (A) - Number of abstract contracts / total number of contracts\n" + txt += "Distance from the Main Sequence (D) - abs(A + I - 1)\n" + txt += "\n" + txt += f"Abstractness (overall): {round(self.abstractness, 2)}\n" + txt += f"{pretty_table}\n" + self.full_text += txt + setattr( + self, + attr, + SectionInfo(title=section_title, pretty_table=pretty_table, txt=txt), + ) + + def update_abstractness(self) -> None: + abstract_contract_count = 0 + for c in self.contracts: + if not c.is_fully_implemented: + abstract_contract_count += 1 + self.abstractness = float(abstract_contract_count / len(self.contracts)) + + # pylint: disable=too-many-branches + def update_coupling(self) -> None: + dependencies = {} + for contract in self.contracts: + external_calls = [] + for func in contract.functions: + high_level_calls = [ + ir + for node in func.nodes + for ir in node.irs_ssa + if isinstance(ir, HighLevelCall) + ] + # convert irs to string with target function and contract name + # Get the target contract name for each high level call + new_external_calls = [] + for high_level_call in high_level_calls: + if isinstance(high_level_call.destination, Contract): + new_external_call = high_level_call.destination.name + elif isinstance(high_level_call.destination, str): + new_external_call = high_level_call.destination + elif not hasattr(high_level_call.destination, "type"): + continue + elif isinstance(high_level_call.destination.type, Contract): + new_external_call = high_level_call.destination.type.name + elif isinstance(high_level_call.destination.type, str): + new_external_call = high_level_call.destination.type + elif not hasattr(high_level_call.destination.type, "type"): + continue + elif isinstance(high_level_call.destination.type.type, Contract): + new_external_call = high_level_call.destination.type.type.name + elif isinstance(high_level_call.destination.type.type, str): + new_external_call = high_level_call.destination.type.type + else: + continue + new_external_calls.append(new_external_call) + external_calls.extend(new_external_calls) + dependencies[contract.name] = set(external_calls) + dependents = {} + for contract, deps in dependencies.items(): + for dep in deps: + if dep not in dependents: + dependents[dep] = set() + dependents[dep].add(contract) + + for contract in self.contracts: + ce = len(dependencies.get(contract.name, [])) + ca = len(dependents.get(contract.name, [])) + self.contract_metrics[contract.name] = MartinContractMetrics( + contract, ca, ce, self.abstractness + ) diff --git a/slither/utils/myprettytable.py b/slither/utils/myprettytable.py index 2f2be7e72d..d67f570c0a 100644 --- a/slither/utils/myprettytable.py +++ b/slither/utils/myprettytable.py @@ -4,9 +4,17 @@ class MyPrettyTable: - def __init__(self, field_names: List[str]): + def __init__(self, field_names: List[str], pretty_align: bool = True): # TODO: True by default? self._field_names = field_names self._rows: List = [] + self._options: Dict = {} + if pretty_align: + self._options["set_alignment"] = [] + self._options["set_alignment"] += [(field_names[0], "l")] + for field_name in field_names[1:]: + self._options["set_alignment"] += [(field_name, "r")] + else: + self._options["set_alignment"] = [] def add_row(self, row: List[Union[str, List[str]]]) -> None: self._rows.append(row) @@ -15,6 +23,9 @@ def to_pretty_table(self) -> ColorTable: table = ColorTable(self._field_names, theme=Themes.OCEAN) for row in self._rows: table.add_row(row) + if len(self._options["set_alignment"]): + for column_header, value in self._options["set_alignment"]: + table.align[column_header] = value return table def to_json(self) -> Dict: @@ -22,3 +33,30 @@ def to_json(self) -> Dict: def __str__(self) -> str: return str(self.to_pretty_table()) + + +# UTILITY FUNCTIONS + + +def make_pretty_table( + headers: list, body: dict, totals: bool = False, total_header="TOTAL" +) -> MyPrettyTable: + """ + Converts a dict to a MyPrettyTable. Dict keys are the row headers. + Args: + headers: str[] of column names + body: dict of row headers with a dict of the values + totals: bool optional add Totals row + total_header: str optional if totals is set to True this will override the default "TOTAL" header + Returns: + MyPrettyTable + """ + table = MyPrettyTable(headers) + for row in body: + table_row = [row] + [body[row][key] for key in headers[1:]] + table.add_row(table_row) + if totals: + table.add_row( + [total_header] + [sum([body[row][key] for row in body]) for key in headers[1:]] + ) + return table diff --git a/slither/utils/upgradeability.py b/slither/utils/upgradeability.py index 3bef3b9b61..bbb253175e 100644 --- a/slither/utils/upgradeability.py +++ b/slither/utils/upgradeability.py @@ -1,66 +1,28 @@ -from typing import Optional, Tuple, List, Union +from typing import Optional, Tuple, List + +from slither.analyses.data_dependency.data_dependency import get_dependencies +from slither.core.cfg.node import Node, NodeType from slither.core.declarations import ( Contract, - Structure, - Enum, - SolidityVariableComposed, - SolidityVariable, Function, ) -from slither.core.solidity_types import ( - Type, - ElementaryType, - ArrayType, - MappingType, - UserDefinedType, -) -from slither.core.variables.local_variable import LocalVariable -from slither.core.variables.local_variable_init_from_tuple import LocalVariableInitFromTuple -from slither.core.variables.state_variable import StateVariable -from slither.analyses.data_dependency.data_dependency import get_dependencies -from slither.core.variables.variable import Variable from slither.core.expressions import ( Literal, Identifier, CallExpression, AssignmentOperation, ) -from slither.core.cfg.node import Node, NodeType +from slither.core.solidity_types import ( + ElementaryType, +) +from slither.core.variables.local_variable import LocalVariable +from slither.core.variables.state_variable import StateVariable +from slither.core.variables.variable import Variable from slither.slithir.operations import ( - Operation, - Assignment, - Index, - Member, - Length, - Binary, - Unary, - Condition, - NewArray, - NewStructure, - NewContract, - NewElementaryType, - SolidityCall, - Delete, - EventCall, - LibraryCall, - InternalDynamicCall, - HighLevelCall, LowLevelCall, - TypeConversion, - Return, - Transfer, - Send, - Unpack, - InitArray, - InternalCall, -) -from slither.slithir.variables import ( - TemporaryVariable, - TupleVariable, - Constant, - ReferenceVariable, ) from slither.tools.read_storage.read_storage import SlotInfo, SlitherReadStorage +from slither.utils.encoding import encode_ir_for_upgradeability_compare class TaintedExternalContract: @@ -385,144 +347,13 @@ def is_function_modified(f1: Function, f2: Function) -> bool: if len(node_f1.irs) != len(node_f2.irs): return True for i, ir in enumerate(node_f1.irs): - if encode_ir_for_compare(ir) != encode_ir_for_compare(node_f2.irs[i]): + if encode_ir_for_upgradeability_compare(ir) != encode_ir_for_upgradeability_compare( + node_f2.irs[i] + ): return True return False -# pylint: disable=too-many-branches -def ntype(_type: Union[Type, str]) -> str: - if isinstance(_type, ElementaryType): - _type = str(_type) - elif isinstance(_type, ArrayType): - if isinstance(_type.type, ElementaryType): - _type = str(_type) - else: - _type = "user_defined_array" - elif isinstance(_type, Structure): - _type = str(_type) - elif isinstance(_type, Enum): - _type = str(_type) - elif isinstance(_type, MappingType): - _type = str(_type) - elif isinstance(_type, UserDefinedType): - if isinstance(_type.type, Contract): - _type = f"contract({_type.type.name})" - elif isinstance(_type.type, Structure): - _type = f"struct({_type.type.name})" - elif isinstance(_type.type, Enum): - _type = f"enum({_type.type.name})" - else: - _type = str(_type) - - _type = _type.replace(" memory", "") - _type = _type.replace(" storage ref", "") - - if "struct" in _type: - return "struct" - if "enum" in _type: - return "enum" - if "tuple" in _type: - return "tuple" - if "contract" in _type: - return "contract" - if "mapping" in _type: - return "mapping" - return _type.replace(" ", "_") - - -# pylint: disable=too-many-branches -def encode_ir_for_compare(ir: Operation) -> str: - # operations - if isinstance(ir, Assignment): - return f"({encode_var_for_compare(ir.lvalue)}):=({encode_var_for_compare(ir.rvalue)})" - if isinstance(ir, Index): - return f"index({ntype(ir.variable_right.type)})" - if isinstance(ir, Member): - return "member" # .format(ntype(ir._type)) - if isinstance(ir, Length): - return "length" - if isinstance(ir, Binary): - return f"binary({encode_var_for_compare(ir.variable_left)}{ir.type}{encode_var_for_compare(ir.variable_right)})" - if isinstance(ir, Unary): - return f"unary({str(ir.type)})" - if isinstance(ir, Condition): - return f"condition({encode_var_for_compare(ir.value)})" - if isinstance(ir, NewStructure): - return "new_structure" - if isinstance(ir, NewContract): - return "new_contract" - if isinstance(ir, NewArray): - return f"new_array({ntype(ir.array_type)})" - if isinstance(ir, NewElementaryType): - return f"new_elementary({ntype(ir.type)})" - if isinstance(ir, Delete): - return f"delete({encode_var_for_compare(ir.lvalue)},{encode_var_for_compare(ir.variable)})" - if isinstance(ir, SolidityCall): - return f"solidity_call({ir.function.full_name})" - if isinstance(ir, InternalCall): - return f"internal_call({ntype(ir.type_call)})" - if isinstance(ir, EventCall): # is this useful? - return "event" - if isinstance(ir, LibraryCall): - return "library_call" - if isinstance(ir, InternalDynamicCall): - return "internal_dynamic_call" - if isinstance(ir, HighLevelCall): # TODO: improve - return "high_level_call" - if isinstance(ir, LowLevelCall): # TODO: improve - return "low_level_call" - if isinstance(ir, TypeConversion): - return f"type_conversion({ntype(ir.type)})" - if isinstance(ir, Return): # this can be improved using values - return "return" # .format(ntype(ir.type)) - if isinstance(ir, Transfer): - return f"transfer({encode_var_for_compare(ir.call_value)})" - if isinstance(ir, Send): - return f"send({encode_var_for_compare(ir.call_value)})" - if isinstance(ir, Unpack): # TODO: improve - return "unpack" - if isinstance(ir, InitArray): # TODO: improve - return "init_array" - - # default - return "" - - -# pylint: disable=too-many-branches -def encode_var_for_compare(var: Variable) -> str: - - # variables - if isinstance(var, Constant): - return f"constant({ntype(var.type)},{var.value})" - if isinstance(var, SolidityVariableComposed): - return f"solidity_variable_composed({var.name})" - if isinstance(var, SolidityVariable): - return f"solidity_variable{var.name}" - if isinstance(var, TemporaryVariable): - return "temporary_variable" - if isinstance(var, ReferenceVariable): - return f"reference({ntype(var.type)})" - if isinstance(var, LocalVariable): - return f"local_solc_variable({ntype(var.type)},{var.location})" - if isinstance(var, StateVariable): - if not (var.is_constant or var.is_immutable): - try: - slot, _ = var.contract.compilation_unit.storage_layout_of(var.contract, var) - except KeyError: - slot = var.name - else: - slot = var.name - return f"state_solc_variable({ntype(var.type)},{slot})" - if isinstance(var, LocalVariableInitFromTuple): - return "local_variable_init_tuple" - if isinstance(var, TupleVariable): - return "tuple_variable" - - # default - return "" - - def get_proxy_implementation_slot(proxy: Contract) -> Optional[SlotInfo]: """ Gets information about the storage slot where a proxy's implementation address is stored.