优质的代码审查不仅仅只是阅读代码加上某种难以达到的天才洞察力。它关乎于反复思考系统的每个部分,构建对代码的心智模型,然后利用这个过程发现愚蠢的错误、经典错误以及深层次的错误。
良好的代码审查需要从多个不同的角度来工作,并且不断在高层次和最微小的细节之间反复切换。
我已经专注于智能合约安全领域三年了,代码审查是我的工作的重要部分。以下是我从中获得最大收益的系统方法。
内部代码审查仍然是发现错误最有效的方法。理想情况下,你的单元测试和分支测试可以轻松完成确保代码“能够运行”的工作。
合约安全是一个渐进的过程,而不是一个布尔值。模糊测试、不变性测试和形式化验证都是增加代码正确性概率的绝佳层次,但提升安全梯度最大的单一步骤来自于良好的代码审查。
在智能合约中,正确性极为重要。
大多数其他行业并不会因为发现并利用一个漏洞,就能立即将数千万美元转移给某个人。
如果你正在驾驶飞机:是,世界充满随机性,是的,有很多飞机,是的,硬件故障是个问题。但这并不意味着有数以万计的人在巴黎、东欧、朝鲜、旧金山和加拿大荒野阅读你的源代码,他们连入你的飞行控制系统,积极改变天气、时钟、机场布局,并向你的飞机发射辐射,以便将你的系统置于代码错误的特定状态。智能合约存在于一个固有的敌对环境中。
不仅仅是有恶意的人类 - 还有所有那些潜伏在暗黑森林中的自动化系统,它们不断地探测代码,并准备在发现免费金钱的迹象时立刻行动。
代码的工作不仅仅是足够的。它在随机性面前的运行也不够。它必须面对寻找利用它的方式改变周遭世界的敌对敌人而工作。在所有情况下,代码必须是完全正确的。完美的代码对人类来说很难一次就做到。
我们进行代码审查的第二个原因是希望保持代码的简单性。
从长远来看,简单性是保持bug外门并保持开发速度的关键。系统中留下的每一点复杂性都是对我们未来软件开发的每一刻的时间税,复杂性增加了我们未来构建一切的风险。
简单性并非不费吹灰之力就能实现。它不是简单的。你不能只是挥动魔杖就实现简单性。它需要时间、努力和一些创造力。简单性是一个多维问题。有时候,你不得不在不同简单性方法间做出权衡。
因为简单是如此困难,我们需要团队中多个人对代码提供意见、反馈和完善。简单往往是协作过程的结果。
如果这是一家Web2公司,在不改变行为的情况下对代码的最微小之处吹毛求疵,可能会被视为反社交行为。但在我们的Solidity代码中,我们希望追求尽可能多的完美。
我们进行代码审查的第三个原因是,我们希望在团队间共享知识。
我们有四名智能合约开发人员。当编写代码的人进行深入审查,然后另外两个人也进行深入审查时,我们团队中有四分之三的人现在对这些新代码有了深刻的理解。当团队深刻理解代码库时,无论是团队动态还是编写与系统其他部分协同工作的代码能力都会有很大的不同。
这些代码审查还跨团队共享了编码技巧和安全问题。学习是双向的。我可以从我在代码审查中阅读的内容学习,也可以从人们在对我的代码进行审查时发现的问题中学习。
例如,上周我在审查一些代码时,看到开发者处理从继承层次结构的高层向下传递到基础代码的信息需求的方式。那周晚些时候,当我考虑处理来自采用相同基础代码的策略的不同类型的Curve池时,我运用了我所学到的东西。
代码审查不是一次性阅读代码,评论你看到的内容,然后就批准了。
让我们退一步,看看我们试图做什么。我们试图找出所有的bug。我喜欢把bug分为三类:
核心问题是,你如何找到深层次、微妙、棘手的bug。这通常需要在你的脑海中构建一个比编写系统的人还要清晰的心智模型。你怎样做到这一点,尤其是从零开始?
解决方案是多次循环通过代码。多次、多次、再多次地过滤代码,每次寻找不同的问题,并且每次都在你的脑海中构建一个更清晰、更丰富的代码心智模型。真正难以发现的bug往往隐藏在心智表征中。
所以目标是:
我要做的第一件事是进行初次通读。这是为了感受代码的整体结构,以便在后续的多次查阅中能够很好地进行导航。
现在,我喜欢使用纸质进行审查。所以我会打印代码,通常在第一次阅读时会去掉代码注释。
然后我会在纸上写下任何问题,任何看起来不好或难看的地方,以及我认为代码可能会破坏的方式。我对这些事情的判断常常是错误的 - 我对事物破碎的可能性有点乐观。
完成初次阅读后,我会回顾我的评论,并验证它们是否真的是问题。这时,我会将真正的问题写入PR评论。在这一点上,进行一些详细工作是构建我对系统了解的又一步。
我的过程的其余部分是基于一份考虑/检查事项的清单文件。伟大之处在于,这迫使你从不同的角度思考系统。而当只是阅读代码时,很容易忘记寻找那些不存在的东西。清单有助于发现这些缺失问题。
我们的清单是针对我们自己的代码库和我们想要的代码风格特定的。
当你使用清单进行审查时,你对系统的了解会建立起来,愚蠢和经典的bug会被发现。
我们自己清单中的一些东西实际上并不是代码通过审查必须为真的事项。它们是如果不为真,则我们需要非常非常仔细地检查并警惕危险的事项。例如,我们有一个清单项是“不使用原始以太坊”,我们几乎所有的合约都不使用。但如果我们必须使用原始以太坊,那么我们知道我们需要特别注意可能的危险,并且真的、真的思考可能出错的地方。
清单是发现你代码中常见bug的最佳方法。
接下来的方法是思考系统中的不变量。
不变量是系统必须始终为真的事情。这是一种非常有用的思考系统的方式,然后检查它是否真的总是按我们期望的方式工作。
你可以将它们分解为“这段代码运行之前必须为真的事情”、“这段代码运行后必须为真的事情”和“不同状态变量之间关系的规则”。
所以一旦我写下这些不变量,我就会回过头来检查代码,并验证它们确实是成立的。
状态不变量特别有用 - 系统的某些状态必须总是与其他某些状态有一定的关系才算是好的。例如,假设如果你把每个账户的余额加起来,应该等于系统中的总余额。构建状态不变量的一种好方法是,遍历合约中的非配置变量列表,并对每一个变量,思考它应该如何与其他每一个非配置变量相关。
基于不变量的思考可能是代码审查和发现bug中最被低估的秘密武器。
下一步是考虑攻击合约。
从攻击者的角度思考你的代码是一种强大的心智模式转换 - 作为开发者,这与思考代码将如何工作不同,而是思考你可以置于什么情况下来打破它。
与其只是“考虑攻击合约”,不如说我发现的最佳方式是不看代码就开始写出攻击想法。我只是随意写出可能出错的事情以及它们可能的最坏情况影响。
然后,在我写下一堆攻击后,我写出为什么每一个都无法完成,同样,一次不检查代码。最后一步,我会在代码中验证,我以为会阻止这些攻击的想法是否正确。从“攻击”到“防御”到“攻击”的思维转换很有用,在这一步中,就像所有其他步骤一样,我在构建我对代码的心智模型。
部署代码、配置、所需监控和治理操作与代码本身一样,是最终系统的一部分。检查这里的所有内容是否正确,包括手动验证所有地址是否正确。
最后,进行几分钟的理智分叉测试是非常有价值的。实际操作系统会发现很多问题。代码常常是为了通过测试而编写的,但在使用与测试不同的数字或操作顺序时,可能会出错。这也确保了部署和治理操作的结果是一个运行良好的系统。
我通常会记录我在这里做的分叉测试,稍作整理,然后保存起来,以便在代码实际部署后再次使用。
当第一版代码编写完成时,我喜欢进行一次非正式的初步审查,这与整个过程无关。这让我们在构建测试套件并真正增加系统质量之前,就能进行任何设计纠正。
因此,我们的真正内部审查是在代码编写完成后,以及这组代码的所有者对代码和测试感到满意之后进行的。首先,所有者会对自己的代码进行审查,然后标记其他两名团队成员进行他们的审查。
代码所有者自己应始终进行首次审查。他们是目前比全宇宙任何人都更了解这段代码的人,所以通过强制一些新视角,他们有很好的机会发现bug。其次,这让所有者可以立即得到关于他们审查工作质量的反馈,因为如果所有者没有发现bug,希望其他两名审查者会发现。
我们希望在将代码发送给审计师之前,完成我们完整的内部审查过程。这样,外部审计就是对您内部过程的一次检查。如果审计员发现一个重要的bug,那么你需要回头去弄清楚如何改变你的内部审查过程,以便这种类型的bug不再漏网。
找到最难的bug的关键是从许多不同的角度和不同的层次来审视系统。并且使用清单来查找易于发现的bug!