像打补丁一样做代码扫描:一次让审计快60%的硬核优化

· 2026-11-04 15:14 · 3 阅读

原创 Yak 2026-11-04 15:14 四川

90% 的代码其实不用重新分析:我们让代码审计学会了“偷懒”

yaklang 的增量编译(incremental compilation)核心是只编译变更文件、复用未变更部分的 IR,并保证审计结果与全量编译一致。本文按"背景与动机、底层实现、效率优化、IRify 落地细节、新旧逻辑对比"五个部分,描述 yaklang 增量编译从旧版多层模型到当前 Base/Diff 模型的实现与优化。
涉及代码主要位于 common/yak/ssaapi 与 common/yak/ssa/ssadb,可对照源码阅读。

一、增量编译的背景与动机

yaklang 的代码审计链路为:源码 → SSA IR(静态单赋值形式的中间表示)→ SyntaxFlow 规则查询。一次全量编译需要将项目中的全部源文件解析、构建 IR 并落库;对包含数万文件、捆绑大量依赖 jar 的中型 Java 工程而言,耗时在分钟级。

IRify 在处理大型项目时面临的主要性能瓶颈包括:

1.完整重新编译时间长:即使只修改少量文件,仍需全量构建;

2.资源占用高:大项目编译时内存与 CPU 占用处于高位;

3.迭代效率低:需要等待较长的构建周期。

在以下场景中,全量编译存在明显的重复开销:

•AI 审计(IRify)的迭代回路:AI 修改规则或用户修改文件后重新扫描,绝大部分代码与上一次扫描时完全相同;

•安全值班与 CI 的重复扫描:同一项目被反复扫描,每次变更通常只涉及少量文件;

•修复验证:漏洞修复后只需验证变更点,无需重审整个仓库。

增量编译的目标可以概括为:只编译变更文件,复用未变更部分的 IR,同时保证审计结果与全量编译一致。其中后一项是主要难点:SSA 的符号解析是跨文件的,修改 Utils.java 中的函数后,引用该函数的 Controller.java 虽未变更,其旧 IR 中对应部分已过时;文件删除、重命名也会在旧 IR 中留下悬空引用。

与全量编译相比,增量编译的差异可以归纳为:

特性维度增量编译模式全量编译模式
编译范围仅编译变更文件及直接依赖编译全部文件
构建时间与变更范围成正比(经验上小改动场景可缩短 60%–90%)完整构建时间
内存占用较低高峰期占用高
适用场景日常迭代、重复扫描、修复验证首次构建、清理构建
⟷ 左右滑动查看完整表格⟷ 左右滑动查看完整表格

yaklang 采用 overlay(叠层)方案:变更文件编译为独立的差量 Program,查询时将"全量基座 + 若干差量层"合成为虚拟视图。基座不被修改,差量层可以追加或丢弃。该模型同时满足正确性要求(未变更文件的 IR 保持原样)与效率要求(编译范围限于变更集)。

二、增量编译的底层实现

YAK

2.1 数据模型:IrProgram

增量编译的元数据记录在 ir_programs 表(common/yak/ssa/ssadb/program.go),其中四个字段构成层链:

字段含义
BaseProgramName差量层指向其基座 Program;为空表示该层是链的首层
FileHashMap文件路径 → 状态:-1 删除(基座存在、本次不存在)、0 修改、1 新增
IsOverlay参与增量序列的标记(基座同样置位)
OverlayLayers完整层链配方,如 ["base", "diff-1", "diff-2"],自底向上;链头行保存完整配方,基座层只记录自身名称
⟷ 左右滑动查看完整表格⟷ 左右滑动查看完整表格

编译入口按 compileMode() 分为三种模式(common/yak/ssaapi/ssa_compile_project.go):

•full:普通全量编译,不参与增量序列;

•incremental-base:增量序列的首次编译。实际执行一次全量编译,产物被标记为后续差量编译的基座(IsOverlay + 单层 OverlayLayers);

