Ohhnews

分类导航

$ cd ..
foojay原文

这个依赖更新看起来完全像是账户劫持

#marshal#依赖更新#供应链安全#gpg签名#行为分析

我将一个正在构建的扫描器对准了一个旧的 Spring 项目,它标记了 javax.activation。版本从 1.1-rev-1 升级到了 1.1.1。之前的版本都带有 GPG 签名,而这一版没有。 [LOADING...]

如果你读过真实供应链攻击的事后分析,这种模式应该会让你警觉。一个多年来始终对发布内容进行签名的软件包,突然发布了一个未签名的版本。一个无趣的解释是构建管道发生了变化。另一种解释是:现在发布软件的人不同了,而签名密钥留在了旧维护者那里。当 ua-parser-js 在 2021 年被劫持时,恶意版本来自一个被攻破的账户。当 event-stream 在 2018 年出现问题后,引进了一位未经审查的新维护者。制品本身看起来没问题,变化的是它的元数据。

那么 javax.activation 属于哪种情况呢?结果发现,它属于那种无趣的情况,但又不完全是。这个制品在 Sun 向 Oracle 过渡期间更换了归属方,签名规范也随之中断了。它是无害的。但我之所以知道这一点,是因为我亲自去核实过,而这正是本文的真正重点:区分常规交接与账户接管的信号,并不存在于任何 CVE 数据库中,因为在事件发生时,根本就没什么可提交 CVE 的。

CVE 扫描器无法察觉的空白

我想说清楚这一点,因为这并不是在说“你的扫描器不好”。Snyk、Dependabot、OWASP Dependency-Check,它们都在尽职尽责。它们的职责是将你的依赖树与已知、公开、已发布的安全公告进行匹配。这使它们天生就成为滞后指标。过去几年里每一次重大的供应链攻击——event-streamua-parser-jsnode-ipc、XZ Utils 后门——都轻松绕过了 CVE 扫描,因为恶意版本发布时还没有 CVE。披露是在几天或几周后才出现的。那些开启了自动更新的团队,在几小时内就已经拉取了被投毒的版本。

现有用于解决此问题的行为工具,基本上全是面向 npm 的。这很奇怪,因为 JVM 才是银行系统运行的地方。Maven Central 承载着大量受监管的、无趣的、承载关键业务的软件,但几乎没有人关注其软件包随时间的变化。

这个空白让我很恼火,于是我为此构建了一个工具。

Marshal

Marshal 是一个开源 CLI 工具(Apache 2.0 协议,Java 21),它扫描 Maven 或 Gradle 项目,并根据每次依赖更新的变化方式而非已知漏洞来评分。它特意设计为 CVE 扫描的补充工具。保留你的 CVE 扫描器,Marshal 覆盖的是 CVE 存在之前的窗口期。

每个依赖项都会获得一个 0 到 100 的风险评分,由七条行为规则构成:

规则监视内容
签名缺失之前版本有 GPG 签名,本次没有
缺少签名本次发布根本没有签名
新维护者签名密钥与之前所有版本均不同
依赖爆炸一次发布中声明依赖数量增长超过 3 倍
仓库 URL 变化scm URL 转移到不同组织或消失
主版本跳升跳过超过两个主版本且中间无版本
版本被撤销之前发布的版本已消失

评分分为四个级别。绿色(0-20):静默通过。黄色(21-50):在终端摘要中显示计数,完整详情在 JSON 输出中。橙色(51-80):在 PR 上发布评论。红色(81-100):导致 CI 失败。

评分实际工作原理

开发者安全工具的头号杀手是误报疲劳,因此引擎设计的重点在于不要虚报警报。

对此最重要的一条规则是:单一信号无法产生红色。无论某条规则的权重有多高,单独一个信号最高只能达到橙色。红色需要至少两个相互印证的独立信号。仅签名缺失值得调查,但签名缺失加上新签名密钥再加上三倍的依赖数量,就是另一回事了,这种组合才会导致构建失败。

引擎还包含一个信誉层。一个受维护的高信誉软件包列表(Apache commons 系列、大型框架供应商)的原始评分会减半。这些包仍然会被扫描,规则仍然会触发,只是需要更强的证据才会发出警报,因为 Apache 的公司密钥轮换是常规操作,而一个只有三周历史的制品发生密钥轮换则不是。

