Repository: crytic/slither

Issue:
slither-flat: Add --remove-assert
`--remove-assert` will remove all the Solidity `assert` calls.

The goal is to allow developers to use `assert` to prove assertions with third-party tools (ex: Echidna), while not adding overhead with the production code

It is to be used only if the developer correctly used `assert()`, otherwise it will create bugs. Enabling the flag must produce a warning.


Discussion:
Since this directly relates to compilation, any thoughts on building this into crytic-compile instead?
The flattening was implemented in Slither for multiple reasons, including:
- Parsing the solc AST is a complex task. Slither supports already the parsing, including for both legacy and compact format. It supports solc from 0.4 to the latest and all the minor changes that were made in the AST format across time
- Slither has more information and allows for more complex patchings. For example, when removing the `assert`, we could emit a warning if the content of the assert triggers a side effect, like changing a state variable. This is straightforward to do thanks to SlithIR
- `crytic-compile` only goal should be to be a proxy between the different compilation platform and code analyzers. It should not have anything smart in it, otherwise, I think it would be a break in the abstraction layer
Thanks @montyly, that rationale all makes sense to me.
Unfortunately, this isn't as straight forward as it first seemed. After "patches" are applied in slither/tools/flattening/flattening.py's `_get_source_code` method, Slither's source mappings may become inaccurate.

To illustrate, the following version of `_get_source_code` works only if `_external_to_public` is false. When external functions are made public, the chopping up of `content` is inaccurate and results in mangled source code:

```Python
from slither.slithir.operations import NewContract, TypeConversion, SolidityCall
[...]

    def _get_source_code(self, contract):
        src_mapping = contract.source_mapping
        content = self._slither.source_code[src_mapping['filename_absolute']]
        content_offset = 0
        start = src_mapping['start']
        end = src_mapping['start'] + src_mapping['length']

        # interface must use external
        if self._external_to_public and contract.contract_kind != "interface":
            # to_patch is a list of (index, bool). The bool indicates
            # if the index is for external -> public (true)
            # or a calldata -> memory (false)
            to_patch = []
            for f in contract.functions_declared:
                # fallback must be external
                if f.is_fallback or f.is_constructor_variables:
                    continue
                if f.visibility == 'external':
                    attributes_start = (f.parameters_src.source_mapping['start'] +
                                        f.parameters_src.source_mapping['length'])
                    attributes_end = f.returns_src.source_mapping['start']
                    attributes = content[attributes_start:attributes_end]
                    regex = re.search(r'((\sexternal)\s+)|(\sexternal)$|(\)external)$', attributes)
                    if regex:
                        to_patch.append((attributes_start + regex.span()[0] + 1, True))
                    else:
                        raise SlitherException(f'External keyword not found {f.name} {attributes}')

                    for var in f.parameters:
                        if var.location == "calldata":
                            calldata_start = var.source_mapping['start']
                            calldata_end = calldata_start + var.source_mapping['length']
                            calldata_idx = content[calldata_start:calldata_end].find(' calldata ')
                            to_patch.append((calldata_start + calldata_idx + 1, False))

            to_patch.sort(key=lambda x:x[0], reverse=True)

            content = content[start:end]
            for (index, is_external) in to_patch:
                index = index - start
                if is_external:
                    content = content[:index] + 'public' + content[index + len('external'):]
                else:
                    content = content[:index] + 'memory' + content[index + len('calldata'):]
        else:
            content = content[start:end]

        # asserts should be removed (when used for real, `_remove_assert` should be checked as per option flag)
        if contract.contract_kind != "interface":
            for func in contract.all_functions_called:
                for ir in func.all_slithir_operations():
                    if isinstance(ir, SolidityCall) and ir.function.name == 'assert(bool)':
                        assert_src_mapping = ir.node.source_mapping
                        assert_start = assert_src_mapping['start'] - start - content_offset
                        assert_len = assert_src_mapping['length'] + 1
                        assert_end = assert_start + assert_len
                        content = content[:assert_start] + content[assert_end:]
                        content_offset += assert_len

        self._source_codes[contract] = content
```

Long term, I think it may be better to rework how `_get_source_code` makes modifications. As is, the only way I can think to accomplish this is to do one of the following:

* Parse the modified source again to update the mappings, which seems silly
* Keep track of all of the modifications and their resulting changes, which is complex and error-prone (as demonstrated above)
* Avoid source mappings all together and just use regex (yuck)

@montyly Any thoughts? Maybe reworking this first is a good option.
I think if we order the patches from the latest offset to the first one we should be fine. It's what `to_patch.sort(key=lambda x:x[0], reverse=True)` does.

`to_patch` could contain a namedtuple `(index, type)`. `type` could be 
- `public_to_external`
- `calldata_to_memory`
- `line_removal`

Just in case, we could check that there is no overlap between the `line_removal` and the others



Resolving patch:
diff --git a/slither/tools/flattening/__main__.py b/slither/tools/flattening/__main__.py
--- a/slither/tools/flattening/__main__.py
+++ b/slither/tools/flattening/__main__.py
@@ -24,6 +24,10 @@ def parse_args():
                         help='Convert external to public.',
                         action='store_true')
 
+    parser.add_argument('--remove-assert',
+                        help='Remove call to assert().',
+                        action='store_true')
+
     parser.add_argument('--contract',
                         help='Flatten a specific contract (default: all most derived contracts).',
                         default=None)
@@ -38,7 +42,7 @@ def main():
     args = parse_args()
 
     slither = Slither(args.filename, **vars(args))
-    flat = Flattening(slither, external_to_public=args.convert_external)
+    flat = Flattening(slither, external_to_public=args.convert_external, remove_assert=args.remove_assert)
 
     flat.export(target=args.contract)
 
diff --git a/slither/tools/flattening/flattening.py b/slither/tools/flattening/flattening.py
--- a/slither/tools/flattening/flattening.py
+++ b/slither/tools/flattening/flattening.py
@@ -1,23 +1,33 @@
 from pathlib import Path
 import re
 import logging
+from collections import namedtuple
+
+from slither.core.declarations import SolidityFunction
 from slither.exceptions import SlitherException
 from slither.core.solidity_types.user_defined_type import UserDefinedType
 from slither.core.declarations.structure import Structure
 from slither.core.declarations.enum import Enum
 from slither.core.declarations.contract import Contract
-from slither.slithir.operations import NewContract, TypeConversion
+from slither.slithir.operations import NewContract, TypeConversion, SolidityCall
 
 logger = logging.getLogger("Slither-flattening")
 
-class Flattening:
+# index: where to start
+# patch_type:
+#   - public_to_external: public to external (external-to-public)
+#   - calldata_to_memory: calldata to memory (external-to-public)
+#   - line_removal: remove the line (remove-assert)
+Patch = namedtuple('PatchExternal', ['index', 'patch_type'])
 
+class Flattening:
     DEFAULT_EXPORT_PATH = Path('crytic-export/flattening')
 
-    def __init__(self, slither, external_to_public=False):
+    def __init__(self, slither, external_to_public=False, remove_assert=False):
         self._source_codes = {}
         self._slither = slither
         self._external_to_public = external_to_public
+        self._remove_assert = remove_assert
         self._use_abi_encoder_v2 = False
 
         self._check_abi_encoder_v2()
@@ -36,13 +46,11 @@ def _get_source_code(self, contract):
         content = self._slither.source_code[src_mapping['filename_absolute']]
         start = src_mapping['start']
         end = src_mapping['start'] + src_mapping['length']
+        first_line = src_mapping['lines'][0]
 
+        to_patch = []
         # interface must use external
         if self._external_to_public and contract.contract_kind != "interface":
-            # to_patch is a list of (index, bool). The bool indicates
-            # if the index is for external -> public (true)
-            # or a calldata -> memory (false)
-            to_patch = []
             for f in contract.functions_declared:
                 # fallback must be external
                 if f.is_fallback or f.is_constructor_variables:
@@ -54,7 +62,7 @@ def _get_source_code(self, contract):
                     attributes = content[attributes_start:attributes_end]
                     regex = re.search(r'((\sexternal)\s+)|(\sexternal)$|(\)external)$', attributes)
                     if regex:
-                        to_patch.append((attributes_start + regex.span()[0] + 1, True))
+                        to_patch.append(Patch(attributes_start + regex.span()[0] + 1, 'public_to_external'))
                     else:
                         raise SlitherException(f'External keyword not found {f.name} {attributes}')
 
@@ -63,23 +71,33 @@ def _get_source_code(self, contract):
                             calldata_start = var.source_mapping['start']
                             calldata_end = calldata_start + var.source_mapping['length']
                             calldata_idx = content[calldata_start:calldata_end].find(' calldata ')
-                            to_patch.append((calldata_start + calldata_idx + 1, False))
-
-            to_patch.sort(key=lambda x:x[0], reverse=True)
-
-            content = content[start:end]
-            for (index, is_external) in to_patch:
-                index = index - start
-                if is_external:
-                    content = content[:index] + 'public' + content[index + len('external'):]
-                else:
-                    content = content[:index] + 'memory' + content[index + len('calldata'):]
-        else:
-            content = content[start:end]
+                            to_patch.append(Patch(calldata_start + calldata_idx + 1, 'calldata_to_memory'))
+
+        if self._remove_assert:
+            for function in contract.functions_and_modifiers_declared:
+                for node in function.nodes:
+                    for ir in node.irs:
+                        if isinstance(ir, SolidityCall) and ir.function == SolidityFunction('assert(bool)'):
+                            to_patch.append(Patch(node.source_mapping['start'], 'line_removal'))
+                            logger.info(f'Code commented: {node.expression} ({node.source_mapping_str})')
+
+        to_patch.sort(key=lambda x: x.index, reverse=True)
+
+        content = content[start:end]
+        for patch in to_patch:
+            patch_type = patch.patch_type
+            index = patch.index
+            index = index - start
+            if patch_type == 'public_to_external':
+                content = content[:index] + 'public' + content[index + len('external'):]
+            elif patch_type == 'calldata_to_memory':
+                content = content[:index] + 'memory' + content[index + len('calldata'):]
+            else:
+                assert patch_type == 'line_removal'
+                content = content[:index] + ' // ' + content[index:]
 
         self._source_codes[contract] = content
 
-
     def _export_from_type(self, t, contract, exported, list_contract):
         if isinstance(t, UserDefinedType):
             if isinstance(t.type, (Enum, Structure)):
@@ -152,4 +170,3 @@ def export(self, target=None):
             else:
                 ret = []
                 self._export(contract, ret)
-


Test patch:
diff --git a/tests/flat/file1.sol b/tests/flat/file1.sol
new file mode 100644
--- /dev/null
+++ b/tests/flat/file1.sol
@@ -0,0 +1,7 @@
+contract A{
+
+    function test(bytes calldata b) external{
+
+    }
+
+}
diff --git a/tests/flat/file2.sol b/tests/flat/file2.sol
new file mode 100644
--- /dev/null
+++ b/tests/flat/file2.sol
@@ -0,0 +1,9 @@
+import "file1.sol";
+
+contract B is A{
+
+    function test() public{
+        assert(true);
+    }
+
+}