•incremental-diff:基于基座的差量编译,只编译变更文件。

YAK

2.2 差量计算:MD5 快照对账

差量计算由 calculateFileSystemDiff 完成,流程如下:

1.基座侧快照:遍历 base 文件系统,对每个源文件计算内容的 MD5 摘要,得到 路径 → 摘要 映射,不驻留文件字节;

2.新侧走查:遍历新文件系统并逐文件对账——路径不存在记 1(新增);摘要不同记 0(修改)并加入 diff FS;走查结束后快照中剩余路径记 -1(删除);

3.产物为只包含变更文件的 VirtualFS 与一份 FileHashMap,变更文件集即差量层的编译输入。

两个实现细节影响对账的正确性:

diff 域等于编译域。 对比范围限定为该语言实际会编译并持久化的文件(FilterFile ∪ FilterPreHandlerFile),两侧对称。若将资源文件、配置文件纳入 diff,会产生大量无意义的变更记录并触发不必要的重编译。

diff 两侧必须处于同一形态。 基座文件系统是编译时视图:jar 归档已展开、.class 已映射为 .java;新输入是裸文件系统。若直接以裸 FS 对账,磁盘上的 jar 会整体判定为新增,基座中的每个展开条目会判定为删除,增量编译退化为全量重编译。因此新侧先经过与全量编译相同的语言预处理(swapLanguageFs),保证两侧可比。

YAK

2.3 差量层编译:入口隔离与回滚范围

差量层编译由 CompileDiffProgramAndSaveToDB 执行,包含三个设计要点:

•入口隔离:差量编译的 config 只声明"这是增量序列的一层",不携带 BaseProgramName,经由独立编译入口执行,不会路由回 ParseProject 形成递归。基座关联与 FileHashMap 在编译完成后以后置赋值方式写入产物;

•只删不增:当本次变更全部为删除(不存在可编译文件)时,生成 delete-only program——不含 IR,仅记录 FileHashMap 中的 -1 条目,视图层据此移除对应文件;

•回滚范围受限:差量编译失败或 panic 时,回滚仅删除本次新写入的差量层 Program,基座与历史层不受影响。增量体系中的失败结果为"回退到上一次增量状态",不会破坏可用基座。

YAK

2.4 Overlay 视图:持有权模型与双源查询

编译产物落库后,查询侧由 ProgramOverLay(common/yak/ssaapi/programs_overlay.go)将基座与差量层合成虚拟 Program。其核心为两条不变量:

Plain Text

1  Diff[i].File = 第 i 层最终持有的路径(该层的新增 + 修改)
2  ExcludeFile  = 所有层持有路径的并集 ∪ 全部删除路径(基座查询需跳过)

每个路径的持有权由最新持有该路径的层独占:新层持有某路径时,先将其从旧层的持有集中移除(stripOwnedPath),保证任一路径同一时刻只有一个现行版本。查询按持有权做双源路由:

•Ref/Match:差量层按 include(只查自身持有的文件)自新向旧查询;基座按 exclude(跳过被持有与被删除的路径)查询;两部分结果合并。旧层中过期的定义由新层遮蔽;

•Relocate(跨层符号重定位):从基座或旧层取得的值,若其所属文件已被新层持有,则按"稳定名 + opcode"在持有层中定位对应的新值后返回。查询先走变量名索引的快速路径,未命中时按 opcode 扫描并以名字交集匹配——Java 方法名(Class_method_<hash> 形式)通常依赖该路径命中。

ProgramOverLay 实现了 SyntaxFlow 的 ValueOperator 接口,SyntaxFlow 规则查询 overlay 视图与查询普通 Program 的方式一致,审计规则无需感知增量机制。追加新差量层时(extendOverlayWithNewLayer),基座与历史层被复用,仅追加新层,聚合文件系统保持惰性、不重新构建,基座的 updated_at 保持不变。

