两万个项目堆成山(续):漏洞都去哪儿了?
上回说到我们删了一万五千个旧项目,DT 服务满血复活。本以为可以功成身退,结果第二天一早打开页面——漏洞数量:0。
欢迎收看大型连续剧《运维永不杀青》第二集。
前情提要
[上一集](两万个项目堆成山:一次 Dependency-Track 服务雪崩的全记录)里,我们的 Dependency-Track(以下简称 DT)被两万个堆积的项目活活累瘫,健康检查连续失败一万五千多次才被发现。我们重启、备份、写脚本、删项目,一气呵成。
故事本该在"删除完成:成功 6671,失败 61"这一行画上句号。
但运维的故事,从来没有句号,只有逗号。
一、案发第二天:漏洞集体失踪
清理完成后的第二天,例行检查。打开 DT 的 Dashboard,准备欣赏劳动成果。
Portfolio 漏洞:0。
面临风险的项目:0。
易受攻击的组件:0。
一整排零蛋,整整齐齐,仿佛在嘲笑我。
要知道,清理前这里显示的是一百六十多万条漏洞发现。一夜之间,从百万富翁变成穷光蛋,这剧情走向不对啊。
第一反应:删错了?难道清理脚本把不该删的也删了?
先深呼吸。然后开始分层排查——这是血的教训换来的纪律:数字归零不等于数据丢失,先验证再恐慌。
二、分层诊断:是"没算完"还是"真没了"
2.1 第一层:指标缓存
DT 的 Dashboard 不是实时查库,而是读指标缓存。大规模删除后,ProjectMetricsUpdateTask需要重新计算所有项目的指标,计算完成前显示 0 是正常的。
查日志:指标任务在跑,每 2.5 秒处理十几个项目。
行,等它算完。4137 个项目,预计十几分钟。
2.2 第二层:真没了
十几分钟后,任务跑完了。再查——还是 0。
这就不是缓存的事了。开始查数据库,这里有一个关键认知:
| 表 | 是什么 | 当时数量 |
|---|---|---|
VULNERABILITY | 全局 CVE知识库(漏洞字典) | 306,132 |
COMPONENTS_VULNERABILITIES | 项目组件与漏洞的关联(页面数字的来源) | 0 |
FINDINGATTRIBUTION | 漏洞发现归因 | 0 |
看到没?30 万条漏洞记录安安静静地躺在知识库里,一条没少。但"哪个项目的哪个组件中了哪个漏洞"的关联表,空空如也。
这就好比图书馆的书都在,但借阅记录全没了——书没丢,是"关系"没了。
三、时光机:3.6GB 备份再度立功
关联表为空,有两种可能:
- 清理时被误删了(事故,要背锅)
- 本来就没有(历史遗留,更尴尬)
怎么定性?上备份。
清理之前我们做过 3.6GB 全库备份,此刻它就是时光机。把备份恢复到一个临时数据库容器里,跑几条对照查询:
备份里的关联记录总数:1,677,908 其中属于"近 180 天项目"(我们要保留的)的:0 其中属于"超 180 天项目"(我们删掉的)的:1,677,833真相大白:那一百六十八万条漏洞发现,全部挂在被删除的旧版本项目上。我们保留下来的四千多个近期项目,在清理前就一条发现都没有。
清理没有丢数据。它只是把一堆旧账本烧了——而那些我们想留的账本,本来就是空白的。
虚惊一场?不,是惊出第二场。
四、更尴尬的真相:分析器在长期罢工
新问题浮出水面:为什么近半年的项目一条漏洞都没分析出来?
难道我们的代码都完美无缺?用过 Maven 的人都知道,这不可能——Spring、log4j、fastjson 这些老朋友,哪个不是背着几十上百个 CVE 的?
翻配置,答案找到了:
内部分析器靠组件的 CPE 去匹配 NVD。而 CI 生成的 CycloneDX BOM 里,Maven 组件只有 PURL,没有 CPE。
CPE 和 PURL 是两种不同的组件标识格式。分析器拿着 CPE 格式的钥匙,去开 PURL 格式的锁,开了半年,一把也没打开。
而那个能解决这个问题的开关——“对 PURL 组件启用模糊 CPE 匹配”——从来没被打开过。
系统没有报错,没有警告,只是每天勤勤恳恳地导入 BOM、勤勤恳恳地分析、勤勤恳恳地得出零条结果。
它不罢工,它只是在无效加班。
五、配置的语义恶作剧:界面说"启用",数据库说"排除"
找到开关,打开它,总行了吧?
没那么简单。
在界面上勾选"对 PURL 组件启用模糊匹配",点保存——勾选框自己弹回去了。再勾,再弹。像极了那个永远关不上的浏览器弹窗。
一度以为是前端 bug。直到直接查了数据库,才发现 DT 的配置属性玩了一手语义反转:
internal.fuzzy.enabled = false ← 模糊匹配总开关:关 internal.fuzzy.exclude.purl = true ← PURL 组件:被排除看懂了吗?数据库里用的是“排除(exclude)”语义,true表示"排除掉";而界面上写的是“启用”。同一个配置,两张面孔。
而且还有个联动逻辑:总开关关着的时候,子选项无法持久化——这就是勾选框自动弹回的原因。它不是 bug,是"你不配"。
正确姿势:先开总开关,再改排除项,最后查库验证。永远不要只相信界面,数据库才是老实人。
六、墙外有墙:情报源的连通性罗生门
配置修好了,顺手检查了一下漏洞情报源的同步状态——又发现问题。
本地 NVD 知识库的最后同步时间:九个月前。
也就是说,就算分析器一直在正常工作,它用的也是九个月前的漏洞字典。查最新 CVE?不存在的。
为什么停了九个月?连通性测试安排上:
| 目标 | 结果 |
|---|---|
| NVD 主站和旧版 feeds | 403(Cloudflare 按 IP 拦截) |
| NVD 官方 REST API | 200 |
| OSS Index | 可达 |
| GitHub Advisories | 可达 |
同一堵墙,拦住了旧路,新路口却敞开着。NVD 官方日志里还贴心地留了句话:“旧版 feeds 即将退役,建议切换到 REST API 镜像。”
官方都这么说了,那就切。在配置页启用"API 镜像"模式,申请一个免费的 API Key 填进去,重启,收工。
理论上。
七、补全行动:4152 个项目的再教育
回到漏洞补全。开启模糊匹配只对新导入的 BOM生效,存量四千多个项目需要重新触发分析。
DT 没有"重新分析"按钮。唯一的办法:把每个项目的 BOM 导出来,再重新上传一遍——上传动作会触发完整的分析流水线。
写个脚本:导出 → base64 编码 → 重传,3 线程并发。听起来很简单,执行起来,三连坑。
坑一:Key 里的换行符
脚本启动,4152 个项目全部失败,报错Invalid header value b'...\n'。
排查半天,最后发现是设置环境变量时引号跨了行,API Key 的值里混进了一个换行符。HTTP 头里带换行,服务器当场拒收。
修复:重新设置变量,顺手给脚本加了.strip()——永远不要相信用户输入,包括你自己。
坑二:KeyError: ‘bom’
重跑,又全部失败,这次是KeyError: 'bom'。
手工调试才发现:DT 4.11 的 BOM 导出接口直接返回 CycloneDX 文档本身,没有{"bom": "..."}包装。脚本里那句json.loads(raw)["bom"]对着空气取了个键。
版本升级改了接口行为,文档没说,代码没变,坑留给了运维。修复:拿到原始内容直接编码上传。
坑三:跑的还是旧脚本
改完代码重跑,报错一模一样。
一度怀疑人生。最后发现:文件压根没更新成功,后台跑的还是旧版本。
从此立了个规矩:启动后台脚本前,先grep一下关键代码行,确认文件真的是你以为的那个版本。
三连坑填完,脚本正式开跑。这次一路绿灯:
[4150/4152] 成功 4150 失败 0 完成: 成功 4152, 失败 0零失败。漏洞发现从 0 涨回五万多条,一百多个项目重新挂上了"有风险"的牌子。
数字没有回到一百六十八万——那是好事。旧的百万级数字是两年里每个构建版本重复计数的虚胖,现在的五万条,只统计活跃版本。健康的数据,不需要虚高的排面。
八、NVD Key 悬疑剧:88 个字符的罗生门
补全跑完,回头处理 NVD 同步,结果它给了个下马威:
NvdApiException: NVD Returned Status Code: 403Key 被拒。检查数据库里存的 Key,长度88——而标准 NVD Key 是 36 位 UUID。
"粘错了!"我们当即断定,还顺手分析了 88 位里的/、+、=:“看,这是 base64 字符,明显不是 Key。”
于是重新申请、重新粘贴、重新验证……折腾一圈后才发现真相:
DT 会把 Key 加密后存库。36 位明文加密后恰好是约 88 位的 base64 密文。那些"可疑的 base64 字符",是加密的正常产物。
而我们中途还干过一件蠢事:把数据库里的密文取出来当 Key 去调 NVD 接口——用加密后的乱码当钥匙开锁,不开才怪。
教训很简单:验证配置是否生效,看任务的实际日志,别拿密文当凭证。
真正的凶手是第一次确实粘错的凭证。把正确 Key 在宿主机实测 200 后重新填入,重启容器。由于镜像任务是 24 小时周期调度(当天已有执行记录,重启不再触发),全量同步要等次日凌晨的调度窗口自动执行。
不急。这次钥匙已经配好了,只等锁自己转。
九、装上烟雾报警器
第一集的结尾说过:治标不如治本。这一集我们把"报警器"装上了:
健康检查脚本 + 每小时定时巡检:
- DT API 是否存活
- 项目总数是否超过阈值(比如 6,000——上次就是堆到两万才崩的)
- 异常写告警日志
一个小插曲:脚本第一版用grep "X-Total-Count"提取项目数,结果把 CORS 响应头也匹配进去了,算出来项目数是Origin,。
匹配加行首锚点,问题解决。正则一时爽,锚点保平安。
十、这一集的教训
- 数字归零先分层:区分"指标缓存没算完"和"关联数据真没了",别一上来就写事故报告。
- 知识库不等于发现:
VULNERABILITY表有 30 万条,不代表项目有 30 万个漏洞。前者是字典,后者是病历。 - 备份是最好的时光机:分不清"删丢了"还是"本来没有"?恢复到临时库对比一下,十分钟出真相。
- 界面会说谎,数据库不会:尤其是带"排除/启用"语义反转的配置,查库验证是唯一标准。
- 沉默的失败最危险:分析器空转了半年没有任何报错。给你的关键链路加个"产出是否为零"的监控吧。
- 后台脚本启动前,确认文件版本:
grep一下关键行,两秒钟,省两小时。 - 密文不是凭证:加密存储的配置,不能取出来当原文用,也不能拿明文长度做校验。
写在最后
这两集合起来,是一个完整的"连环案":
项目堆积压垮服务 → 清理恢复 → 暴露出漏洞归零 → 挖出分析器长期空转 → 顺带发现情报源断更九个月 → 补全数据、切换同步通道、装上监控。
每一层问题都藏在上一层的阴影里。如果只修到第一层就收工,我们会拥有一个健康但"永远零漏洞"的 SCA 平台——一个从不出漏洞报告的安全平台,是安全的吗?
大概比不出报告更危险的,是看报告的人以为它很安全。
所以,去检查你的关键系统吧。不只是检查它"活着",还要检查它"有产出"。
也许你的某个组件,也在沉默地空转。
(全剧终?运维的剧,永远在续订。)
本文为《两万个项目堆成山》系列第二集,基于 Dependency-Track 4.11.4 的真实运维经历撰写。文中所有命令与脚本均已在生产环境验证,敏感信息已做脱敏处理。环境不同,坑的形状可能不同,请酌情参考。