1. 依赖漏洞为什么值得单独做一次自动化排查1.1 被忽略的第三方依赖风险说实话大部分Python项目的安全问题都不是业务代码写出来的而是第三方依赖带进来的。你pip install一把梭的时候可能根本不知道这个包背后引了哪些传递依赖更不知道这些依赖的某个版本已经公开了CVE漏洞。举一个非常典型的例子log4j漏洞在国内Java圈炸锅的时候很多人下意识觉得“这是Java的事跟Python没关系”。但Python生态里同样出现过类似的供应链攻击事件比如PyPI上被投毒的恶意包、某个知名库的旧版本被发现远程代码执行漏洞。区别只是Java的log4j被媒体放大报道了Python这边更多是在安全公告库和漏洞数据库里悄悄更新。这时候就需要一个能自动扫描依赖漏洞的工具。它的核心作用很简单解析你项目里声明的所有依赖清单逐个比对公开漏洞数据库把“这个包当前版本存在漏洞、影响范围是什么、修复版本是什么”一次性列出来。说白了就是给依赖做一次全面体检。这个工具适合谁用我觉得至少三类人个人开发者自己维护的开源项目或小工具没人提醒你依赖过时了全靠自觉。团队技术负责人负责整个项目的依赖治理需要定期安全巡检还要能出报告。安全工程师做上线前安全检查或者接到“排查项目供应链风险”这类任务时手里必须有一套趁手的自动化工具。1.2 手工排查为什么走不通有人可能会说我直接去NVD美国国家漏洞数据库逐个CVE翻不就行了我试过真的不现实。首先你的项目里有多少个直接依赖通常几十个。它们的传递依赖加起来呢轻松上百个。每个依赖都去查一遍它是否受某个CVE影响这个工作量已经不是靠人力能扛住的了。其次手工排查特别容易漏。NVD上每天新增几十上百条CVE记录你不能每天都把所有依赖重新查一遍。往往是出事之后才回头翻那时候已经晚了。更重要的是手工方式只能看到“某个版本有没有漏洞”看不到“当前项目的依赖树里哪个漏洞是真的可利用的”。有些CVE影响面很大但你的代码根本没用到对应的危险函数有些CVE影响范围很小却恰好命中你的关键路径。这些判断都需要工具先帮我们把原始数据拉全再结合实际情况分析。所以说自动化不是可选项而是刚需。下面我先把主流方案拉出来对比再讲我们自己搭建这套扫描工具的实际过程。2. 主流扫描方案对比我为什么选了这套组合2.1 四类常见的依赖漏洞扫描器在动手写代码之前肯定要先看看社区里已经有什么现成的轮子。Python生态里主流的依赖漏洞扫描方案我分成四类来对比方案扫描方式漏洞数据来源优点缺点pip-audit本地安装环境或依赖清单PyPI JSON OSV官方维护、使用简单、可做SBOM只能查Python生态Safety依赖清单Safety DBpyup.io商业库数据全、更新快免费版有查询次数限制OSV-Scanner锁定文件OSV漏洞数据库多语言生态、数据源开放需要项目有lockfileTrivy镜像、文件系统、仓库多个数据源聚合覆盖面最广不只是Python体量较重偏容器场景我实际用下来简单项目用Safety查一下也够用但免费版有次数限制不适合做定时巡检。Trivy更偏向容器镜像和云原生资产扫描如果只是查一个纯Python服务引入Trivy有点大材小用。2.2 我的选型结论pip-audit OSV-Scanner 组合我最终确定的组合是核心用pip-audit辅助用OSV-Scanner再加上一层自己的封装脚本。选择pip-audit的主要理由是它由Python打包权威机构PyPA维护跟pip是同一个“家族”的项目可信度高。支持从多种依赖来源扫描包括本地site-packages、requirements.txt、pyproject.toml、Pipfile.lock等。支持输出JSON、SBOM等结构化格式方便二次处理。默认对接OSV和PyPI的安全公告数据源本身就是公开的。OSV-Scanner则作为补充专门处理那些“项目里只有lockfile格式、pip-audit解析不够友好”的场景。比如有些项目用Poetry管理依赖虽然pip-audit也能读pyproject.toml但锁定版本信息如果存在poetry.lock里OSV-Scanner的解析更准。不要纠结于“一定要找一个万能工具”。实际工程里工具组合是常态。我的思路是主扫描器负责大部分场景辅助扫描器覆盖剩下的边角场景再用自己写的脚本把两者的报告汇聚起来。下面详细讲讲这个自动扫描工具的实现逻辑。3. 扫描工具的核心实现逻辑3.1 依赖解析先搞清楚项目里到底装了哪些包扫描的第一步是拿到一份可靠的依赖列表。这一步看似简单实际坑很多。如果你的项目目录下有requirements.txt先用pip-audit直接扫它。但要注意一个问题requirements.txt里写的版本号有可能是不完整的。比如requests2.20.0 flask gunicorn20.1.0这里flask没有指定版本requests指定的是下限。扫描器只能基于你声明的约束做判断结果要么是“信息不足跳过”要么是“按当前环境里实际安装的版本判断”。所以要拿到准确结果必须结合一个已经安装好依赖的环境来扫描或者项目里有精确锁定版本的lockfile。我一般分两条路径执行如果项目里有poetry.lock、Pipfile.lock或uv.lock优先用这些文件。因为lockfile里把每一个传递依赖的精确版本都锁定了扫描结果最准确。如果只有requirements.txt且版本写得不严格我会先创建一个干净的虚拟环境执行pip install -r requirements.txt然后直接扫描这个虚拟环境的site-packages。用虚拟环境的原因是避免把开发机上的全局包混进来。曾经踩过坑直接扫当前环境结果把其他项目装的包也扫出来了漏报倒没有但报告里一堆跟当前项目无关的告警干扰判断。3.2 漏洞源对接与匹配逻辑依赖列表拿到手之后接下来就是漏洞匹配。pip-audit的核心流程是把每个依赖包的包名和版本解析成规范格式。调用PyPI JSON API或OSV API查询该版本是否存在已知安全公告。如果命中返回漏洞ID、受影响的版本范围、修复版本、漏洞描述等信息。这里有一个值得注意的点很多漏洞的“受影响版本范围”不是精确到某一个版本而是一个区间比如2.0.0,2.4.1。扫描器需要做的是判断你当前锁定的版本是否落在这些区间内。我自己封装的时候直接用pip-audit的JSON输出避免重复实现这个匹配逻辑。命令长这样pip-audit --requirement requirements.txt --format json --output pip-audit-report.json但这里有个问题如果requirements.txt里版本写得太宽泛pip-audit无论如何也无法精确判断当前锁定版本因为它没看到“实际装的版本”。后来我改成了先用pip freeze生成当前环境快照再扫快照文件pip freeze requirements-lock.txt pip-audit --requirement requirements-lock.txt --format json --output pip-audit-report.jsonpip freeze会把当前环境里所有包的精确版本列出来扫描结果准确度直接提升一个档次。代价是这份requirements-lock.txt只能作为扫描输入不能替代真正的依赖清单给别人用因为里面可能包含不需要直接引用的传递依赖。3.3 输出报告的格式设计扫描结果如果只是往终端里打几行字没人愿意看。尤其是要定期巡检出报告的时候报表的易读性很重要。我做的输出层分两种格式一种是人读的摘要直接输出到终端或写入Markdown文件。核心字段包括包名、当前版本、漏洞ID、漏洞描述摘要、CVSS评分、修复版本。按CVSS评分从高到低排一眼就能看出哪个最紧急。另一种是机器读的完整数据用JSON或SBOM格式保留。这样后续可以接入告警系统也可以做历史数据对比看“上一轮扫出来5个漏洞这一轮修掉了几个、新增了几个”。具体代码封装的时候我是这样做的import json import subprocess def run_scan(requirement_file): cmd [ pip-audit, --requirement, requirement_file, --format, json, ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(result.stderr) return json.loads(result.stdout) def summarize(report): dependencies report.get(dependencies, []) vulnerabilities [] for dep in dependencies: # 每个依赖下的 vulns 字段就是命中的漏洞信息 for vuln in dep.get(vulns, []): vulnerabilities.append({ package: dep[name], version: dep[version], vuln_id: vuln[id], summary: vuln.get(description, )[:80], cvss: vuln.get(cvss, {}).get(score, N/A), fix_version: vuln.get(fix_versions, []), }) return sorted(vulnerabilities, keylambda x: x[cvss], reverseTrue)这里的核心思路是扫描器本身不关心你用什么数据格式只要能把原始漏洞数据拿回来后面的分析和通知都是你自己说了算。注意pip-audit的JSON结构在不同版本里字段名会有微调。我在项目里固定了pip-audit的最低版本避免上游升级后脚本解析报错。这个坑后面还会细说。4. 实跑结果与避坑记录4.1 拿一个真实项目跑一遍为了验证这套工具的效果我拿自己维护的一个Flask项目做了实测。这个项目规模不大直接依赖34个传递依赖70多个。扫描命令执行之后结果分成了三类高危漏洞CVSS 7.02个都来自同一个底层库。原因是这个库的旧版本有一个反序列化相关的安全问题而我们项目里的另一个库依赖了它属于典型的传递依赖风险。中危漏洞CVSS 4.0-6.93个问题不大但需要关注。低危或信息级8个基本都是版本过旧导致的潜在风险升级后即可解除。最有价值的是扫描工具直接给出了修复版本。比如某个库当前装的版本是4.2.0漏洞影响范围是4.5.1那么我把版本升级到4.5.1就能同时解决两三个漏洞。这个信息在手工排查时可能要花半天才能从漏洞公告里捞出来。4.2 误报和漏报的真实案例依赖漏洞扫描有一个绕不开的问题误报。不是工具错了而是漏洞描述里的受影响范围往往大于实际可利用的场景。举个例子我扫描A项目时工具报出某个JSON处理库存在CVE影响版本是1.2.3。但排查之后发现这个漏洞只有在该库被用作服务端端点解析时才能触发而A项目里它只是被用来读取本地配置文件。从CVSS评分看是中等偏高的漏洞实际上对我们的风险非常有限。那怎么办我的做法是在报告里保留这些“理论上存在但实际不可利用”的项但单独加一个分类叫“需人工复核”。判断依据主要是看漏洞通告里的攻击向量、前置条件以及我们项目实际调用该库的方式。这一层判断没法完全自动化但可以通过一个“已知风险白名单”来管理避免每次扫描都重复人工确认。漏报也有案例。有一回我发现某个依赖包在PyPI上的版本定义和本地安装包的实际代码不一致怀疑是缓存污染。扫描器只比对“包名版本号”不会校验包内容的哈希所以这种情况下会漏掉风险。后来我把扫描工具接入了pip hash校验逻辑对锁定文件里每个包生成哈希跟PyPI上发布源文件的哈希对比不一样的直接标红。这个问题比较罕见但供应链攻击的可怕之处就在于你可能装了一个版本号正确但内容被篡改的包。4.3 升级修复时的连锁反应依赖冲突坑扫描工具给了修复版本你以为直接pip install --upgrade就完事了太天真了。依赖升级的连锁反应是这套流程里最容易翻车的一环。我遇到过一次典型的依赖冲突A库的新版本修复了漏洞但A库新版本引入了对C库更高版本的要求而B库又恰好需要C库的旧版本。结果就是升级A之后B直接启动失败。这种“依赖地狱”在Python项目里太常见了。所以我的建议是扫描工具查出来的高危漏洞先修修复时不要贪多一次只升一个包每次升级后跑一遍完整测试用例。如果升级某个包导致连锁冲突就用Python官方推荐的依赖解析器pip install新版默认行为去解解不开就退回原来的版本手工评估当前漏洞的实际风险而不是硬升。这个经验和网上很多“把所有依赖升到最新”的建议相反。把所有依赖升到最新往往能消除漏洞但代价是新版本之间的兼容性风险。依赖漏洞扫描的目标是“把风险降到可接受水平”不是“零漏洞”。5. 把扫描嵌入日常流程CI、定时与告警5.1 本地手动扫描与CI阶段工具做出来之后不能只在出事的时候跑一次。我给它设计了三个使用场景。第一个是本地手动扫描适合在开发过程中随时自查。这个最简单一条命令的事。我把它封装成了一个脚本入口长这样python dep_scan.py --source venv --output console python dep_scan.py --source requirements.txt --output markdown --file report.md第二个场景是接入CI流程。以GitLab CI为例我在流水线里加了一个dependency-scan阶段装好依赖之后立刻扫描dependency-scan: stage: test script: - pip install pip-audit - pip-audit --requirement requirements-lock.txt --format json --output pip-audit-report.json || true artifacts: paths: - pip-audit-report.json expire_in: 30 days注意这里用了|| true目的是不要让扫描阶段的非零退出码直接中断流水线。更合理的做法是只在发现高危漏洞时中断中低危漏洞只记录不阻塞。5.2 定时扫描与通知渠道第三个场景是定时巡检。我部署了一个简单的定时任务每周一早上自动拉取项目最新代码、创建虚拟环境、安装依赖、执行扫描然后把报告推送到团队群里。通知渠道我用的是自定义脚本核心就是一个webhookPOSTimport requests def notify_team(payload): webhook_url https://your-webhook-url headers {Content-Type: application/json} requests.post(webhook_url, jsonpayload, headersheaders, timeout10)这样做的价值在于把“扫描”从一次性自查变成了持续性的依赖观察。某个依赖在上一轮扫描时还是安全的这周它的新CVE被公布了定时任务会在周一早上自动发现并通知你。这里引出一个关键步骤漏洞数据库是会持续更新的上周扫不出来不代表这周扫不出来。我自己就遇到过态度是宁可每周多扫一次也别等漏洞被利用之后再补。5.3 缺少lockfile时的兜底策略不是所有项目都有规范的lockfile。很多老项目就是一份手写的requirements.txt版本号写得稀烂甚至有的连requirements.txt都没有靠setup.py动态装依赖。遇到这种情况我的兜底策略是“先制造lockfile再扫描”。具体流程在干净的Python虚拟环境里安装项目声明的依赖。用pip freeze强制生成一份完整精确的依赖快照。拿这份快照去做漏洞扫描。这个策略的缺点是生成快照那一刻的版本可能不是开发机上正在用的版本。所以我会在快照文件头部加一行注释标记生成时间和来源环境避免别人误以为这是项目官方锁定的依赖。另外还有一种情况项目依赖了不受pip管理的私有包或本地包。这些包通常不在PyPI上也没有对应的漏洞记录。扫描工具会跳过它们我的处理方式是把它们单独列出来人工确认它们的安全状态而不是依赖自动工具的判断。6. 扫描工具之外的延伸思考6.1 可以继续扩展的方向基础版扫描工具跑通之后我逐渐意识到它只是依赖安全管理的一小部分。往深了做还有几个方向值得扩展。一个是SBOM生成。SBOM软件物料清单是近几年供应链安全里非常重要的概念简单说就是把项目用到的所有组件、版本、依赖关系列成清单让“项目里有什么”这件事变得透明。pip-audit本身就支持--format sbom输出CycloneDX格式的SBOM这对企业级合规审计很有帮助。另一个是漏洞数据的历史对比。我现在会在每次扫描时把结果存一份到本地数据库按周做对比看看漏洞数量的变化趋势。这能直观看出依赖治理有没有成效修漏洞的速度是否跟得上新漏洞发布的速度。还有一个是我特别想做的漏洞利用可达性分析。现在的扫描器只能告诉你“存在漏洞”但不能告诉你“这个漏洞是否真的有可能被外部触发”。这涉及到调用链分析、入口点识别等复杂逻辑已经不是简单工具能解决的了。目前只能靠人工复核来弥补。6.2 我这几个月用下来的真实体会最后聊几点个人经历吧。第一工具要趁早做但别追求一步到位。我最初的版本只是简单封装了pip-audit的终端输出几个月用下来逐步加了报告、定时任务、通知渠道才变成现在这样。一开始就搞大而全的架构反而容易烂尾。第二漏洞扫描的关键不是“扫出多少”而是“扫出来之后有没有人管”。如果扫出来的漏洞没人负责修那这个工具就只是一个告警制造机只会让人麻木。我建议在接入扫描流程之前先确定好漏洞分类分级标准以及对应的处理责任人。第三别把扫描结果当唯一依据。依赖漏洞扫描器提供的只是“存在风险”的线索最终要不要升级、怎么升级还是要结合项目实际场景、代码调用情况以及运行环境来判断。扫描工具是帮你把地毯翻起来看下面的灰真正决定怎么打扫的还是你自己。一件事能坚持做下去靠的不是工具多厉害而是你给它定义了一个清晰的位置它不负责替你思考只负责帮你把所有该看的东西按期摆到眼前。这套依赖漏洞自动扫描工具在我这里的角色就是这样。