三、针对增量编译的效率优化

本节描述三项效率优化:文件树列表缓存、跨语言默认排除域、排除域在扫描与编译三阶段的统一应用。

YAK

3.1 文件树列表缓存

ssadb:// 文件树采用“缓存 + 按需失效”的访问策略,共三级缓存:

•程序级整树缓存(irSourceFS,common/yak/ssa/ssadb/source_filesys.go):每个 Program 的文件树作为整体缓存,touch-on-hit——被浏览的程序延长存活时间,闲置 10 分钟后整树逐出,容量上限 64;

•层树与文件体缓存(overlayFS.layerCache):结构树按层惰性构建,构建后常驻;文件体首次读取后原地替换空壳 stub,此后 Stat 报告真实大小;

•目录级水合缓存:同一目录的数据库查询只执行一次,后续 listing 命中内存。

首次展开文件树时完成构建与数据库水合,此后浏览、刷新与重复 ReadDir 均命中内存,不再访问数据库,源码文件照常打开。该行为由 TestIrSourceFS_RepeatedReadDirDoesNotReload 固化为测试。

YAK

3.2 跨语言默认排除域

SSA 编译内置跨语言聚合的默认排除域(common/yak/ssaapi/ssaconfig/compile_exclude.go),用于整体跳过测试代码、测试夹具、构建产物、依赖缓存等非生产攻击面内容。排除域由两张表组成:

•目录名表:node_modules、target、build、dist、vendor、testdata、tests、pycache、venv 等,按目录基名整体跳过;

•glob 表:按语言分组的文件模式,包括 Go 的 **/*_test.go 与 go.sum、JS 的 **/*.min.js 与锁文件、Python 的 /test_.py、C 的 **/.o 与 cmake-build-*/ 等。

两张表的构成遵循三条规则:

1.跨语言聚合:扩展名限定的模式(如 *_test.go)只可能命中对应语言的文件,不会影响其他语言;目录名标记的均为非源码内容(构建产物、依赖缓存、版本控制元数据),或不会出现在源码树中,因此无需按语言拆分;

2.归档不做全量排除:捆绑的运行时与依赖归档可能是合法扫描目标,因此仅按名称排除明显测试性的归档(test.jar),构建机械(gradle/wrapper/、mvnw)按名称排除;

3.glob 语义补齐:gobwas/glob 要求 / 后跟分隔符,/x 无法匹配根级 x。实现中为每个 /x 模式自动补充根形式 x,目录名展开为 dir、dir/、**/dir、/dir/ 四种形态,保证根目录下的同类文件同样被排除。

YAK

3.3 排除域贯穿扫描与编译三阶段

yak code-scan 的三个阶段——source(源码模式规则)、struct(结构扫描)、ssa(编译审计)——共享同一份 BuildCompileExcludeFunc 解析规则(内置默认 + 用户 --exclude-file)。被排除的文件不进入 source 规则扫描、不进入差量对账、也不进入编译:一个文件要么同时存在于三个阶段的视野中,要么三个阶段都跳过它,三个阶段的文件视野保持一致。

四、IRify 中的增量编译优化细节

本节描述 #5288 对增量项目文件树的重构。IRify 的项目文件树通过 ssadb:// 协议访问:根目录列出所有 Program,每个 Program 对应一棵树;增量 Program 是一条层链,文件树需要将"基座 + N 个差量层"呈现为单一树视图。

YAK

4.1 重构前的实现及问题

重构前,增量项目的文件树是一个包装 VirtualFS 的包装器:将聚合 FS 拷贝进虚拟 FS,再将 diff 状态附加在外部。该实现存在三类问题:

•读取层不确定:head 层修改过的文件在首次读取时可能返回基座的旧内容,因为读取路径缺少持有层概念,结果取决于遍历顺序;

•删除文件表现不一致:被删除的文件在 listing 中不存在,但在 ReadFile/Stat 中可能仍然可访问;

