随着DevOps模式的落地,快字当头。研效提速也意味着出现安全漏洞的数量和概率随之上涨。过去安全风险的管控主要依赖于DAST类技术,即:采用黑盒测试技术,对待检查目标发起含检查用例的请求。DevOps给这一模式带来了挑战,安全检查速度慢、周期长,容易给业务带来干扰,一定程度上有阻碍业务持续交付的风险。另据CapersJones的研究结论:解决缺陷的成本,在研发流程中越靠后越高。
因此,安全机制的左移在开展DevSecOps建设时就变得更为重要了。左是指软件开发生命周期的早期,也就是设计、编码阶段。发生安全漏洞研发的早期阶段存在哪些问题?我们认为可以总结为三类:
开发人员不了解安全编写代码的知识
开发人员有一定安全意识,但因为赶进度或疏忽遗漏编写安全机制,或是错误地实现安全校验逻辑
代码编写是否安全仅凭开发人员自觉,缺乏检查和卡点机制
首先是开发人员缺乏安全编写代码知识的问题。在开展日常安全运营过程中,常常会遇到如下与之关联的挑战:
如何帮助开发人员建立起安全编码意识,实现代码写出来就没有漏洞?
当检出安全漏洞时,如何给予开发人员详细、可操作的改进指引?
安全编码意识和漏洞修复指引,两份材料是否合二为一?
综合上述背景,去年梳理了一份统一代码安全指南。从开发人员视角讲述安全注意事项,并配套了丰富的代码示例。覆盖常见的8门编程语言,包括:C#、C,C++、Go、JavaScript、Java、Objective-C、PHP、Python。
代码安全指南的内容呈树状结构展开,共分5层,如下:
每种语言面临安全的风险种类不同,需要分别开展详述。如:go和javascript对比,go就不存在原型链污染的问题。同时,由于公司内的代码风格规范亦分语言展开,安全规范采取相同的分语言方式能保持整体的连贯性。
这里的端是指不同的终端,如:Web、安卓客户端、iOS客户端、PC客户端。实践过程中,将内容按端区分的原因有:
1、同一门编程语言,用在不同的终端应用开发,其面临的风险类型和数量有着天壤之别。例如:JavaScript应用于前端页面开发时,面临的主要风险是DOMXSS;但JavaScript亦可依托Node.js进行Web后端接口开发,如果编码不当,则存在命令注入、SQL注入等风险。
2、大型互联网公司内,项目开发采取“流水线”化作业,分工往往精细明确,将不同端的场景作为主干目录,更便于开发人员检索、快速了解编码安全知识。
通过复盘历史漏洞,安全风险可按成因粗略归为两类:
1、代码漏洞,是指代码编写时,因不安全的API使用和逻辑编写产生的安全风险。
对于开发人员来说,各类API是程序代码的基础组成部分。对安全团队来说,API也就是编写安全检查策略要收集整理的sink点。
1、为什么要在代码安全指引中,枚举API/sink点?
对开发人员来说,API是实现业务逻辑时,高频接触对象。通常安全漏洞往往可归因为API的错误使用。对安全工程师来说,sink点是编写安全策略、组件是非常重要的一部分,直接决定了SAST系统的扫描能力。
业界也不乏类似的探讨,如Google在ICSE‘21的论文《IfIt’sNotSecure,ItShouldNotCompile:PreventingDOM-BasedXSSinLarge-ScaleWebDevelopmentwithAPIHardening》中,阐释了Google加固前端组件,使其Web页面能天然“免疫”DOMXSS漏洞的实践过程。
在上述实践过程中,最为重要的一步是:XSSsink点的提取,也就是易被误用产生安全漏洞的API。原文“WewouldliketomentionthatmanyotherXSScounter-measures,suchasdata-flowanalyses,alsoneedtoidentifyareasonablycomprehensivesetofXSSsinkstobeeffective.Therefore,enumeratingthesinksaresomewhatorthogonaltoAPIhardening”,指出sink点的提取是开展API加固极为重要的一步。
与上述实践类似,我们认为在代码安全指南中,清楚地列出容易被误用的API,对日常开发和安全策略建设均有帮助。
2、如何确保所枚举API的完善性?
编写代码安全指引时,我们亦遇到了类似的挑战,采取的解决思路是:
1)整合各语言、组件、框架文档中的最佳安全实践。在编写安全指南初期,我们重点参考了CWE、OWASP等材料。
2)结合内、外部已知的漏洞案例的复盘、抽象,做校对补充。
官方文档给出的示例如下:
.append()、.prepend()、.wrap()、.replaceWith()、.wrapAll()、.wrapInner()、.after()、.before()等API存在同样的因误用产生安全风险的可能性。最终,针对上述场景下的指引要求描述如下:
除列出所有风险API,让开发人员在使用时,脑海里能对快速关联到指引要求外,在编写指引时,我们约定还采取了两种额外的表述注意点,来保证指引内容的可操作性:
a.限定产生安全风险的开发场景,更明确;
b.明确给出安全漏洞的规避方式,或是提供可选的“开箱即用”式安全方案。最终,效果如下:
通过上述的思路,撰写代码安全指南并配套在线学习课程和考试,我们试图解决开发人员不了解如何安全地开发的问题。当前,该方向仍存在两项挑战:
2、代码安全指南只是安全左移建设的第一步,还需解决的挑战有:
代码编写是否安全全凭开发人员自觉,缺乏提示、检查和卡点机制
“代码编写是否安全全凭开发人员自觉,缺乏提示、检查和卡点机制”的问题,解决方式是:静态代码安全检查解决。
要对代码安全规范扫描,我们首先需要考虑的就是将源码表示成一种方便检查的形式,然后选择合适的方式进行扫描。常规的源码表示方式以AST(抽象语法树)和IR(中间表达)为主:以C++为例,clang可以将C/C++源码转换为clangAST和LLVMIR。
一般而言,IR是从AST经过层层转换而来,所以会比AST拥有更多信息。但相对的,每种语言一般会有自己不同的IR,处理起来会相对比较困难。而AST的形式则简单直观,操作起来也比较容易。
根据选择的源码表示不同,有不同的方式做代码检查:
方式一、AST匹配
第一种方式是在AST上做检查,既然源码被表示成了树的形式,我们就可以遍历AST,使用一些pattern去检查代码规范中的问题。如:Go语言代码安全指南中的1.7.2条目(CSRF防护)
检查该配置的正确
//Goodstd::stringcmdline="ls";cmdline+=user_input;if(cmdline.find_first_not_of("1234567890.+-\*/e")==std::string::npos)system(cmdline.c_str());elsewarning(...);很显然这里如果要判断它违反规范需要满足两个条件:
cmdline含有用户输入
如果说第二个条件还能通过模式匹配的方式检查的话,第一个条件就很难通过模式匹配的方式检查了,所以我们需要另一种检查方式。
方式二、污点分析
污点分析是更为常用的检查安全漏洞的方法。通过标记外部输入点、基于程序数据流观察数据是否能到达危险函数(如:SQL查询,命令执行等函数)来检查安全漏洞。
要实现污点分析,有三个核心的关键点:
source点:表示外部输入,如web输入,文件输入等。
sink点:表示危险的函数调用或者变量赋值,如system等函数
sanitizer:表示过滤点,经过过滤点的数据将被取消标记。
以下面这段代码为例,污点分析从user_input一路进行数据流追踪(一般因为source点的数量远远大于sink点的数量,会反过来从sink点开始回溯以提升效率),中间的检查就是sanitizer,这时候会取消标记,因此该漏洞并不成立。
使用污点分析,由于是通过数据流追踪的方式得到漏洞,我们能得到程序相对精确的漏洞触发流程,便于研发二次检查。同时因为依赖手动配置source点、sink点和sanitizer,会有部分漏报。而且研发往往会采用不同的过滤方式,不同的写法,导致sanitizer并不能很好的确定下来。在大型项目中,组件往往分布于不同仓库,加上许多自定义的通信框架,完整的程序数据流会很难得到。
综上我们可以总结出两种不同的扫描方式各自的优缺点:
在初步了解了扫描方式后,就要对社区工具做调研。针对单一语言的工具写起来就太多了(各种语言的lint),这里主要提两个工具:Semgrep和CodeQL。一是这两个工具支持语言都不止一个,二是在各自技术领域它们都比较有代表性。
Semgrep是一款基于Facebook开源SAST工具pfff中的sgrep组件开发的开源SAST工具,目前由安全公司r2c统一开发维护,提供强于grep工具的代码匹配检索能力。
其核心技术原理可以用下面这张图概述:
Semgrep支持两种类型的代码模式匹配。一种是基于AST的匹配,使用tree-sitterparse各种语言的源码,并将它们转换成genericAST(Semgrep内的一种通用AST格式),然后再使用规则匹配。举个例子,如果要检索代码中使用strcpy的代码行,可以编写如下ripgrep命令:
rg--quiet--statsstrcpy-tc
但类似如下的代码也会被匹配到,实际上它仅为注释,并没有真正调用strcpy:
45://string.hisnotguaranteedtoprovidestrcpyonC++Builder.
而运行如下semgrep命令,即可基于AST搜索,实现更精准的匹配。其中…是semgrep提供的代码模式匹配语法——省略号运算符(Ellipsisoperator),用于代表若干参数、语句或字符:
semgrep-e"strcpy(...)"--lang=c.
另一种模式是genericpatternmatching,使用一个通用文本parser(spacegrep),通过分词和特殊字符的识别来做代码模式匹配。相比基于AST的匹配,该模式能力较弱,但拓宽了工具对各类编程语言的覆盖能力。例如对YAML、ERB、Jinja等语言,就可以使用该模式进行检查。假设有一个配置文件如下,想要检索其中不正确的“allowed_origins”配置:
resource"aws_s3_bucket""b"{bucket="s3-website-test-open.hashicorp.com"acl="private"cors_rule{allowed_headers=["*"]allowed_methods=["PUT","POST"]allowed_origins=["*"]#<---Matcheshereexpose_headers=["ETag"]max_age_seconds=3000}}尽管还不支持解析该文件格式的AST,可以基于spacegrep提供的能力,编写如下规则对代码库进行检索:
但该种匹配模式下,有时无法区分出是真实的API调用,还是代码注释。两种模式对同一代码片段匹配效果,比对如下:
综上,Semgrep的特点如下:
安装便捷。通过包管理工具安装cli,即装即用。
规则上手、编写简单。采用yaml配置文件编写扫描规则,语法简单但表现能力相较于传统grep类工具更强大。
引擎本身和规则集均开源,且支持的规则集丰富,已经开源包含总计有1000+条规则。
扫描速度快。直接基于AST/文本匹配,工具效率会相对较高。经测试,扫描速度可达到2~10w行代码/秒。
支持代码自动修复替换。通过AutoFix语法可自动修复存在安全风险的代码。
易于集成,官方已给出了一系列和CI集成的配置,涉及14款CI平台,如Jenkins、Gitlab、CircleCI等。
CodeQL是Semmle公司推出的一款静态代码分析工具(以前叫SemmleQL),后被Github收购,并成立了SecurityLab支持Github的开源代码安全检查能力。
相对于Semgrep,CodeQL走的是深度分析的技术路线,核心技术原理是分析源码并将其转换成代码快照,然后通过它自己的QL查询语言做代码查询来达到扫描的目的。
如上图,先提取不同语言的源代码文件到代码快照,然后将查询规则(.ql文件)编译成CodeQL内部的查询形式,再对代码快照进行查询。
值得一提的是:两款工具都对策略规则写法做了统一,能较好地解决跨编程语言检查的需求。设想一下:如果用每种语言的lint编写检查规则,不同lint间的规则语法不一致,将会带来比较高的维护门槛。
通常,编写代码涉及三个环节—编码、代码托管、CI/CD,开发人员在各阶段对检查工具和结果的期望和要求是不同的。可以简单概括并比对如下:
两款代码检查工具可简单比对总结如下:
在编写代码的不同阶段,开发人员对检查工具和结果的预期和要求各不相同。在不同编码阶段采取的静态代码安全检查方案可概括如下:
1、本地编写阶段
在本阶段,对工具有一定要求:速度快,容错性要高。而且往往这个点代码还未写完,工具的检查错误可以快速得到修复。这时候AST模式匹配就会是一个很好的检查方式。在编码阶段,可以使用semgrep等类似lint、grep工具,封装为IDE插件。
实践过程中,还可以尝试引入一些更激进的策略,如:提示并要求修改不安全的SQL查询拼接逻辑。开发人员可根据提示快速判断,如果风险确实存在,即借助自动修复功能修改代码,进而在代码构建前收敛安全风险。
2、构建部署阶段
这个阶段代码已签入代码仓库,类似semgrep或CodeQL的方案可封装为流水线原子,提供给开发人员使用,并配套提供安全卡点(质量红线)机制。如果检查出安全漏洞,应给出详细的漏洞触发路径,并提示风险关联的代码安全指南条目来引导修复。
3、日常检查阶段,代码仓库检查
该阶段一般为纯旁路的检查,时效性可以适当放宽,可进行一系列复杂的分析。但需要注意的是,由于代码仓库是纯静态代码,如果工具对编译有要求(如Clang,CodeQL的部分语言),在该阶段检查应注意编译环境的适配。
值得一提的是,代码安全规范/指南也在编写策略的过程中扮演非常重要的角色。如果公司内有一份比较清晰、完善的规范,能详细地列出各类容易出错的API,编写检查规则会容易许多。
当有了一份代码安全指南和配套的静态检查机制后,开发人员仍需要自行在项目中引入或实现安全校验逻辑。这时候就会遇到“开发人员有一定安全意识,但因为赶进度或疏忽遗漏编写安全机制,或是错误地实现安全校验逻辑”的问题。
深挖背后的原因及“痛点”,我们认为可以归纳为如下三点:
全凭安全意识不靠谱。即便是安全工程师也会犯错,需要将安全指南的要求转化为开箱即用式的安全组件。
重检查,轻具可操作性、便捷的解决方案。检查工具聚焦于发现问题,但开发人员在解决问题的时候,如果没可操作的解决方案,修复工作往往难以推动。
研发效率与质量。不同业务重复在写安全校验逻辑,不仅浪费人力,还容易出错。
“越早越好”。应该在软件设计初期就考量安全、稳定性问题。否则,越往后代价越高,过程会很痛苦。(笔者注:本质上就是安全左移)
Google团队认为,安全能力嵌入框架大有裨益,如下:
规范化与一致性,复用最佳实践。某些功能容易出问题,通过框架封装,能减少由业务各自实现产生的风险
2、SecuritybyDesign。USNIX15’的议题《PreventingSecurityBugsthroughSoftwareDesign》中抛出了与众不同却巧妙的观点:写出漏洞不一定是开发者的问题,而可能是API设计得容易出错。以SQL注入为例,尽管大部分语言、组件和框架都提供了参数绑定(参数化查询)的功能,安全规范也有不断强调。但开发人员仍非常容易写出带SQL注入问题的代码。
为解决前述问题,提出了一种名为trustedsqlstring的机制,从接口设计层拒绝拼接SQL查询语句传入。以Go语言例,各类参数需要标注静态类型的机制。通过修改SQL查询组件导出操作函数的入参类型,可以从API设计机制上禁用查询拼接,仅允许通过占位符动态拼接参数值进入查询语句。
通过整理汇总,可以把现有的安全过滤方案分为三类:校验、过滤和组件。
从上述对比来看,组合采用校验,和带默认安全机制的功能组件(融入SecuritybyDesign设计思路)两个方案是最优解。
为此,我们为公司级RPC框架引入了一套Validation组件和检查、卡点机制,并取得了不错的效果。公司级RPC框架使用ProtocolBuffer作为IDL,根据设计理念,ProtocolBuffer原本就带有一定数据校验类似的特性。但经分析,其不足以支撑业务和安全的需求,例如:校验入参是否为空?传入的是否是字母数字组合,这些需求是PB原生的能力无法支持的。因此,需要引入第三方数据校验组件补充这部分能力,当前主要有两类方案:
方案1、在代码中引入validator,并编写校验规则
方案2、在IDL文件中编写校验规则并自动生成代码的protoc插件
通过分析对比,最终选择了方案2——开发人员在定义proto文件时即在拓展字段中定义好校验规则,过程如下:
同时,通过对历史漏洞的复盘分析,我们发现许多漏洞是string类型的字段未限制字符集产生的。如:id参数功能上仅允许数字传入,但未做校验,允许传入`)(‘“等字符,带入经拼接产生的SQL查询语句,最终产生了SQL注入漏洞。于是,我们在内部代码安全指南中添加了明确的要求:请求(Req)消息体中的String类型字段必须限定格式/字符范围+长度。
另一方面,我们还与基础设施开发团队合作,将上述组件嵌入了proto管理平台默认提供的文件模版中。开发人员几乎可以无额外成本,快速上手使用这套机制:
此外,我们还设计了静态安全检查和卡点机制,并引入上述平台,来保证数据校验逻辑的准确性和安全性。开发人员在保存proto文件时,能立即获得提示并及时修正存在的安全隐患。
综上,数据校验本身是一项业务需求,通过降低开发门槛、给出安全要求并配套自动化检查和卡点机制,在框架层提供组件让每个业务模块都能做好数据校验。基于前述思路建设的组件还有不少,包括:DB组件、网络资源请求组件等。这些组件不是自成一体的安全过滤函数库,而是与某项特定功能需求结合的特定组件。
结合对业界现有理念与实践的分析,我们基于代码安全指南在公司级框架上建设了一系列“默认安全”的组件,并设计了一系列检查工具嵌入在统一的研发基础设施中。
理想情况下,组件是和代码安全指南高度对应的。举个例子,开发人员在Go项目编写资源请求功能时,无需在每个项目中自行实现SSRF防御逻辑,只需引入组件即可快速实现业务功能,又确保安全。
由此带来的收益是:
更高的业务接受度,安全机制内置在功能组件中,使用几乎无感知,且会被当作一种最佳业务实践口口相传,逐步获得高覆盖度
代码安全检查更准确方便,安全过滤特征范围缩小,检查工具对
研发效能的提升,开发人员无需自行、重复实现安全校验/检查代码
当然,上述工作也给安全和基础组件研发团队提出了挑战:既要对应用安全风险有全面且深入的了解,在开发安全组件时,还要兼顾功能灵活性(业务需求)和安全性(安全需求)。
综上,借助代码安全指南、默认安全框架组件、嵌入基础设施的静态代码安全检查三项机制联动,我们试图在软件研发生命周期的最初阶段收敛漏洞。
在上述框架下,研发和安全人员日常开展工作的模式为:
研发人员
阅读学习代码安全指南、安全组件文档
业务基于统一框架、组件设计功能,聚焦于业务逻辑
使用自助安全检查工具,在DevOps各环节检查漏洞,漏洞修复指引会链接回代码安全规范,形成闭环。
安全人员
有新的漏洞进来->分析提炼漏洞模式(变体分析)->沉淀至代码安全规范
基于代码安全规范编写检查规则(浅层和深层)
推出对应的安全组件
在安全左移探索过程中仍有许多机制尚待优化,我们希望通过逐步开源上述解决方案,与业界一道丰富安全左移的理论和实践。
上述探索过程中,获得了基础设施研发团队(框架、持续集成平台)、业务侧研发安全团队的支持,在此献上诚挚的谢意。
[1]Kern,Christoph.“Preventingsecuritybugsthroughsoftwaredesign.”(2015).
[2]Wang,Pei,JulianBangert,andChristophKern.“IfIt’sNotSecure,ItShouldNotCompile:PreventingDOM-BasedXSSinLarge-ScaleWebDevelopmentwithAPIHardening.”2021IEEE/ACM43rdInternationalConferenceonSoftwareEngineering(ICSE).IEEE,2021.
[3]Adkins,Heather,etal.BuildingSecureandReliableSystems:BestPracticesforDesigning,Implementing,andMaintainingSystems.O’ReillyMedia,2020.