对于你个人审核过的结果,有一个抑制白名单。该白名单故意设计得很严格:条目必须锁定完整的 groupId:artifactId:version,通配符在加载时会被拒绝,每个条目都需要一个书面理由和过期日期。白名单中某个包的新版本会自动脱离白名单并重新评估——这是故意的,因为新版本正是妥协可能发生的地方。被抑制的结果仍会出现在 JSON 输出中并附带理由,从而留下审计踪迹,而不是静默地消失。

回到 javax.activation。两条规则触发:签名缺失(权重 40,历史事件:该包曾有签名规范,但放弃了)和缺少签名(权重 15,当前状态:此版本目前不可验证)。总分 55,橙色。不是红色,这是正确的。没有第二个身份信号,没有新密钥,没有依赖变化,因此引擎输出“合并前请审查”而非“阻止一切”。如果签名缺失伴随着维护者密钥变更,则会变成红色。那才是账户接管的模式。

在你的 pom.xml 上试试

整个工具就是一个 jar 包。你需要 JRE 21。

curl -fsSL https://github.com/marshal-hq/marshal/releases/download/v0.2.0/marshal-cli-0.2.0.jar -o marshal.jar

java -jar marshal.jar scan --build-file ./pom.xml

输出类似这样(已缩减):

● ORANGE  javax.activation:activation  1.1-rev-1 → 1.1.1   55/100
          SIG-DROPPED: prior releases signed, this one is not
          MISSING-SIG: no GPG signature for this release

1 finding at or above threshold. Exit code 1.

Gradle 项目的工作原理相同:将 --build-file 指向 build.gradlebuild.gradle.kts,Marshal 会通过 Gradle 工具自身解析完整的依赖图,而不是尝试将构建文件作为文本来解析。对于 CI,有 JSON 输出(--output json,模式稳定在 1.0)和阈值标志(--threshold orange),用于控制退出码,因此集成到管道中只需一行。退出码故意保持简单:0 表示干净,1 表示发现达到或超过阈值的结果,2 表示用法错误,3 表示无法解析。最后一种情况永远无法通过配置静默。“无法分析你的构建”这一点绝不能无声通过。

还有一个 diff 命令,用于比较基础构建文件与头构建文件,仅报告新引入的风险,以及一个 GitHub Action,可在 PR 上运行并在评论中发布你在截图中看到的内容。此处我不再详述 Action 的设置,README 中有说明。

如果你期望首次扫描时看到满屏红色:不会。大部分维护良好的包得分为零,这本应如此。有趣的问题从来不是“一个工具能标记多少内容”,而是它标记的那两三个东西是否值得你关注。

今日发布内容

当前版本为 v0.2.0。Maven 和 Gradle 在 scan、diff 和 Action 功能上处于同等地位。通过 --output 标志支持三种输出格式(humanjsonmd),可通过 marshal.yml 配置阈值和规则权重,关键结果可发送 Slack webhook,内置 SQLite 元数据缓存使重复扫描快速且支持离线,以及上述的白名单系统。该引擎已针对真实历史事件的重放语料库(包括 event-streamua-parser-jsnode-ipc)作为 CI 固定测试进行验证,因此每个版本都必须持续捕获已经发生过的攻击。

一个诚实的局限性值得一提:Maven Central 除了签名密钥之外,并不公开发布者身份,因此新维护者规则仅对密钥变更触发。而对于 XZ Utils 类型的攻击(一个看似可信的贡献者数月的耐心社交工程),静态行为信号大多无法捕捉。Marshal 可能会标记维护者交接,但不会标记有效负载。我更愿意直言不讳,而不是让你产生误解。

规划中的功能

所有工作均为开源,大致按以下顺序进行:

  • 在 Maven 路径上实现完整的传递性解析。Gradle 路径已经能解析完整的依赖图;Maven 路径目前读取直接依赖项,完整的 Maven 模型解析正在推进中。
  • 为 BOM 导入(scope=import)添加测试工具。代码路径已连接,但我想在实际项目上测试后再让大家依赖。
  • 激活受监管白名单的签名远程刷新,以便误报修复无需等待二进制发布即可到达用户。机制已构建,暂未启用;一旦发布密钥和 URL 就绪,便会上线。
  • marshal watch --package 用于监控单个制品。
  • 最终支持 npm。优先让 Maven 真正稳定。

链接

如果你运行它并发现了愚蠢的标记,请提交一个 issue。一个误报报告对我现在来说比一个星星更有价值。而如果它标记了并非愚蠢的内容,我也绝对想听听。

本文最初发表在 foojay 上:这个依赖更新看起来完全像是一次账户接管