•diffOnly 逻辑位于上层:前端"显示全部文件"勾选状态需要由上层路由解析并分别实现,文件系统自身不提供 diff 视图。

YAK

4.2 overlayFS:原生实现完整 FileSystem 的层链视图

重构后的 overlayFS(common/yak/ssa/ssadb/source_overlay_fs.go)直接实现 filesys_interface.FileSystem 接口,不再包装其他实现,overlay 语义集中于一处。层链数据为 []overlayLayer(层名 + 该层的 FileHashMap),自旧到新排列,最后一层为 head。所有调用者路径以 head 为根(换头),各层条目在合并时重新挂接到 head 前缀下。

各核心操作的规则如下:

ReadDir(全量视图):遍历层链中每层的树,条目重新挂接到 head 前缀,按名字去重且新层覆盖旧层,过滤最终视图中已删除的文件,按名排序返回。目录存在性以"任一层持有该目录"的存在性标志判断,下层空目录不会被误判为不存在。排序保证输出结果的确定性。

ReadDirDiff(差量视图):仅列出 head 层自身的条目,对应前端"只看本次变更"的视图。目录仅存在于下层时,diff 视图返回空列表而非错误,避免全量视图中展开的节点在切换视图后失效。

持有层解析(ReadFile/Stat 的基础):自新向旧遍历层链,第一个持有该路径的层即为持有层。未填充的 stub 同样视为持有——head 层树上存在某路径的节点(即使内容未加载)即表明 head 的 diff 涉及该文件,读取不会回退到基座的同路径副本。该规则修复了"head 修改过的文件首次读取返回 base 旧内容"的问题。文件体按需加载:首次 ReadFile 时通过持有层的存储路径从数据库读取内容,并原地替换树上的 stub,此后 Stat 报告真实大小。数据库读取在锁外执行,慢查询不会阻塞 listing,锁内仅执行 stub 替换。

删除解析:自新向旧遍历,第一个持有该路径的层决定其状态:状态为 -1 时该路径在最终视图中不存在;更新的层重新持有旧层删除过的路径时,该文件恢复可见。删除文件在 ReadDir、Stat、ReadFile 中的表现统一为不存在。

另有两组工程处理:坏层降级——某层树构建失败时记录该层并在视图中跳过,其余层正常服务;单层链等价普通树——Program 是否为增量仅由链长决定,["prog"] 单层链即普通全量 Program 的树,无需特判分支。

YAK

4.3 在 IRify 中使用增量编译

IRify 提供两种开启增量编译的方式:

•重新编译时自动增量:在 SSA 项目探测.yak 脚本的编译选项中勾选"启动增量编译"后,编译产物在数据库中标记 is_overlay = true;此后对该项目执行重新编译时,程序自动计算文件差异并生成差量文件系统,只编译变更文件。基项目与增量项目在项目历史中各自可见,文件树通过聚合机制呈现包含基项目文件在内的完整视图;

•项目配置中手动增量:在编译选项的"基础程序名称"输入框中输入某个项目的全名(对应配置项 base_program_name),本次编译即基于该项目执行差量编译。

YAK

4.4 与前端的接口

•ExtraInfo 向前端暴露 IsIncremental 与 IsOverlayProgram 标记,用于文件树上标识增量 Program;

•diffOnly URL 参数选择差量视图或全量视图;文件系统不解析 query 参数,但提供 diff 视图的原生入口(ReadDirDiff);

•base_program_name 配置项(CLI / 配置 JSON)指定差量编译的基座,供 IRify 的增量任务使用。

YAK

4.5 缓存层次

结合第 3.1 节的缓存,IRify 文件树的一次展开经过两层缓存:

Plain Text

1  irSourceFS          ← 程序级:整棵 overlayFS 树,TTL 10min / 容量 64 / touch-on-hit
2    └─ overlayFS      ← 层级:每层结构树(layerCache)+ 已填充的文件体
3          └─ DB       ← 仅在首次构建层树 / 首次读取文件体时访问

