diff --git a/scripts/ci_test_printers.sh b/scripts/ci_test_printers.sh index d13932b816..e7310700e5 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" # Only test 0.5.17 to limit test time for file in *0.5.17-compact.zip; do diff --git a/slither/printers/all_printers.py b/slither/printers/all_printers.py index 5555fe7082..bd9072e24f 100644 --- a/slither/printers/all_printers.py +++ b/slither/printers/all_printers.py @@ -9,6 +9,7 @@ from .summary.slithir import PrinterSlithIR from .summary.slithir_ssa import PrinterSlithIRSSA from .summary.human_summary import PrinterHumanSummary +from .summary.halstead import Halstead from .functions.cfg import CFG from .summary.function_ids import FunctionIds from .summary.variable_order import VariableOrder @@ -21,3 +22,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/halstead.py b/slither/printers/summary/halstead.py new file mode 100644 index 0000000000..8144e467f7 --- /dev/null +++ b/slither/printers/summary/halstead.py @@ -0,0 +1,48 @@ +""" + 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 + + +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): + 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..c49e63fcb5 --- /dev/null +++ b/slither/printers/summary/martin.py @@ -0,0 +1,31 @@ +""" + 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 + + +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): + 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/halstead.py b/slither/utils/halstead.py new file mode 100644 index 0000000000..a5ab41cc9b --- /dev/null +++ b/slither/utils/halstead.py @@ -0,0 +1,227 @@ +""" + 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 dataclasses import dataclass, field +from typing import Tuple, List, Dict +from collections import OrderedDict +from slither.core.declarations import Contract +from slither.slithir.variables.temporary import TemporaryVariable +from slither.utils.myprettytable import make_pretty_table, MyPrettyTable +from slither.utils.upgradeability import encode_ir_for_halstead + + +@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): + """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: + 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): + """Populate the operators and operands lists.""" + operators = [] + operands = [] + if not hasattr(self.contract, "functions"): + return + 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): + """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, Tuple[str]]] = ( + ("Core", CORE_KEYS), + ("Extended1", EXTENDED1_KEYS), + ("Extended2", EXTENDED2_KEYS), + ) + + def __post_init__(self): + # 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): + for contract in self.contracts: + self.contract_metrics[contract.name] = HalsteadContractMetrics(contract=contract) + + def add_all_contracts_metrics(self): + # 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): + # 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, 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, + title.lower(), + 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..7d39b2c14a --- /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): + 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): + 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, Tuple[str]]] = (("Core", CORE_KEYS),) + + def __post_init__(self): + self.update_abstractness() + self.update_coupling() + self.update_reporting_sections() + + def update_reporting_sections(self): + # 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, 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, + title.lower(), + SectionInfo(title=section_title, pretty_table=pretty_table, txt=txt), + ) + + def update_abstractness(self) -> float: + 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) -> Dict: + 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..e45edeaf13 100644 --- a/slither/utils/myprettytable.py +++ b/slither/utils/myprettytable.py @@ -22,3 +22,53 @@ def to_json(self) -> Dict: def __str__(self) -> str: return str(self.to_pretty_table()) + + +# **Dict to MyPrettyTable utility functions** + + +def make_pretty_table(headers: list, body: dict, totals: bool = False) -> MyPrettyTable: + """ + Converts a dict to a MyPrettyTable. Dict keys are the row headers. + Args: + data: dict of row headers with a dict of the values + column_header: str of column name for 1st column + 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"] + [sum([body[row][key] for row in body]) for key in headers[1:]]) + return table + + +def transpose(table): + """ + Converts a dict of dicts to a dict of dicts with the keys transposed + Args: + table: dict of dicts + Returns: + dict of dicts + + Example: + in: + { + "dep": {"loc": 0, "sloc": 0, "cloc": 0}, + "test": {"loc": 0, "sloc": 0, "cloc": 0}, + "src": {"loc": 0, "sloc": 0, "cloc": 0}, + } + out: + { + 'loc': {'dep': 0, 'test': 0, 'src': 0}, + 'sloc': {'dep': 0, 'test': 0, 'src': 0}, + 'cloc': {'dep': 0, 'test': 0, 'src': 0}, + } + """ + any_key = list(table.keys())[0] + return { + inner_key: {outer_key: table[outer_key][inner_key] for outer_key in table} + for inner_key in table[any_key] + } diff --git a/slither/utils/upgradeability.py b/slither/utils/upgradeability.py index 3bef3b9b61..5cc3dba08c 100644 --- a/slither/utils/upgradeability.py +++ b/slither/utils/upgradeability.py @@ -489,6 +489,63 @@ def encode_ir_for_compare(ir: Operation) -> str: return "" +# pylint: disable=too-many-branches +def encode_ir_for_halstead(ir: Operation) -> str: + # operations + if isinstance(ir, Assignment): + return "assignment" + if isinstance(ir, Index): + return "index" + if isinstance(ir, Member): + return "member" # .format(ntype(ir._type)) + if isinstance(ir, Length): + return "length" + if isinstance(ir, Binary): + return f"binary({str(ir.type)})" + 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 "delete" + 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 "transfer" + if isinstance(ir, Send): + return "send" + if isinstance(ir, Unpack): # TODO: improve + return "unpack" + if isinstance(ir, InitArray): # TODO: improve + return "init_array" + # default + raise NotImplementedError(f"encode_ir_for_halstead: {ir}") + + # pylint: disable=too-many-branches def encode_var_for_compare(var: Variable) -> str: