OSSFP 是 2023年 提出的 OSS 新颖的软件组成分析(SCA)框架,在包含了23427个C/C++存储库大型数据集上应用,包含595,683个版本和900亿行代码,平均0.12秒来识别每个项目的所有tpl。
相关工作:SourcererCC(2016)、OSSPolice(2017)、Centris(2021)、CCScanner(2022)、TPLite(2023)、OSSDetector(2024)、TPLADD(2025)
绪论
SourcererCC:在嵌套代码克隆时候会出现错误。例如TPLa将TPLb的代码作为嵌套代码克隆复制到其存储库中,并且目标项目也使用了TPLb的一些代码,则SourcererCC将在其检测结果中同时报告TPLa和TPLb,即使目标项目中只使用了TPLb。
Centris:只保留具有最旧时间标记的代码作为原始副本,但仍然有大量常见和琐碎的函数,特征包含噪声,例如公共函数、return 0 等简单函数。
Centris工作中设置了一个预定义阈值θ,但是带来了三个不良后果:
-
如果阈值 θ 设置得太高(例如 θ=0.5),对于部分克隆场景(只克隆了小部分代码)就会容易被忽视,造成FN;如果阈值设置得太低(例如 θ=0),那么目标项目中的公共函数和琐碎函数导致的错报FP会变多(即很多结果都是不可信的)
-
每次都需要计算目标项目和每个TPL的匹配比率,需要消耗一定计算的时间。
-
如果不去过滤掉公共和琐碎的函数,它们占用了较大的空间存储,属于冗余。
OSSFP总结出了SCA工具的三个需求:
R1:SCA工具应该生成具有代表性的签名,实现准确匹配和检测。——将函数区分为了克隆函数、支持函数、通用函数和核心函数四大类。通过过滤到前三类函数,对TPL生成唯一的指纹。(这一点十分赞同👍,保留核心的代码,节省了空间,加快速度,也提高了可信度)
R2:SCA避免在预测TPL时候使用阈值(这点值得探讨,因为这个前提是你能够保证R1所得到的是完全能代表TPL的指纹,但实际上只能尽可能扩大数据库的范围,来使得保留的尽可能只有核心函数;这里是在精确度、可靠度上需要做一个trade-off,看看如何取舍了)
R3:SCA能够在使用大型TPL数据库时,保持良好检测性能(这一点赞同,保留关键代码,然后表征一段函数的特征应该尽可能地小而精细,同时匹配上速度要足够快才能落地使用)
方法论(重点)
A. 总览
-
特征生成阶段,将所有目标库克隆到本地,采取标签方式作为每个存储库的版本,为每个版本的每个功能生成特性;
-
哈希构建阶段,通过删除版本之间的重复函数为每个库构建库函数哈希索引,然后通过删除库之间的重复函数构建不同的函数哈希索引。
-
指纹选择阶段,使用不同的功能属性,过滤出克隆功能、支持功能、常用功能,只保留每个哭的核心函数。
B. 特性生成(Feature Generation)
本部分对每个库的所有版本的每个函数进行特征生成
github作为库源,收集超过100stars的C/C++库,总计23427个github repo 链接
克隆的存储库包含源代码的所有历史消息,包括提交时间和版本标记。
对于每个存储库,我们检索这些标记作为它们的版本。对于那些没有发布任何git标签的仓库,我们使用其当前的主分支作为其唯一的版本。
关于源代码解析工具:
-
OSSFP工作中选择了 Antlr 来解析所有C源代码文件中的函数
-
复现时优先考虑 Antlr ,也可以选择其他的例如tree-sitter、ctags等
为每个库的每个版本的每个功能生成以下特性
# 函数特性, ff
function_feature = [func_hash, time, loc, cc, hv]
# 一个版本的函数特性集合 vfl
version_func_list = [ff1, ff2, ..., ffn]
# 一个库的所有版本的函数特性集合,lfl
library_func_list = [vfl1, vfl2, ..., vfln]
其中函数特性 ff 包含了:
-
函数哈希(Function Hash): 由函数内容生成的MD5 Hash值。为了避免在没有语义差异的情况下缺少函数对的映射,函数内容将通过删除空格、新行字符和用于计算MD5哈希值的注释来进行规范化。函数哈希是我们用来在索引构建阶段对函数进行分组的标识。
-
作者时间(Author Time): 函数内容的创建时间,通过git blame命令获取。git blame命令可以显示每行函数内容的实际创建时间。函数的作者时间是确定所考虑的库是否是该函数的原作者的决定性因素。
-
代码行数(Lines of Code): 函数代码的行数,不包括空行。这行代码是其中的一行。代码行数:函数代码的行数,不包括空行。这行代码是其中的一行
-
圈复杂度(Cyclomatic Complexity): 函数内容内线性独立路径的个数。圈复杂度也用于度量函数的复杂度
-
体积(Halstead Volume): 函数复杂度的度量因子,由函数内容的长度和函数内容的词汇数计算得出。
-
[复现时候可以附加的] 库名称(library name):用于过滤使用
在实现上的细化的技巧:
-
为了避免同一个库不同版本之间重复解析,采用增量方法提高库生成函数特性效率。使用git log命令可以快速获取两个git标签版本之间的差异信息。
-
利用git blame找到函数的时间
至此,对每个库的所有版本的所有函数生成特性。
C. 索引构建(Index Building)
为所有函数构建具有它们所考虑的原作者库的不同散列索引
每个函数拥有不同的hash,那就可以通过hash匹配的方式来标记每个存储库中的克隆函数——只有当函数的作者时间早于所有其他存储库中具有相同hash的函数时,才可以将其确定为非克隆函数或称为原始函数。
为了有效地构建哈希索引,将该过程分为两个步骤
第一步骤:库索引构建
-
收集每个库的所有函数的hash及其生成时间,可以并行运行以减少处理时间
-
这里,给每个库构建了独立hash索引,包含函数hash值和最早提交时间。
-
对于不同版本的存储库,同一存储库中不同版本的相同函数哈希的作者次数可能会因不同版本的非语义更改而变化,例如更改函数名称或文件夹名称。(OSSFP提到了可能会出现非语义的更改,但是并没有做什么措施)
核心判断过程:如果一个函数的hash在LHI(该版本)中尚未存在则创建;如果出现hash碰撞,就比较提交时间,然后修改该hash的提交时间为最早的那个。
完成之后,LHI 追加加入到LHIL中。到此为止,处理完了一个库(repo)
在具体实现过程中,方便Algorithm2的处理,这里的LHIL感觉定义为一个dict()更好。然后将该库的所有版本函数的hash信息都放置在LHIL中。
Algorithm 1 Library Index Building
Input: LFL // List of Library Feature
Output: LHIL // List of Library Hash Index
procedure LIBRARYINDEXGENER ATION(LFL)
LHIL ← 0
for VFL in LFL do // List of Version Feature
LHI ← 0 // Function Hash Index
for FL in VFL do // List of Function Feature
hv←FL.hash_value
ct←FL.commit_time
if hv not in LHI then
LHI.put(hv:ct)
else if ct < LHI.hv.commit_time then
LHI.hv.commit_time ← ct
end if
end for
LHIL.add(LHI)
end for
return LHIL
end procedure
LFL:某个库,包含所有版本
VFL:某个版本,包含该版本所有函数
FL: 某个函数,属于指定库的指定版本
LHIL{hash_value: commit_time} :用来存储一个库的所有版本的不重复的函数特征
LHI {hash_value: commit_time} :用来保存每个版本的函数hash
第二步骤:为整个数据集构建函数哈希索引,只有非克隆函数将被存储在函数哈希索引中。
-
索引需要包含以下信息:
-
原始库:同一函数哈希的最早作者时间的库
-
作者时间(Author Time):函数创建时间,通过git blame命令获取
-
文档频率(Document Frequency):包含相同哈希函数的库的数量
-
迭代每个库,利用算法2,需要记录每个函数出现的文档频率。每个函数散列的出现频率也将被记录为文档频率,以供进一步分析
hv如果不在HI,计入时间,以及频率=1,库id
如果在,比较时间;早于则修改时间和所属库id,更新频率;晚于则只更新频率;
Algorithm 2 Hash Index Building
Input: LHIL // List of Library Hash Index
Output: HI // Final Hash Index
procedure INDEXGENER ATION(LHIL)
HI←0 // Final Hash Index
for LHI in LHIL do // Library Hash Index
libraryid ← LHI.name // Library Id
for hv in VFL do // Hash Value
ct←VFL.hv.commit_time
if hv not in HI then
HI.hv.commit_time ← ct
HI.hv.doc_freq ← 1
HI.hv.library ← library_id
else if ct < HI.hv.commit_time then
HI.hv.commit_time ← ct
HI.hv.doc_freq ← HI.hv.doc_freq + 1
HI.hv.library ← library_id
else if ct >= HI.hv.commit_time then
HI.hv.doc_freq ← HI.hv.doc_freq + 1
end if
end for
end for
return HI
end procedure
LHIL:一个库的所有版本hash
LHI: 某一个版本的hash
D. 指纹选择(Fingerprint Selection)
克隆函数->支持函数->区分通用函数->剩下核心函数
-
第一步,克隆函数过滤(Clone Function Filtering)
要检查函数是否是克隆函数,将在哈希索引中搜索有关函数哈希以比较库信息。所有库的每个版本都可以并行运行以搜索哈希索引。
提到超过60%都是清单1中所示的支持函数。
CENTRIS将克隆函数表示为借来的代码,并尝试为每个库消除这些函数。为了识别每个库的克隆函数,CENTRIS首先计算两个库之间重叠函数的比例,然后使用预定义的阈值来确定重叠函数是否为克隆函数。当重叠函数的比例小于预定义的阈值时,这些函数将保留在非原始库中。当部分使用的tpl的功能仍然作为库的指纹时,计算被考虑的库与目标项目之间重叠功能的比例将会减少。如果减少的比例低于CENTRIS预先设定的阈值,则TPL检测会产生假阴性。
识别克隆功能的算法3,如果library不对则是克隆函数,予以剔除:
Algorithm 3 Clone Function Filtering
Input: LHIL // List of Library Hash Index
Input: VFL // List of Functions of Version
Output: VFL // List of Functions of Version
procedure CLONEFILTERING(LHIL, VFL)
for FF in VFL do // Function Feature
HL ← LHIL.hv.library
if HL != FF.library then
VFL.remove(FF)
end if
end for
return VFL
end proced ure
FF:单个函数特征
VFL:某个版本的函数特征
HL:hash library,该哈希所属的库名称或者id
在复现时候,如果使用的是迭代器的for,不好remove,可以新建一个filtered列表存储结果。
-
第二步,支持函数过滤(Support Function Filtering)
不包含任何复杂逻辑、与普通函数、核心函数不同,可以使用MI对功能复杂性做一个度量体系。
HV 代表霍尔斯特德体积,CC 代表圈复杂度,LOC 代表代码行数。在特征生成步骤中,由于函数解析器已经检索了函数内容,因此可以通过计算换行符直接计算代码行数,而那些空行将被排除在外。
P表示Antlr解析器生成的控制流图中条件节点的数量。P表示Antlr解析器生成的控制流图中条件节点的数量。
N1和N2分别表示操作符总数和操作数总数。函数中的操作数是指变量或数字或常量字符串的词法记号,而其他类型的词法记号则作为操作符。n1和n2分别代表不同操作符的个数和不同操作数的个数。
为了避免函数解析的重复操作,这三种类型的函数属性都将在特征生成阶段获得。
如计算可维护性指数的公式所示,逻辑更直接的函数在可维护性指数上的得分更高。
关于阈值θ1:
在计算出一个库版本的各个功能的可维护性指数后,将支持功能按照可维护性指数得分的递增顺序排列为最高的部分。在不同的开源项目中,支持功能的比例可能会有所不同。将设置阈值θ1来过滤掉支持函数的这些部分。(第一个阈值出现,说明OSSFP不是说完全没有阈值,而是将阈值提前了)
复现时,感觉这部分可以放到特征生成的那里去,这样特征生成就只需要保留个MI值即可
-
第三步,常用函数过滤(Common Function Filtering)
由于公共函数在包含不同核心函数的多个tpl中被广泛实现和使用,这些公共函数的存在频率将高于一个特定库中的任何核心函数。
一个致命的问题,就是OSSFP是强依靠这一步的,但是这一步又恰好需要非常大量的数据。那如果是局部数据怎么办?
为了衡量每个函数的唯一性,受信息检索中的术语频率逆文档频率(TF/iDF)的启发,使用类似的方法来区分公共函数。本文中,将术语频率和文档频率定义为:
-
TF(Term Frequency)
-
一个库中具有相同的hash值的函数的数量
-
由于函数唯一性根据库级别测量,所以 TF 恒设为 1 ,以忽略一个库中测量唯一性影响。
-
-
iDF(Document Frequency)
-
包含相同函数hash的库的数量
-
文档频率在index building步骤中已经计算出来,可通过hash完成检索
-
因此,公共函数的文档频率将高于同一库的核心函数。如果将剩下的函数按照文档出现频率的递增顺序排序,那么常用的函数将出现在函数列表的后半部分。
由于每个库中常用函数的数量可能不同,因此将设置阈值θ2,以便根据库中文档频率的排名过滤出常用函数。(出现第二个阈值θ2)
TF-IDF:
TF−IDF=TF×IDF
TF(t, d) = 词t在文档d中出现的次数/文档d中词语总数
IDF(t, D) = log(预料库D中文档总数/包含词t的文档数目)
其中:
t 是要计算的词。
d 是目标文档。
D 是整个语料库。
最后,通过过滤掉每个库的克隆函数、支持函数和公共函数,确保数据库中只保留每个库的核心函数。
E. TPL检测
当目标项目中的任何函数映射到从核心函数构建的哈希索引中所考虑的库的函数时,该库将被报告为该项目的TPL。
在TPL检测部分没有设置任何阈值,因为所有函数对于相关库都是唯一的(目前来看,在TPL大规模数据下的趋向是唯一的)
评测
OSSFP提出了三个研究性的问题
-
RQ1:与相关工作相比,OSSFP检测TPLs的准确性如何? (侧重于评估OSSFP的准确性)
-
RQ2:OSSFP在时间效率和数据大小方面的可扩展性如何?(评估OSSFP的扩展性)
-
RQ3:每个功能过滤步骤对OSSFP精度的提高有何贡献?(测试核心函数生成特征的代表性)
对比方法
CENTRIS 、 Snyk CLI(商业软件)
假阳性(FP)出现的两大原因:(1)数据范围过小,缺少库,例如库A中的TPL N是核心逻辑库的一部分,而数据库中又没有N,所以导致会认为N是A的一部分(2)版本管理差,例如库B没有发布任何git标签
假阴性(FN)原因分析:有的假阴性案例都来自于库的版本管理不善。GoogleTest,在2013年9月19日至2016年8月22日之间不发布任何标签。在此期间,有许多源代码的持续更新,而一些存储库如FALCONN继续复制GoogleTest的最新代码。当GoogleTest最终在2016年8月22日发布版本时,由于这段时间的非语义变化,该版本的函数提交时间可能会晚于FALCONN。另一方面,如果B1被错误地识别为库A的一部分,如果B1的功能在指纹选择算法中占据优先位置,则会过滤库A的一些核心功能。
提出本领域的问题:23427个OSS并不是当前开源领域完整的C/C++库,一些开源项目是在其他源代码平台的(Gitlab、SourceForge),希望把这些留给未来研究,以构建C/ c++的开源项目路线图。
局限性:
-
OSSFP依赖于开源存储库的完整数据集。缺乏完整的开源存储库数据集和糟糕的版本管理是我们算法的最大限制。
-
缺乏开源存储库和糟糕的版本管理会影响算法,从而产生误报和误报情况。