diff --git a/slither/printers/all_printers.py b/slither/printers/all_printers.py index 6dc8dddbd..4e66351f8 100644 --- a/slither/printers/all_printers.py +++ b/slither/printers/all_printers.py @@ -8,6 +8,7 @@ from .functions.authorization import PrinterWrittenVariablesAndAuthorization from .summary.slithir import PrinterSlithIR from .summary.slithir_ssa import PrinterSlithIRSSA from .summary.human_summary import PrinterHumanSummary +from .summary.ck import CKMetrics from .functions.cfg import CFG from .summary.function_ids import FunctionIds from .summary.variable_order import VariableOrder diff --git a/slither/printers/summary/ck.py b/slither/printers/summary/ck.py new file mode 100644 index 000000000..d896015b2 --- /dev/null +++ b/slither/printers/summary/ck.py @@ -0,0 +1,266 @@ +""" + + # TODO: Add in other CK metrics (NOC, DIT) + # TODO: Don't display all the general function metrics, but add those to complexity-dashboard + 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. + + 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. + - Coupling Between Object Classes (CBO) is a metric that measures the number of classes a class is coupled to. + +""" +from typing import Tuple +from slither.printers.abstract_printer import AbstractPrinter +from slither.utils.myprettytable import make_pretty_table +from slither.slithir.operations.high_level_call import HighLevelCall +from slither.utils.colors import bold + + +def compute_dit(contract, depth=0): + """ + Recursively compute the depth of inheritance tree (DIT) of a contract + Args: + contract: Contract - the contract to compute the DIT for + depth: int - the depth of the contract in the inheritance tree + Returns: + the depth of the contract in the inheritance tree + """ + 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 + + +# pylint: disable=too-many-locals +def compute_metrics(contracts): + """ + Compute CK metrics of a contract + Args: + contracts(list): list of contracts + Returns: + a tuple of (metrics1, metrics2, metrics3, metrics4, metrics5) + # Visbility + metrics1["contract name"] = { + "State variables":int, + "Constants":int, + "Immutables":int, + } + metrics2["contract name"] = { + "Public": int, + "External":int, + "Internal":int, + "Private":int, + } + # Mutability + metrics3["contract name"] = { + "Mutating":int, + "View":int, + "Pure":int, + } + # External facing, mutating: total / no auth / no modifiers + metrics4["contract name"] = { + "External mutating":int, + "No auth or onlyOwner":int, + "No modifiers":int, + } + metrics5["contract name"] = { + "Ext calls":int, + "Response For a Class":int, + "NOC":int, + "DIT":int, + } + + RFC is counted as follows: + +1 for each public or external fn + +1 for each public getter + +1 for each UNIQUE external call + + """ + metrics1 = {} + metrics2 = {} + metrics3 = {} + metrics4 = {} + metrics5 = {} + dependents = { + inherited.name: { + contract.name for contract in contracts if inherited.name in contract.inheritance + } + for inherited in contracts + } + + for c in contracts: + (state_variables, constants, immutables, public_getters) = count_variables(c) + rfc = public_getters # add 1 for each public getter + metrics1[c.name] = { + "State variables": state_variables, + "Constants": constants, + "Immutables": immutables, + } + metrics2[c.name] = { + "Public": 0, + "External": 0, + "Internal": 0, + "Private": 0, + } + metrics3[c.name] = { + "Mutating": 0, + "View": 0, + "Pure": 0, + } + metrics4[c.name] = { + "External mutating": 0, + "No auth or onlyOwner": 0, + "No modifiers": 0, + } + metrics5[c.name] = { + "Ext calls": 0, + "RFC": 0, + "NOC": len(dependents[c.name]), + "DIT": compute_dit(c), + } + for func in c.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 no_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 h in high_level_calls: + if hasattr(h.destination, "name"): + external_calls.append(f"{h.function_name}{h.destination.name}") + else: + external_calls.append(f"{h.function_name}{h.destination.type.type.name}") + + rfc += len(set(external_calls)) + + metrics2[c.name]["Public"] += 1 if public else 0 + metrics2[c.name]["External"] += 1 if external else 0 + metrics2[c.name]["Internal"] += 1 if internal else 0 + metrics2[c.name]["Private"] += 1 if private else 0 + + metrics3[c.name]["Mutating"] += 1 if mutating else 0 + metrics3[c.name]["View"] += 1 if view else 0 + metrics3[c.name]["Pure"] += 1 if pure else 0 + + metrics4[c.name]["External mutating"] += 1 if external_public_mutating else 0 + metrics4[c.name]["No auth or onlyOwner"] += 1 if external_no_auth else 0 + metrics4[c.name]["No modifiers"] += 1 if external_no_modifiers else 0 + + metrics5[c.name]["Ext calls"] += len(external_calls) + metrics5[c.name]["RFC"] = rfc + + return metrics1, metrics2, metrics3, metrics4, metrics5 + + +def count_variables(contract) -> Tuple[int, int, int, int]: + """Count the number of variables in a contract + Args: + contract(core.declarations.contract.Contract): contract to count variables + Returns: + Tuple of (state_variable_count, constant_count, immutable_count, public_getter) + """ + state_variable_count = 0 + constant_count = 0 + immutable_count = 0 + public_getter = 0 + for var in contract.variables: + if var.is_constant: + constant_count += 1 + elif var.is_immutable: + immutable_count += 1 + else: + state_variable_count += 1 + if var.visibility == "Public": + public_getter += 1 + return (state_variable_count, constant_count, immutable_count, public_getter) + + +def no_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 + """ + for modifier in func.modifiers: + if "auth" in modifier.name or "only_owner" in modifier.name: + return False + return True + + +class CKMetrics(AbstractPrinter): + ARGUMENT = "ck" + HELP = "Computes the CK complexity metrics for each contract" + + WIKI = "https://github.com/trailofbits/slither/wiki/Printer-documentation#ck" + + def output(self, _filename): + if len(self.contracts) == 0: + return self.generate_output("No contract found") + metrics1, metrics2, metrics3, metrics4, metrics5 = compute_metrics(self.contracts) + txt = bold("\nCK complexity metrics\n") + # metrics2: variable counts + txt += bold("\nVariables\n") + keys = list(metrics1[self.contracts[0].name].keys()) + table0 = make_pretty_table(["Contract", *keys], metrics1, True) + txt += str(table0) + "\n" + + # metrics3: function visibility + txt += bold("\nFunction visibility\n") + keys = list(metrics2[self.contracts[0].name].keys()) + table1 = make_pretty_table(["Contract", *keys], metrics2, True) + txt += str(table1) + "\n" + + # metrics4: function mutability counts + txt += bold("\nFunction mutatability\n") + keys = list(metrics3[self.contracts[0].name].keys()) + table2 = make_pretty_table(["Contract", *keys], metrics3, True) + txt += str(table2) + "\n" + + # metrics5: external facing mutating functions + txt += bold("\nExternal/Public functions with modifiers\n") + keys = list(metrics4[self.contracts[0].name].keys()) + table3 = make_pretty_table(["Contract", *keys], metrics4, True) + txt += str(table3) + "\n" + + # metrics5: ext calls and rfc + txt += bold("\nExt calls and RFC\n") + keys = list(metrics5[self.contracts[0].name].keys()) + table4 = make_pretty_table(["Contract", *keys], metrics5, False) + txt += str(table4) + "\n" + + res = self.generate_output(txt) + res.add_pretty_table(table0, "CK complexity core metrics 1/5") + res.add_pretty_table(table1, "CK complexity core metrics 2/5") + res.add_pretty_table(table2, "CK complexity core metrics 3/5") + res.add_pretty_table(table3, "CK complexity core metrics 4/5") + res.add_pretty_table(table4, "CK complexity core metrics 5/5") + self.info(txt) + + return res diff --git a/slither/utils/myprettytable.py b/slither/utils/myprettytable.py index af10a6ff2..379d6699d 100644 --- a/slither/utils/myprettytable.py +++ b/slither/utils/myprettytable.py @@ -4,9 +4,17 @@ from prettytable import PrettyTable 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 @@ class MyPrettyTable: table = PrettyTable(self._field_names) 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,70 @@ class MyPrettyTable: 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, 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 + + +def make_pretty_table_simple( + data: dict, first_column_header, second_column_header="" +) -> 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([first_column_header, second_column_header]) + for k, v in data.items(): + table.add_row([k] + [v]) + return table + + +# takes a dict of dicts and returns a dict of dicts with the keys transposed +# 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}, +# } +def transpose(table): + 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] + }