gittuf:用于Git引用的签名日志gittuf - a signed log for git refs
介绍了名为gittuf的安全框架,旨在为Git引用提供经过密码学签名的日志记录。传统的分支保护规则仅仅依赖于“别人数据库里的一行记录”,这在安全性上存在单点故障风险。gittuf通过引入类似TUF(The Update Framework)的安全机制,为Git仓库的引用变更建立不可篡改的信任链。这为供应链安全提供了更坚实的底层基础设施,特别适合对代码完整性要求极高的企业级项目。
Andrew Nesbitt
提交签名是 git 的一部分。分支保护则不是。它是代码托管平台运行的数据库中的一条记录,由平台的 API 在接受推送前进行检查。大多数典型的源码仓库攻击正是利用了这两者之间的脱节。
代码托管平台强制执行的规则
分支保护、强制代码审查、CODEOWNERS、合并队列、状态检查、强制签名:所有这些都由代码托管平台管理,而且在你克隆仓库时,这些规则都不会随之带下来。提供该仓库的服务器可以随意提供任何 ref 指针。这些规则也可以在 git 中不留任何记录的情况下被更改。只需在设置页面拨动开关,就能在推送提交的短暂时间内禁用强制审查,并在推送完成后重新启用。唯一的记录只存在于由同一平台运行的审计日志中。
2021 年 3 月,有人向自托管的 PHP git 服务器推送了两次提交,目标为 php-src,并伪造提交者为 Rasmus Lerdorf 和 Nikita Popov。事后调查指出问题出在服务器本身,而不是这两位开发者的账户。该项目的应对措施是停止运行自己的 git 服务器,并将官方托管转移到 GitHub。仅仅依靠提交签名是无法阻止此事的:这些提交并没有被签名,而且即使签了名,也没有任何机制能强制对它们进行验证。
2018 年 6 月,由于一名管理员重复使用了在其他地方泄露的密码,Gentoo 的 GitHub 组织被攻击者接管。攻击者移除了合法的开发者,添加了傀儡管理员账户,并向 gentoo/gentoo、gentoo/musl 和 gentoo/systemd 推送了包含恶意修改的提交,如在 ebuilds 中植入 `rm -rf` 命令,并在 systemd 的 configure 脚本中进行了混淆删除操作。
根据具体仓库的不同,这些恶意的 ref 在 master 分支的顶端停留了八到十个小时。恢复过程包括请求 GitHub 支持团队冻结该组织,然后通过强制推送将干净的历史记录覆盖上去。而分支保护正是由攻击者刚刚接管的那个代码托管平台管理员角色来强制执行的。
2025 年 3 月,tj-actions/changed-files 维护者的机器人账户发生了 PAT(个人访问令牌)泄露,使得攻击者得以创建一个恶意提交,并将几乎所有现有的 tag 重新指向该提交。该 Action 被大约两万三千个仓库使用,在发生入侵的这段时间窗口内,任何通过 tag 拉取该 Action 的仓库都会拉取到新的恶意载荷,该载荷会将 CI 的 secrets(密钥)转储到构建日志中。
Tag 对象是不可变的:在不改变其哈希值的情况下,它们的内容无法被篡改。然而,指向 tag 的 ref 与其他 ref 一样只是一个普通的指针,只要代码托管平台接受推送,强制推送(force push)就可以移动它。
Ref 未被签名
上一篇文章中提到的那篇 2016 年的 USENIX 论文描述了这种模式:恶意的服务器可以将 ref 回滚到较早的提交,或者将其替换为另一个分支上不同的有效提交。拉取数据的客户端拿到的是一个能顺利通过验证的分支顶端,一个拥有合法签名的真实提交,只不过它不是维护者最近刚刚推进到的那个提交而已。Git 并未对 ref 进行签名,且仓库本身也没有记录哪个提交才是最后一个合法的分支顶端。
引用状态日志(Reference State Log)
gittuf(由同一研究小组在 2025 年的一篇 NDSS 论文中提出)将每一次 ref 更新都记录为一个经过签名的条目,并存放在仓库的 `refs/gittuf/reference-state-log` 路径下的一个哈希链中。每个条目都包含一个 ref 名称、新的提交哈希值以及上一个条目的哈希值,并由策略允许推进该 ref 的密钥进行签名。
验证一个克隆意味着正向遍历 RSL,并根据当时的有效策略检查每一次 ref 变动。如果你的克隆中 main 分支的 tip 与 RSL 结尾处的 tip 不一致,说明你和维护者之间的某个环节为你提供了一个未经他们签名的 ref。
代码审查和其他批准与 RSL 并列,作为独立的签名证明存在,而不是被并入 ref 推进(ref-advancement)条目本身。这样一来,验证过程既可以检查移动 ref 的是否为授权密钥,也能检查是否具备策略要求的批准。
验证在 forge(代码托管平台)外部运行,并依据该 forge 并未掌握的策略和密钥进行检查。对于 PHP 和 Gentoo 事件那种情况,攻陷了 forge 的攻击者可以生成一个有效的 commit,也可以推送一个指向它的 RSL 条目,但无法提供有效的签名。遍历日志的验证器会在满足策略的最后一个条目处停止。tag 的移动与其他操作一样,都是一次 ref 更新,需要由策略允许推进 tag 的密钥进行签名,因此 tj-actions 攻击只会留下不一致的日志,或者使用了攻击者并不拥有的密钥进行签名。
策略、委托与阈值
策略存储在 refs/gittuf/policy 中,采用源自 TUF 的元数据格式。根策略列出了受信任的密钥持有者以及更改根策略所需的阈值。根策略将权限委托给如下形式的规则文件:“这三把密钥中有两把可以推进 refs/heads/main”,或者“此集合管理 src/crypto/ 下的任何内容”,或者“只有发布经理的密钥才能移动匹配 v* 的 tag”。
委托链:一条规则可以将特定路径的权限移交给由另一组不同密钥签名的规则文件。子规则只能在其作用域内增加要求,而不能削弱其继承的权限,因此,授予 infra(基础设施)负责人对 infra/ 的权限,并不能降低根策略为 main 设定的阈值。验证器会遍历这个图,并检查每一次 ref 更新是否满足某条授权规则。
阈值签名正是人们开始要求 GitHub 作为产品功能提供的特性。如今的“必需审查者”只是 forge 中的一个设置,在 push 代码落地前由其 API 进行检查。gittuf 的 M-of-N 则是其密码学版本,仅凭代码仓库本身即可完成验证。同样的模式也能处理针对敏感路径的 CODEOWNERS 风格控制:一次委托可以将规则的范围限定在 refs/heads/main 以及 infra/ 下的路径,并要求来自指定集合的两个签名。
它在签名技术栈中的位置
上一篇文章提到的制品签名技术栈假设制品所源自的代码树(tree)就是维护者批准的代码树。gittuf 提供了这一检查。Sigstore 覆盖了从代码树状态到注册中心中制品的整个过程,并通过证明(attestations)描述是谁基于什么源码构建了它。in-toto 证明可以指出构建所基于的 commit,但并不会记录该 commit 是否是 ref 的合法 tip。而 RSL 补充了这项记录。
因此,客户端检查的信任链从注册中心开始,贯穿构建过程,经过授权该 commit 的 RSL 条目,最终延伸至由 forge 外部持有的密钥。
我希望看到代码托管平台直接内置 gittuf,这样人们所依赖的工作流(在 Web 界面中编辑文件、在 PR 上点击合并)就能代表维护者生成已签名的 RSL 条目。目前最接近的方案是 gittuf 项目自己的 GitHub App,它将 PR 的审查批准记录为来自平台外部的证明(attestations),但合并操作本身依然是由平台发起的,而平台在委托图中并没有密钥。如果平台持有一把密钥,并使用它来响应经过身份验证的用户操作以推进 refs,那么平台就会成为信任链中的一个参与者,而大部分日常工作流则可以保持原样。
需要完整排版与评论请前往来源站点阅读。