首次展开后,浏览、刷新与 diff 视图切换均命中内存,不再访问数据库与磁盘。差量编译产生新层后按程序名失效缓存(DropProgramCache),下次展开呈现新链。

五、新逻辑相对旧逻辑的结构变化与优势

增量编译的早期版本(见内部文档《增量编译》)采用"平等 Layer 列表 + 预聚合"模型。本节对照旧版设计描述当前实现的结构变化及其带来的优势。

YAK

5.1 旧版结构

旧版 ProgramOverLay 的核心结构为:

Plain Text

1  type ProgramOverLay struct {
2      Layers []*ProgramLayer           // 按顺序存储所有层,所有层地位平等
3      FileToLayerMap *SafeMap[int]     // 文件路径 -> Layer 索引(快速查找)
4      AggregatedFS FileSystem          // 构建时聚合好的文件系统
5      signatureCache                   // Value 签名缓存,用于重定位
6  }

旧版设计要点:

•无 Base/Diff 区分:所有层都是平等的 Layer(Layer1 → Layer2 → … → LayerN),上层覆盖下层,依靠"自最上层向下逐层查找、找到即停止"实现覆盖;

•预聚合文件系统:再次增量编译时,先对上一次增量结果执行聚合(逐文件自上而下查找 + 按各层状态值求和判定最终存在性,和为 1 才进入聚合结果),再以聚合后的 FS 作为差量基准;

•文件定位依赖索引:FileToLayerMap 维护文件路径到层索引的映射,用于加速逐层查找;

•重定位依赖签名缓存:跨层符号重定位基于 Value 签名缓存匹配。

旧版流程中,基座类型分为三种情况处理:基座是 Overlay 时使用其聚合 FS;基座本身是差量 Program 时临时创建两层 Overlay(其基座 + 其自身);基座是全量 Program 时重建文件系统。

YAK

5.2 结构变化对照

维度旧逻辑新逻辑
层模型平等 Layer 列表,无 Base/Diff 语义Base *Program + Diff []*ProgramLayer 显式区分,基座与差量层职责不同
路径归属FileToLayerMap 文件路径 → 层索引,查找时逐层自上而下持有权元数据:Diff[i].File 持有集 + ExcludeFile 排除集,追加层时即时剥夺旧层持有权(stripOwnedPath)
查询路由在层列表上按索引逐层查找双源路由:差量层按 include 自新向旧查询,基座按 exclude 一次查询,结果合并
聚合文件系统每轮增量前主动聚合,全量拷贝惰性构建(首次访问时)+ 缓存;追加层不重建、不重拷(extendOverlayWithNewLayer)
删除判定各层状态值求和的算术规则(和为 1 存在)自新向旧首个持有该路径的层显式判定,-1 即最终不存在,更新层重新持有则恢复
符号重定位Value 签名缓存匹配"稳定名 + opcode"双路径:变量名索引快路径,opcode 扫描 + 名字交集兜底
层链恢复基座为差量 Program 时临时创建两层 OverlayOverlayLayers 配方一次恢复完整链;配方损坏时告警并按可用层降级
持久化配方IsOverlay + 全量层名列表BaseProgramName + FileHashMap 按层持久化;链头行保存完整配方,基座层只记录自身
文件树包装 VirtualFS,diff 状态外挂,每次刷新重新水合overlayFS 原生实现完整 FileSystem,持有层解析 + stub 按需填充 + ReadDirDiff 原生差量视图
缓存无(重复浏览重复重建)程序级 / 层级 / 目录级三级缓存(§3.1)
编译范围控制无统一排除域跨语言默认排除域贯穿 source 扫描、差量对账、编译三阶段(§3.2、§3.3)
⟷ 左右滑动查看完整表格⟷ 左右滑动查看完整表格

YAK

5.3 优势说明

正确性方面:

•消除读取歧义:旧版读取结果取决于遍历顺序,聚合时机不当或索引未更新时可能返回过期内容;新版持有层解析以"首个持有层"为唯一规则,且未填充的 stub 同样视为持有,保证 head 修改过的文件首次读取即返回新内容;

•删除语义统一:旧版求和规则隐式耦合各层状态值,多轮"删除—再新增—再删除"场景下语义不易验证;新版显式解析规则在 ReadDir、Stat、ReadFile 三个入口行为一致,被删除文件统一表现为不存在;

•差量基准稳定:旧版再次增量前需要重新聚合,聚合结果的形态差异可能引入对账偏差;新版差量两侧强制同一形态(swapLanguageFs 预处理)且 diff 域等于编译域,消除假变更与增量退化为全量的情况。

效率方面:

•增量成本与链长解耦:旧版每轮增量都要对全部层执行一次全量聚合拷贝,链越长开销越大;新版聚合惰性构建、按需缓存,追加新层仅复制持有集元数据(层文件列表),不触碰基座与聚合产物;

•查询路由简化:旧版每个文件查找都要经过索引映射与可能的逐层回退;新版差量层查询只涉及自身持有集(规模为变更文件数),基座一次 exclude 查询完成,不随层数线性增长;

•文件树访问零重复:三级缓存使重复浏览、刷新与视图切换全部命中内存,消除了旧版约 1.8MB/s 的空闲磁盘写入(§3.1);

•编译范围收敛:默认排除域使测试代码、构建产物、依赖缓存不进入编译与对账,直接缩短全量与增量的基线耗时(§3.2、§3.3)。

工程性方面:

•失败隔离:compileMode 三态路由、差量编译入口隔离(防递归)、失败回滚仅删除本轮差量层(§2.3),增量失败不影响已有可用状态;

•可观测与可降级:层链配方损坏时告警留痕并按可用层降级,坏层构建失败时在文件树视图中跳过(§4.2),不再因单层问题导致整个项目不可读;

•前端可见性:旧版前端无法区分普通项目与增量项目;新版通过 ExtraInfo 标记与 diffOnly 视图参数,文件树可直接标识增量 Program 并切换全量/差量视图(§4.3、§4.4)。

六、总结

上述工作可以按四个层面归纳:

层面正确性来源效率来源
差量计算(§2.2)diff 域等于编译域、两侧形态对齐MD5 快照对账,编译范围限于变更集
查询视图(§2.4)持有权、双源路由、Relocate基座 IR 直接复用
文件树(§4)持有层解析、统一的删除语义程序级 / 层级 / 目录级缓存
结构演进(§5)Base/Diff 显式化、显式删除解析、失败隔离惰性聚合与层追加复用、与链长解耦
⟷ 左右滑动查看完整表格⟷ 左右滑动查看完整表格

整体设计遵循同一原则:变更部分作为独立的一层——差量 Program 是一层,overlayFS 的 head 是一层,缓存失效的粒度同样按层。层间以"最新持有者决定"的规则组合,避免对旧产物做合并式修改。

END

  • Memfit:是万径安全旗下Yaklang团队研发的面向长程渗透测试、漏洞挖掘、应急响应等全链路攻防任务的生产级安全领域Agent系统

  • Yakit:是万径安全旗下Yaklang 团队开源的一款完全国产的交互式安全测试平台

YAK官方资源

Yakit官网下载地址:
https://yaklang.com/

Github地址:
https://github.com/yaklang/yakit

Yakit 视频教程:
https://space.bilibili.com/437503777

Memfit下载地址:
https://memfit.ai/

IRify下载地址:
https://ssa.to/

YTray下载地址:
https://yaklang.io/ytray/

跳转微信打开

本文转载自微信公众号「Yak Project」,查看原文。版权归原作者所有,如有侵权请联系删除。