关于 lockvet
lockvet 面向审查锁文件、工作流固定版本、拉取请求或待安装软件包的开发者,用于检查依赖变更和供应链风险。它分析 61 种格式,汇总版本变化、漏洞公告、发布时间、弃用、许可证变化等信号,并内置 MCP 服务。
主要功能
- 01分析 61 种依赖格式
- 02说明直接与传递依赖变化
- 03通过 OSV.dev 检查公告与修复版本
- 04标记版本年龄、弃用和许可证变化
- 05检查拉取请求与依赖队列
- 06比较 SBOM 和软件包
适用场景
- 01合并前审查依赖变更
- 02筛查 Dependabot 或 Renovate 队列
- 03安装前检查软件包
- 04比较容器镜像 SBOM
MCP 相关
内置的 `lockvet mcp` MCP 服务可让客户端在对话中检查拉取请求 URL、本地仓库、文件、软件包和依赖队列。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm -e GITHUB_TOKEN -e GITLAB_TOKEN -e BITBUCKET_TOKEN -e GITEA_TOKEN -e AZURE_DEVOPS_TOKEN ghcr.io/matteo-sung/lockvet:0.6.20GITHUB_TOKEN密钥GitHub token — private repos and higher API rate limits for vet_url/queueGITLAB_TOKEN密钥GitLab token (read_api) — private projects and self-hosted instancesBITBUCKET_TOKEN密钥Bitbucket Cloud access token or app password — private repos, workspace queue scansGITEA_TOKEN密钥Gitea/Forgejo/Codeberg token — private reposAZURE_DEVOPS_TOKEN密钥Azure DevOps PAT (Code: Read) — private projects本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/matteo-sung/lockvet:0.6.20stdio工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
lockvet
在合并前,解释锁文件中的每一处更改。
▶ 在浏览器中试用 — 粘贴 Dependabot/Renovate PR URL,拖入两个锁文件进行差异比较,拖入一个锁文件审计它当前锁定的内容,或在安装前检查某个包 — 无需安装。
报告可通过链接分享:
将任何 PR 审计结果分享为 URL,
或查询某个包
(这个包是冒充拼写错误的 chalk 的恶意软件)。

真实示例:sharkdp/fd 中 jiff 的 dependabot“补丁”升级悄悄新增了 7 个传递依赖 crate,其中一个被 RUSTSEC 标记。
lockvet 能发现这些问题吗? — event-stream、chalk/debug 接管事件、Shai-Hulud 蠕虫(以及它通过 keyv/Cacheable 在2026 年 8 月卷土重来 — 在浏览器中重现)、ultralytics 挖矿程序、strong_password gem 劫持、2021 年依赖混淆攻击、tj-actions 工作流固定版本攻击,以及 Codecov 恶意下载事件;这些案例均基于真实安全公告重现,并附有可复现的测试样例。
锁文件差异难以阅读 — 一次常规的 npm install 就可能重写数千行,而 Dependabot PR 只会告诉你一个包发生变化,锁文件却悄悄改动了四十个。lockvet 会读取实际的锁文件差异,并告诉你究竟发生了什么:
- 升级了什么 — 每个新增 / 移除 / 升级 / 降级的包,按 major / minor / patch 分类,最严重的排在前面
- 为什么会变动 — 每项变更都会标记为
(direct)或via <引入该依赖的依赖项>,因此,40 个包的差异可以归纳为“一个直接升级及其附带变更” - 有哪些风险 — 新版本引入的漏洞、此次升级修复的漏洞,以及同时受影响的安全公告(实时来自 OSV.dev,并对 GHSA/CVE/PYSEC 别名去重)——每条尚未解决的安全公告都会附上修复版本,该版本从公告自身的范围中读取(
· fixed in 4.17.21),因此修复措施与问题出现在同一行 - 有哪些可疑之处 — 每个新引入版本的发布时间有多久,最近 7 天发布的版本会标上 ⏱(大多数被劫持的版本会在几天内被发现,因此设置冷却期是低成本的保险措施)、上游弃用通知,以及 ⚖ 许可证变更——静默地将 MIT 换成 BUSL 或“非标准”许可证的升级会被标记(通过 deps.dev)
- 供应链预警 — 版本在其所属的注册表索引中不存在(这是未发布恶意软件的表现形式)、名称与热门包只差一个字符的新依赖(拼写仿冒包——通过内嵌的热门包列表离线检查)、突然新增 npm 安装脚本的升级,以及静默移除 sigstore 来源证明的较新版本
- 上游实际变更 — 每个新版本都会链接到源代码仓库中精确的标签间差异(
…/compare/v1.2.3...v1.3.0),并根据仓库中真实存在的标签进行验证,确保链接不会返回 404 — 覆盖 npm 的[email protected]单仓库标签、release-please 的name-v1.2.3标签、Go 子模块的dir/v1.2.3标签,甚至 Go 伪版本的提交哈希 - 适用于任何 PR、MR、比较或提交 — 无需克隆 — 使用
lockvet pr owner/repo#123、lockvet mr group/project!123、lockvet compare owner/repo v1...v2,或直接粘贴 GitHub / GitLab / Bitbucket / Gitea / Codeberg / Azure DevOps URL(也包括可自托管的 GitLab、Gitea、Forgejo 和 Azure DevOps Server):直接通过 API 进行检查 - 一次检查整个 Dependabot 队列 —
lockvet queue <org>会将一个仓库、用户或组织的所有未关闭 Dependabot/Renovate PR 分类到一张表中,支持 GitHub、GitLab、Bitbucket、Gitea/Forgejo 或 Azure DevOps:哪些会引入漏洞、哪些是 major 或全新升级,以及哪些看起来只是常规变更 - 也支持 SBOM — 比较两个容器镜像 — 输入两个 CycloneDX 或
SPDX JSON SBOM(
lockvet diff old.cdx.json new.cdx.json,例如由syft生成):一次报告镜像中所有生态系统的变更,包括 npm、 PyPI、Go 以及 Alpine/Debian OS 包,并根据正确的发行分支解析 发行版安全公告(ALPINE-CVE-…、DEBIAN-CVE-…) - 安装之前即可检查 —
lockvet pkg npm:left-pad可检查尚未出现在任何锁文件中的包:安全公告(包括恶意包记录)、发布时间、弃用情况、拼写仿冒嫌疑——可检查注册表中的最新版本,也可检查指定版本 - 可供 AI 助手使用 —
lockvet mcp是内置的 MCP 服务:Claude Code、Cursor 或任何 MCP 客户端都可以在对话中检查 PR URL、本地仓库、两个文件、准备添加的包,或整个 Dependabot 队列 - 一个静态二进制文件,覆盖所有生态系统 — 支持 61 种格式:
npm、pnpm、yarn(classic 和 berry)、bun、Deno、Cargo、uv、poetry、PDM、pipenv、
requirements.txt、pylock.toml(PEP 751)、Go 模块(go.mod+go.sum)、Composer、Bundler、Hex(mix 和 rebar3)、pub/Flutter、 Gradle(构建脚本 —build.gradle/build.gradle.kts— 以及锁文件、版本目录、验证元数据和gradle-wrapper.properties)、Maven POM(pom.xml— 解析属性后确定版本固定项、父项、BOM 导入和插件 — 以及maven-wrapper.properties)、sbt 构建定义(build.sbt、plugins.sbt、project/Dependencies.scala、project/build.properties— Scala 的清单即锁文件)、NuGet、Swift Package Manager、CocoaPods、Conan、vcpkg(清单基线和版本覆盖)、R/renv、 conda/pixi、Julia、Haskell(stack 和 cabal)、Gleam、Terraform/OpenTofu、 Helm、Ansible Galaxy(requirements.yml)、Nix flakes、Zig(build.zig.zon)、Bazel 模块(bzlmod)、GitHub Actions 工作流 和 GitLab CI 配置(include: component:目录固定项、作业image:/services:引用)、 CircleCI 配置(orbs:注册表固定项、Docker 执行器image:引用)、 (uses:固定项)、容器基础镜像(Dockerfile / Containerfile / Composeimage:固定项)、Dev Containers(devcontainer.json中的镜像 和features:OCI 固定项)、Kubernetes 清单和 kustomization (容器镜像固定项、kustomization.yaml中的newTag:/digest:覆盖项、helmCharts:条目、FluxHelmRelease/OCIRepository固定项,以及 Argo CDApplication图表固定项)、 Helm values 文件(values.yaml镜像固定项)、 pre-commit 钩子固定项 (.pre-commit-config.yaml中的rev:)、asdf/mise 工具链固定项 (.tool-versions、mise.toml、mise.lock— 含各平台的校验和 完整性固定项)、单工具版本文件(.nvmrc、.node-version、.python-version、.ruby-version、.go-version、.java-version、.terraform-version、.terragrunt-version)和 SDKMAN 的.sdkmanrc— 以及 CycloneDX 和 SPDX SBOM
🤖 此项目由 AI Agent Matteo Sung 构建和维护, 所有更改均公开发布。欢迎人类提交 Bug 报告和 PR。
示例
$ lockvet HEAD~1 # what did that "upgrade express" commit really do?
package-lock.json (npm)
↑ express 4.17.1 → 5.1.0 MAJOR (direct) (15mo old)
▼ fixes GHSA-rv95-896h-c2vc (moderate) Express.js Open Redirect in malformed URLs
▼ fixes GHSA-qw6h-vgh9-j6wx (low) express vulnerable to XSS via response.redirect()
↑ body-parser 1.19.0 → 2.3.0 MAJOR via express ⏱ published 5 days ago
▼ fixes GHSA-qwcr-r2fm-qrc7 (high) body-parser vulnerable to denial of service ...
↑ path-to-regexp 0.1.7 → 8.4.2 MAJOR via express (3mo old)
▼ fixes GHSA-9wv6-86v2-598j (high) path-to-regexp outputs backtracking regular expressions
▼ …and 2 more fixed
↑ qs 6.7.0 → 6.15.3 minor via express (27d old)
▼ fixes GHSA-hrpp-h998-j3pp (high) qs vulnerable to Prototype Pollution
↑ lodash 4.17.20 → 4.17.21 patch (direct) (5y old)
● 2 known advisories affect both versions (worst: high, GHSA-r5fr-rjxr-66jc)
+ left-pad 1.3.0 (added) (direct) (8y old)
● deprecated upstream: use String.prototype.padStart()
- minimist 1.2.5 (removed) via mkdirp
64 packages changed · 21 major · 9 minor · 4 patch · 23 added · 7 removed
· 3 direct · 61 transitive · vulnerabilities: 0 introduced, 15 fixed, 3 unresolved
· 1 fresh (<7d old) · 1 deprecated
安装
Homebrew(macOS / Linux):
brew install matteo-sung/tap/lockvet
Scoop(Windows):
scoop bucket add matteo-sung https://github.com/matteo-sung/scoop-bucket
scoop install matteo-sung/lockvet
aqua(lockvet 已在标准注册表中):
aqua g -i matteo-sung/lockvet
mise(通过其 aqua 后端):
mise use -g aqua:matteo-sung/lockvet
GitHub CLI 扩展(gh-lockvet —
gh lockvet 会检查你当前所在 PR,并复用你的 gh 身份验证):
gh extension install matteo-sung/gh-lockvet
Debian / Ubuntu(.deb,也支持 .rpm 和 .apk — amd64 和 arm64):
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.20/lockvet_v0.6.20_linux_amd64.deb
sudo dpkg -i lockvet_v0.6.20_linux_amd64.deb
Fedora / RHEL:
sudo rpm -i https://github.com/matteo-sung/lockvet/releases/download/v0.6.20/lockvet_v0.6.20_linux_amd64.rpm
Alpine(软件包未签名 — 而是通过校验和和 Sigstore 证明进行验证,因此如需确保安全,请先验证):
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.20/lockvet_v0.6.20_linux_amd64.apk
apk add --allow-untrusted lockvet_v0.6.20_linux_amd64.apk
Go:
go install github.com/matteo-sung/lockvet@latest
或者从 发布页面 下载预构建二进制文件(Linux / macOS / Windows,amd64 和 arm64):
curl -fsSL https://raw.githubusercontent.com/matteo-sung/lockvet/main/install.sh | sh
Docker(linux/amd64 和 arm64,包含 git — 适用于 CI):
docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/matteo-sung/lockvet:0.6.20 lockvet
Shell 补全和 man 手册
Homebrew 会自动安装 bash/zsh/fish 补全和 man lockvet;
发布压缩包会将它们放在 completions/ 和 man/ 目录下。通过其他方式安装?
二进制文件可以自行输出所有内容:
lockvet completion bash > /etc/bash_completion.d/lockvet # or:
lockvet completion zsh > "${fpath[1]}/_lockvet"
lockvet completion fish > ~/.config/fish/completions/lockvet.fish
lockvet man > /usr/local/share/man/man1/lockvet.1
验证发布版本
lockvet 会标记那些丢失构建来源证明的依赖项,
因此它对自身也采用相同标准:从 v0.4.5 起,每个发布产物 —
每个二进制归档、checksums.txt 和 Docker 镜像 — 都会在构建时获得
Sigstore 公共日志的证明。你可以证明任何下载文件都是由此仓库的发布工作流构建的:
gh attestation verify lockvet_v0.6.20_linux_amd64.tar.gz --owner matteo-sung
gh attestation verify oci://ghcr.io/matteo-sung/lockvet:0.6.20 --owner matteo-sung
每次发布也会将其 Sigstore bundle 作为资源一并提供
(lockvet_<tag>.intoto.jsonl,一个 bundle 涵盖所有产物),因此你可以离线验证:
gh attestation verify <file> --owner matteo-sung --bundle lockvet_<tag>.intoto.jsonl。
checksums.txt 也有证明,且 install.sh 会根据它验证下载内容,因此经过验证的
checksums.txt 可传递地涵盖其中列出的所有内容。
用法
lockvet # working tree vs HEAD — "what did I just do?"
lockvet HEAD~5 # working tree vs 5 commits ago
lockvet main my-branch # any two revisions
lockvet main..my-branch # range syntax works too
lockvet -md # markdown, ready to paste into a PR comment
# (package names link to npmjs/crates.io/PyPI/…)
lockvet -json # machine-readable, full vuln ID lists
lockvet -sarif # SARIF for GitHub Code Scanning — alerts on the
# exact lockfile line (see "In CI" below)
lockvet -offline # no network calls (skips vuln + metadata lookups —
# unless -osv-db gives it a local vuln database)
lockvet -only jiff # one package's story: jiff itself plus everything
# it dragged in (matches names AND via-chains;
# globs ok: -only "@babel/*" or -only "*sys*")
lockvet queue myorg # triage EVERY open Dependabot/Renovate PR
lockvet queue owner/repo # of an org, user, or single repo (see below)
lockvet queue gitlab.com/grp # same for a GitLab group or project,
lockvet queue codeberg.org/o # a Gitea/Forgejo owner or repo, a Bitbucket
# workspace, or an Azure DevOps project
lockvet audit # not a diff: check everything you pin RIGHT NOW
lockvet audit web/ -fail-on vuln,unlisted # (see "Audit" below)
lockvet diff old.cdx.json new.cdx.json # two files on disk, no git — SBOMs
lockvet diff Cargo.lock.orig Cargo.lock # or any two lockfiles (see below)
lockvet -changelogs # pull upstream release notes inline (see below)
lockvet -fresh-days 14 # widen the "recently published" window (default 7)
lockvet -fail-on major,vuln # CI gate: exit 1 on major bumps or new vulns
lockvet -fail-on fresh # CI gate: enforce a release cooldown
在任意 git 仓库中运行。lockvet 会自行找出两个版本之间所有更改过的锁文件 —
无需配置,也无需声明“这是哪个包管理器”。
审查任何 GitHub、GitLab、Bitbucket、Gitea 或 Azure DevOps PR——无需克隆
将 lockvet 指向一个拉取请求,它就会通过 GitHub API 获取每个已更改锁文件的两侧内容:
lockvet pr sharkdp/fd#1723 # owner/repo#number
lockvet https://github.com/npm/cli/pull/9793 # or just paste the URL
这是审查 Dependabot/Renovate PR 的最快方式:无需检出、适用于任何公开仓库,所有标志(-md、-json、-only、-fail-on)均可使用。对于私有仓库或更高的速率限制,它会自动读取 GITHUB_TOKEN、GH_TOKEN,或已登录的 gh CLI。适用于来自复刻仓库的 PR、子目录中的单体仓库锁文件,以及新增、删除或重命名的锁文件。
添加 -comment 后,lockvet 会将报告作为评论发布在 PR 或 MR 本身上——再次运行时会原位更新同一条评论,而不是不断添加新评论:
lockvet pr sharkdp/fd#1723 -comment # needs a token that can
lockvet mr my-group/app!42 -comment # write comments
lockvet pr https://bitbucket.org/ws/repo/pull-requests/7 -comment
GitLab 合并请求的用法也相同——无论是 gitlab.com 还是任何可自托管的实例(主机名直接从 URL 获取):
lockvet mr gitlab-org/gitlab!245360 # group/project!iid
lockvet https://gitlab.com/gitlab-org/gitlab/-/merge_requests/245360
lockvet https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/4232
来自复刻仓库的 MR 和子群组均可使用。对于私有项目,设置后会使用
GITLAB_TOKEN(或在 GitLab CI 中使用 CI_JOB_TOKEN);
公开项目无需身份验证。
Bitbucket Cloud 拉取请求也支持——粘贴 URL 即可:
lockvet https://bitbucket.org/atlassian/aui/pull-requests/5394
来自复刻仓库的 PR 也可使用;设置后,私有仓库会使用 BITBUCKET_TOKEN(访问令牌)或
BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD。
Gitea 和 Forgejo 拉取请求——支持 codeberg.org、gitea.com 或任何可自托管的实例(主机名从 URL 获取)——以及提交 URL:
lockvet https://codeberg.org/forgejo/forgejo/pulls/13594
lockvet https://gitea.com/gitea/tea/pulls/1057
lockvet https://codeberg.org/forgejo/forgejo/commit/714ddd0044f3
来自复刻仓库的 PR 也可使用,-comment 会在 PR 上发布或更新报告
(使用 GITEA_TOKEN、FORGEJO_TOKEN 或 CODEBERG_TOKEN);
公开仓库无需身份验证。
Azure DevOps 拉取请求——支持 dev.azure.com、*.visualstudio.com 或
可自托管的 Azure DevOps Server——粘贴 URL 即可:
lockvet https://dev.azure.com/org/Project/_git/repo/pullrequest/128
公开项目无需身份验证;私有项目会使用 AZURE_DEVOPS_TOKEN(具有 Code: Read 权限的
个人访问令牌),或在 Azure Pipelines 中使用
SYSTEM_ACCESSTOKEN。-comment 会在 PR 上以已关闭的讨论串形式发布或更新报告(需要
Code: Read & Write 权限),因此要求解决评论的分支策略绝不会因报告而受阻。
同样适用于 GitHub、GitLab、Bitbucket、 Gitea/Forgejo 或 Azure DevOps 仓库的任意两个修订版本—— 例如“两个版本之间的依赖项有哪些变化?”——也适用于 单个提交:
lockvet compare sharkdp/fd v10.1.0...v10.2.0 # two releases
lockvet https://github.com/sharkdp/fd/compare/v10.1.0...v10.2.0
lockvet https://github.com/npm/cli/commit/f055ce68 # one commit
lockvet https://gitlab.com/veloren/veloren/-/compare/v0.17.0...v0.18.0
lockvet https://bitbucket.org/atlassian/aui/commits/8c4205a86de7
lockvet https://codeberg.org/forgejo/forgejo/compare/v11.0.0...v11.0.1
lockvet "https://dev.azure.com/org/Proj/_git/repo/branchCompare?baseVersion=GBmain&targetVersion=GBnext"
lockvet https://dev.azure.com/org/Proj/_git/repo/commit/da22be91c073
比较 URL(包括 main...user:branch 这类复刻仓库语法)和提交 URL
会自动识别,因此可以直接从浏览器粘贴。
一次性处理整个 Dependabot 队列
逐个审查机器人 PR 是本末倒置——真正的问题是,这 30 个 PR 中哪些确实需要人工处理。lockvet queue 会检查某个仓库、用户或整个组织的所有未关闭的 Dependabot/Renovate PR,并按风险从高到低排列结果:
lockvet queue mastodon/mastodon # one repo
lockvet queue grafana # a whole org (or user)

每项统计都来自对各 PR 锁文件的实际差异比较(所有 PR 只需一次 OSV / deps.dev 批量请求,因此整个组织的队列只需几秒)。-md 会将表格转换为 Markdown,方便创建每周分类 issue;-json 可供仪表板使用;-only left-pad 可查找涉及某个软件包的 PR;-fail-on vuln 会在任何未关闭的 PR 引入漏洞时以状态码 1 退出。
默认情况下,它会搜索由 app/dependabot 和 app/renovate 创建的 PR;-author my-bot 可覆盖此设置(-author any = 所有未关闭的 PR),而 -limit 100 可提高 PR 数量上限(默认为 30)。如果可用,会使用 GITHUB_TOKEN / gh 身份验证——PR 数量超过约 5 个时,建议使用,以避免触及 API 速率限制。
GitLab 队列也可用——传入群组或项目 URL(gitlab.com 或可自托管实例;也会包含子群组项目):
lockvet queue gitlab.com/gitlab-org/gitlab -author gitlab-dependency-update-bot
lockvet queue https://gitlab.example.com/platform # a whole group
GitLab 机器人的用户名会因实例而异(没有统一的 Renovate 应用用户),因此默认搜索用户名 renovate-bot、dependabot 往往需要使用 -author <your bot's username>。设置了 GITLAB_TOKEN 时会使用该令牌。
也支持 Gitea / Forgejo / Codeberg——传入所有者或仓库 URL(codeberg.org、gitea.com 或可自托管实例;未知主机名会通过一次匿名 API 探测自动检测):
lockvet queue codeberg.org/forgejo -author viceice-bot # a whole org
lockvet queue https://git.example.org/team/app # one repo
这里的机器人用户名也各不相同(Forgejo 自带的 Renovate 以 viceice-bot 身份运行),因此需要传入 -author;也可以使用 -author any 检查所有涉及锁文件的未关闭 PR。设置了 GITEA_TOKEN / FORGEJO_TOKEN / CODEBERG_TOKEN 时会使用相应令牌。
Bitbucket Cloud——传入工作区或仓库 URL:
lockvet queue bitbucket.org/atlassian # a whole workspace
lockvet queue bitbucket.org/atlassian/aui # one repo
Bitbucket 机器人通常以应用用户身份运行,其唯一名称是显示名称,因此作者匹配也会宽松地匹配显示名称——默认值 renovate-bot 可以找到 atlassian-renovate-bot。无法在服务器端按作者搜索时,lockvet 会扫描工作区中最近更新的仓库。这里未经身份验证的速率限制较严格;除快速查看外,请设置 BITBUCKET_TOKEN(或应用密码)。
以及 Azure DevOps——传入项目或仓库 URL:
lockvet queue dev.azure.com/myorg/myproject # a whole project
lockvet queue dev.azure.com/myorg/myproject/_git/api # one repo
Azure DevOps 上的机器人身份也各不相同,因此作者匹配会宽松地匹配显示名称(默认值 renovate 可以找到 "Renovate Bot");也可以传入 -author any。设置了 AZURE_DEVOPS_TOKEN / SYSTEM_ACCESSTOKEN 时会使用相应令牌。
每周分类 issue——此工作流会在仓库中维护一个始终更新的 "Dependency PR triage" issue,每周一刷新(实时示例):
name: dependency triage
on:
schedule: [{cron: '0 8 * * 1'}]
workflow_dispatch:
permissions:
issues: write
pull-requests: read
jobs:
triage:
runs-on: ubuntu-latest
steps:
- run: curl -fsSL https://raw.githubusercontent.com/matteo-sung/lockvet/main/install.sh | sh -s -- -b /usr/local/bin -v v0.6.20
- env: {GITHUB_TOKEN: '${{ github.token }}'}
run: lockvet queue "$GITHUB_REPOSITORY" -md > queue.md
- env: {GH_TOKEN: '${{ github.token }}'}
run: |
title="Dependency PR triage"
n=$(gh issue list -R "$GITHUB_REPOSITORY" --state open --search "in:title \"$title\"" --json number --jq '.[0].number')
if [ -n "$n" ]; then gh issue edit -R "$GITHUB_REPOSITORY" "$n" --body-file queue.md
else gh issue create -R "$GITHUB_REPOSITORY" --title "$title" --body-file queue.md; fi
(在 lockvet queue 命令行中添加 -author any,即可包含非机器人的 PR。)
比较两个 SBOM(或容器镜像)
lockvet diff 会检查磁盘上的两个文件——无需 Git 仓库。传入两个 CycloneDX 或 SPDX JSON SBOM(文件名不限;会根据内容识别格式),它就会说明两者之间发生了哪些变化,并一次性涵盖文档中的所有生态系统:
syft -q alpine:3.18 -o cyclonedx-json > old.cdx.json
syft -q alpine:3.19 -o cyclonedx-json > new.cdx.json
lockvet diff old.cdx.json new.cdx.json
new.cdx.json (SBOM)
↑ ca-certificates-bundle 20241121-r1 → 20250911-r0 MAJOR via apk-tools
↑ zlib 1.2.13-r1 → 1.3.1-r0 minor via apk-tools
↑ busybox 1.36.1-r7 → 1.36.1-r20 patch via alpine-baselayout › busybox-binsh
▼ fixes ALPINE-CVE-2023-42363 A use-after-free vulnerability was discovered in xasprintf…
▼ fixes ALPINE-CVE-2023-42364 A use-after-free vulnerability in BusyBox v.1.36.1 allows…
…
12 packages changed · 1 major · 1 minor · 10 patch · 2 direct · 10 transitive
· vulnerabilities: 0 introduced, 5 fixed, 4 unresolved
软件包按其 purl 匹配:语言生态系统(npm、PyPI、Go、Cargo 等)会进行完整检查——OSV 安全公告、发布日期、经验证的变更日志链接;操作系统软件包则会查询发行版安全公告,并针对正确的发行版本分支(Alpine:v3.18、Debian:12、Wolfi)解析,因此基础镜像升级会明确显示修复或引入了哪些 CVE。版本语义也会遵循对应发行版的规则:apk -r7 → -r20 修订版本、_git 快照后缀、Debian epoch(1:3.10-4)和 ~deb13u1 预发布版本都能正确比较。
它也适用于仓库之外的普通锁文件(lockvet diff Cargo.lock.orig Cargo.lock),而已提交到 Git 的 SBOM(bom.json、*.cdx.json、*.spdx.json)也会像其他锁文件一样被所有其他模式——lockvet、lockvet pr、GitHub Action——识别。
审计当前固定的依赖 — lockvet audit
以上内容都在解释一种变更。lockvet audit 回答的是另一个问题——“我们当前依赖的内容中,是否有已知的不安全项?”——你可能会在供应链攻击的消息传出后、接手一个代码库时,或定期进行安全检查时提出这个问题。
它会遍历目录树(跳过 node_modules、vendor、.git 等目录),读取找到的每个锁文件——包括全部 61 种格式、SBOM、CI 工作流、Dockerfile 和 Kubernetes 清单——并针对当前固定的版本运行完整流程。只显示发现的问题:

这段回放展示了案例研究 7中的“刚传出攻击消息”扫描(其中提供了可复现的测试样例)。对于日常状态良好的代码库,输出也会像你期望的那样简洁:
$ lockvet audit # in sharkdp/fd
Cargo.lock (crates.io · 126 packages)
• anyhow 1.0.102 (direct) (5mo old)
▲ affected by RUSTSEC-2026-0190 Unsoundness in `Error::downcast_mut()` · fixed in 1.0.103
• crossbeam-epoch 0.9.18 via ignore › crossbeam-deque (2y old)
▲ affected by RUSTSEC-2026-0204 Invalid pointer dereference in `fmt::Pointer` impl… · fixed in 0.9.20
• proc-macro-error2 2.0.1 via jiff › … › defmt-macros (23mo old)
▲ affected by RUSTSEC-2026-0173 proc-macro-error2 is unmaintained
audited 126 packages across 1 lockfile · 24 direct, 102 transitive · 3 advisories affecting 3 packages
审计会对每个固定版本标记以下问题:
- 已知公告:影响当前版本的公告(OSV.dev——与差异模式相同,使用按别名去重的信息源,因此也会显示
MAL-*恶意软件包公告); - 版本未出现在其注册表索引中,但该软件包的其他版本均有列出——这表明某个版本已被取消发布或下架(通常是恶意版本)。仍固定 2025 年 9 月
[email protected]内容的锁文件会触发此项以及 MAL 公告; - 已弃用/已撤回/已取消发布/已放弃的固定版本,涵盖所有注册表集成,并提供上游原因和建议的替代版本;
- 仅在几天前发布的版本(⏱ 冷却期,
-fresh-days)。
所有选项都和差异模式一样可组合使用:-md、-json、-only "@babel/*"、-fail-on vuln,unlisted 和 -sarif——因此,定时工作流可以在固定了不安全内容的确切锁文件行上保持 Code Scanning 警报:
# .github/workflows/lockvet-audit.yml — nightly dependency audit
name: lockvet audit
on:
schedule: [{cron: '14 6 * * *'}]
workflow_dispatch:
permissions:
contents: read
security-events: write
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
curl -fsSL https://raw.githubusercontent.com/matteo-sung/lockvet/v0.6.20/install.sh | sh -s -- -b .
./lockvet audit -sarif > audit.sarif || true
- uses: github/codeql-action/upload-sarif@v3
with: {sarif_file: audit.sarif}
基于状态变化的信号(⚙ 新增安装脚本、⛨ 移除来源证明)需要一对前后状态,因此仅在差异模式中提供——审计如实报告当前状态,而非历史变化。
无需安装即可试用:浏览器体验区提供审计锁文件模式——拖入一个或多个锁文件(或 SBOM),即可完全在浏览器中运行相同的审计。
在安装前审查软件包——lockvet pkg
依赖管理中风险最高的时刻,就是对一个从未见过的软件包运行 npm install something。lockvet pkg 会对尚未出现在任何锁文件中的软件包运行同一套流程——就在你做决定时:

你可以获取注册表掌握的所有信息:影响该版本的安全公告(包括 MAL-* 恶意软件包记录)、发布时长(⏱ 全新发布的版本风险更高)、弃用/撤回/撤销状态及上游原因、注册表索引中缺失的版本,以及针对只需改动一个字符就能变成热门软件包名称的 ≈ 拼写抢注嫌疑。
软件包规格格式为 eco:name[@version];如果不指定版本,软件包自己的注册表会告知哪个版本是“最新”:
lockvet pkg npm:left-pad # latest, from the npm registry
lockvet pkg pypi:[email protected] # a specific version
lockvet pkg cargo:serde gem:rails hex:phoenix pub:dio # several at once
lockvet pkg go:github.com/gin-gonic/gin # Go modules
lockvet pkg maven:com.google.guava:guava # Maven group:artifact
lockvet pkg jsr:@std/http terraform:hashicorp/aws pod:Alamofire
lockvet pkg swift:Alamofire/Alamofire # SwiftPM (github.com implied)
lockvet pkg helm:https://charts.bitnami.com/bitnami/postgresql # Helm charts
lockvet pkg ansible:community.general # Ansible Galaxy (collections & roles)
lockvet pkg tool:node tool:terraform # asdf/mise tools, from the tool's own repo tags
lockvet pkg vcpkg:fmt # vcpkg ports, from the registry's versions database
最新版本查询支持 npm、PyPI、crates.io、RubyGems、Packagist、Go、Hex、pub.dev、JSR、NuGet、Maven、CocoaPods、Terraform、CRAN、Hackage、Bazel Central Registry(bazel:<module>)、vcpkg 端口(vcpkg:<port>——microsoft/vcpkg 版本数据库中的最新条目,包含端口版本)、conda(conda:[channel/]name——默认频道为 conda-forge)、Helm chart(helm:<repo-url>/<chart>——根据对应仓库自己的索引解析,并跳过已弃用的版本)、Ansible Galaxy(ansible:namespace.name——优先使用集合,经典角色作为后备)、GitHub Actions(actions:owner/repo)、Swift 软件包(swift:host/owner/repo——最新版本为最高的稳定标签),以及 asdf/mise 工具(tool:<name>——工具自身仓库中最新的稳定标签,并以版本形式返回);其他生态系统(conan:、julia:)需显式指定 @version。这里同样无需安装: 浏览器 playground的 审查软件包模式会在浏览器中运行同样的查询,并生成可分享的 URL——
#pkg=npm:chakl 会查询注册表中 chalk 拼写错误对应的“最新”软件包,并报告该软件包关联的恶意软件记录。(少数注册表完全不响应浏览器请求,包括 RubyGems、Maven、conda 和 Ansible Galaxy;CLI 则支持所有注册表。)
-fail-on vuln,unlisted,typosquat 可像控制 CI 一样控制脚本是否失败,-md/-json 输出也与其他场景相同。
让 AI 助手审核依赖(MCP 服务)
lockvet mcp 会通过标准输入/输出将 lockvet 作为模型上下文协议
服务运行,因此 Claude Code、Claude Desktop、Cursor、VS Code 和其他任何 MCP 客户端都可以在对话过程中审核锁文件变更——“这个 Dependabot PR 合并安全吗?” 会成为助手真正能够回答的问题,依据的是 OSV 数据,而不是凭感觉。
# Claude Code
claude mcp add lockvet -- lockvet mcp
// Claude Desktop, Cursor, and most other clients (mcpServers config):
{ "mcpServers": { "lockvet": { "command": "lockvet", "args": ["mcp"] } } }
使用 Docker,无需安装:
{ "command": "docker", "args": ["run", "-i", "--rm", "ghcr.io/matteo-sung/lockvet:0.6.20", "lockvet", "mcp"] }。
lockvet 也已收录在官方 MCP Registry
中,标识为 io.github.matteo-sung/lockvet,
因此可以浏览 Registry 的客户端能够直接从中添加。
六个只读工具,与 CLI 功能对应:
| 工具 | 功能 |
|---|---|
vet_url | 通过 URL 审核任意 PR/MR、比较范围或提交 — 支持 GitHub、GitLab、Bitbucket、Gitea/Forgejo、Azure DevOps,无需克隆 |
vet_git | 审核本地仓库:工作树与 HEAD 的差异,或任意修订范围 |
vet_files | 审核磁盘上的两个锁文件或 SBOM |
audit | 审核项目当前固定的所有内容 — 安全公告、未列出的版本、弃用情况 |
vet_package | 在安装依赖前审核它(npm:left-pad、pypi:[email protected])— 安全公告、版本时长、弃用情况、疑似 typosquat |
queue | 在一个表格中筛查某个仓库/组织所有尚未关闭的 Dependabot/Renovate PR |
报告以 Markdown 格式返回(需要结构化数据时可使用 format: "json");forge
令牌从环境变量(GITHUB_TOKEN、GITLAB_TOKEN 等)中读取,因此 CLI
可访问的私有仓库也都能正常使用。试试:“筛查 grafana 中尚未关闭的依赖
PR,并告诉我应该先查看哪些。”
在 CI 中(自动审核 Dependabot/Renovate PR)
lockvet 会在任何涉及锁文件的 PR 上发布摘要评论 —
查看真实 PR 示例:
# .github/workflows/lockvet.yml
name: lockvet
on:
pull_request:
paths:
- '**/package-lock.json'
- '**/pnpm-lock.yaml'
- '**/yarn.lock'
- '**/bun.lock'
- '**/Cargo.lock'
- '**/uv.lock'
- '**/poetry.lock'
- '**/pdm.lock'
- '**/requirements.txt'
- '**/go.mod'
- '**/composer.lock'
- '**/Gemfile.lock'
permissions:
pull-requests: write
contents: read
jobs:
lockvet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: matteo-sung/[email protected]
# optional:
# with:
# fail-on: vuln # or "major,vuln,downgrade,fresh,deprecated,unlisted,scripts,provenance,license"
# fresh-days: '7' # cooldown window for the fresh flag
# changelogs: 'true' # inline release notes for every bump
# sarif: 'true' # code scanning alerts (see below)
(不使用 GitHub Actions?lockvet pr <PR-url> -comment -fail-on vuln 也能
完成相同工作 — 获取、生成报告、评论、设置门禁 — 只要 CI 环境中有
GITHUB_TOKEN 即可。)
GitHub Code Scanning(SARIF)
lockvet -sarif 会生成 SARIF 2.1.0,
因此存在漏洞、仍存在漏洞以及已弃用的传入版本都会显示为代码扫描警报 — 精确标注在固定该
软件包的锁文件行上,并附带 OSV 链接和严重性。在 Action 中只需设置一个输入(此外,该作业
还需要 security-events: write):
permissions:
pull-requests: write
contents: read
security-events: write
- uses: matteo-sung/[email protected]
with:
sarif: 'true'
也可以在任何地方独立使用:
$ lockvet -sarif BASE HEAD > lockvet.sarif # also works with pr/mr/compare modes
然后使用 github/codeql-action/upload-sarif 或
gh api repos/<owner>/<repo>/code-scanning/sarifs 上传。
在 GitLab 中,只需一行命令即可审核 MR 并将报告作为 MR 备注发布 — 再次运行时会原位更新备注:
# .gitlab-ci.yml
lockvet:
image: ghcr.io/matteo-sung/lockvet:0.6.20
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: ["**/*lock*", "**/go.mod", "**/requirements.txt"]
script:
- lockvet mr "$CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID" -comment -fail-on vuln
-comment 需要 GITLAB_TOKEN CI/CD 变量(具有 api 范围的项目访问令牌 —
CI_JOB_TOKEN 无法发布备注)。没有令牌时,去掉
-comment:获取公开 MR 无需身份验证,报告仍会输出到
作业日志中。也支持自托管实例 — 主机名取自 URL。
想对比检出内容而不是使用 API?git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" && lockvet "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" 可在本地完成相同操作。
在 Bitbucket 中,同样的一行命令可在 Pipelines 中运行:
# bitbucket-pipelines.yml
pipelines:
pull-requests:
'**':
- step:
name: lockvet
image: ghcr.io/matteo-sung/lockvet:0.6.20
script:
- lockvet pr "https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pull-requests/$BITBUCKET_PR_ID" -comment -fail-on vuln
要使用 -comment,请设置 BITBUCKET_TOKEN 仓库变量(具有 pull request: write 范围的仓库
访问令牌)。没有令牌时,去掉 -comment
即可,报告会输出到流水线日志中。
在 Azure DevOps 中,添加一个由 PR 触发的作业,将报告作为评论发布到 拉取请求上:
# azure-pipelines.yml
jobs:
- job: lockvet
condition: eq(variables['Build.Reason'], 'PullRequest')
pool: { vmImage: ubuntu-latest }
container: ghcr.io/matteo-sung/lockvet:0.6.20
steps:
- checkout: none
- script: >
lockvet pr
"$(System.CollectionUri)$(System.TeamProject)/_git/$(Build.Repository.Name)/pullrequest/$(System.PullRequest.PullRequestId)"
-comment -fail-on vuln
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
使用 -comment 时,构建服务账户需要在仓库中拥有 Contribute to pull requests 权限;
没有此权限时,去掉 -comment,报告会输出到
作业日志中。
Codeberg(或任何使用 Woodpecker CI 的 Gitea/Forgejo)也可以这样配置:
# .woodpecker/lockvet.yaml
when:
- event: pull_request
steps:
- name: lockvet
image: ghcr.io/matteo-sung/lockvet:0.6.20
environment:
GITEA_TOKEN:
from_secret: gitea_token # only needed for -comment
commands:
- lockvet pr "$CI_REPO_URL/pulls/$CI_COMMIT_PULL_REQUEST" -comment -fail-on vuln
作为 pre-commit 钩子
在风险升级提交之前就将其拦截——lockvet 的默认模式(工作树与 HEAD 对比)恰好就是“此提交所做的更改”,并且只有在提交中包含锁文件(或带有 uses: 固定版本的工作流文件)时,钩子才会触发:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/matteo-sung/lockvet
rev: v0.6.20
hooks:
- id: lockvet
# optional: also gate on majors and <7d releases
# args: [-fail-on, "vuln,unlisted,scripts,provenance,typosquat,integrity,registry,major,fresh"]
# optional: skip network lookups for instant commits (still catches
# downgrades, repins, resolution moves, and typosquats)
# args: [-offline]
钩子始终会打印说明,默认情况下仅在最高警示级别时阻止提交——新增漏洞、版本未收录在其注册表索引中、新增安装脚本、来源信息缺失、疑似拼写仿冒,以及完整性/解析篡改——这些信号经过调优,旨在将误报降至接近零。可通过覆盖 args 调整拦截条件(也可清空:args: [] 会让钩子仅提供信息)。除了 pre-commit 本身,不需要其他依赖(钩子通过 Go 构建;如果 pre-commit 尚未安装 Go,会自动下载)。
确认发现项——.lockvetignore
无法静默处理的拦截条件最终只会被关闭。当你已经查看某项发现并接受了它——例如不适用于你的用法的 CVE、计划中的主版本升级,或你打算在下个迭代中迁移掉的弃用包——可在锁文件旁创建 .lockvetignore 文件记录这一决定;此后,该发现不再计入摘要和 -fail-on:
# One rule per line; # comments. Globs (* ?) and case don't matter.
GHSA-35jh-r3h4-6jhm # ReDoS — we never pass user input here
[email protected] # everything about this one version
fresh:aws-sdk-go-v2 # daily releases, cooldown is noise
major:react # the React 19 migration PR
deprecated:crossbeam-channel until=2026-12-31 # revisit after Q4 freeze
规则可以是公告 ID、pkg[@version],或 kind:pkg[@version],其中 kind 可以是 vuln、fresh、deprecated、unlisted、scripts、provenance、integrity、registry、license、major、downgrade。添加 until=YYYY-MM-DD 到期日期可让确认临时生效——到期后规则将不再适用,每次运行都会发出警告,直到删除或延长该行,因此临时搁置不会悄然变成永久忽略。
抑制处理秉持诚实原则:被忽略的发现仍会显示在报告中,并带有暗色的 ○ ignored (.lockvetignore) 标记;在 -json 输出中则显示为 ignored/ignored_vulns,摘要也会说明已确认的发现项数量。该文件会在当前目录中自动查找(对于 lockvet audit,则在被审计的目录树中查找),适用于所有模式,包括 lockvet pr <url> 和 GitHub Action;也可通过 -ignore-file <path> 指定其他位置,或通过 -no-ignore 关闭——因此,CI 可以按策略强制要求“不忽略任何问题”。queue 模式会跨多个仓库运行,且不应用任何忽略文件。
弃用和许可证变更
每个引入的版本都会通过其注册表进行检查(经由 deps.dev):
-
已弃用 — 注册表会标记已弃用的版本;上游给出的原因会内联显示(
● deprecated upstream: use String.prototype.padStart())。 对于 PyPI,这也涵盖了 撤回的版本(附带撤回原因)和 PEP 792 项目状态: 维护者已归档或 PyPI 管理员已隔离的项目(即恶意软件审查状态),每当变更仍固定使用该项目时都会标记。crates.io 的撤回版本也会以同样方式显示,并直接对照稀疏索引核验,因此即使撤回刚发生几分钟,也能被发现。Composer 包也会直接从 Packagist 获取同样的信息:升级到已弃用的包时,会标记维护者建议的替代包(● deprecated upstream: abandoned; use symfony/mailer instead)。NuGet 弃用信息也直接来自注册索引,其中包括 deps.dev 未提供的替代包(● deprecated upstream: legacy; use Azure.Storage.Common instead),作者未列出的版本也会在此处显示。Hex 退役版本(BEAM 生态中针对单个版本的弃用)也会显示在此处,信息直接来自 hex.pm,包含维护者给出的原因和说明(● deprecated upstream: retired: deprecated — Not really maintained, please check out Tesla)。Dart 包的相关信息直接来自 pub.dev:升级到发布者已停止维护的包中的版本时,会标记其指定的替代包(● deprecated upstream: discontinued on pub.dev; replaced by flutter_markdown_plus);升级到已撤回的版本时也会标记,因为dart pub本身会拒绝新解析该版本。JSR 包的相关信息直接来自 jsr.io:升级到已撤回的版本,以及升级到 jsr.io 上已归档的包,都会显示在此处。Go 撤回版本的信息直接来自模块代理:升级到作者已撤回的版本时,会显示模块自身go.mod中的理由注释(● deprecated upstream: retracted: https://github.com/klauspost/compress/issues/1114);即使 deps.dev 尚未重新索引,带有// Deprecated:提示的模块也会被标记。CocoaPods 弃用信息来自 CDN 上 pod 自身的 podspec——pod trunk deprecate会为每个版本重写该文件——其中包括指定的后继包(● deprecated upstream: deprecated on CocoaPods; in favor of FirebaseCrashlytics)。Terraform/OpenTofu provider 的相关信息直接来自相应注册表:注册表发出警告的 provider、从 registry.terraform.io 下架的 provider、被 OpenTofu 注册表屏蔽的 provider,以及 HashiCorp 已归档的 provider,都会被标记并附上替代建议(● deprecated upstream: This provider has been archived. Please use the templatefile function or the Cloudinit provider instead)。Maven 构件的相关信息来自 POM 本身:升级到重定位存根(Java 项目宣布坐标迁移的方式)时,会标记新的坐标和作者的说明(● deprecated upstream: relocated to com.mysql:mysql-connector-j — MySQL Connector/J artifacts moved to reverse-DNS compliant Maven 2+ coordinates.),而 deps.dev 完全不对此建模。R 包的相关信息直接来自 CRAN:升级到 CRAN 已归档的包中的版本时会被标记——该包已从索引移除,因此install.packages()不再能解析它(● deprecated upstream: archived on CRAN (no longer installable from the index))。Haskell 包的相关信息直接来自 Hackage:升级到 Hackage 弃用列表中的包时,会标记维护者建议的替代包(● deprecated upstream: deprecated on Hackage; use crypton, cryptohash-md5 or cryptohash-sha1 instead——即cryptonite的情况);升级到通过 preferred-versions 标记为弃用的单个版本时也会被标记——这相当于 Hackage 的撤回版本,求解器会避开它。Bazel 模块的相关信息直接来自 Bazel Central Registry:升级到已撤回的版本时,会标记注册表给出的原因(● deprecated upstream: version 3.19.0 is yanked from the Bazel Central Registry: CVE-2022-3171——BCR 会因 CVE 和构件损坏而撤回版本);使用--allow_yanked_versions构建的锁文件会在selectedYankedVersions中记录其选用的撤回版本,lockvet 即使离线也能读取。Conda 包的相关信息直接来自 anaconda.org:升级到构件带有broken标签的版本时会被标记——conda-forge 通过该方式移除有问题或恶意的构建,而不将其删除(● deprecated upstream: marked broken on conda-forge (artifacts moved to the broken label));所有构建均已损坏和部分构建已损坏会使用不同的措辞。 -
许可证变更 — 新版本使用的许可证与其替代版本不同:
↑ husky 4.3.8 → 5.0.9 MAJOR (direct) ● license change: MIT → non-standard在开发过程中更改许可证(MIT → BUSL、SSPL、“非标准”许可证等),正是那种没人会在 40 行的锁文件差异中注意到的事情。lockvet 只有在注册表报告了变更前后两侧的许可证时,才会报告许可证变更。JSON 输出会在每项涵盖的变更中附带
old_license/new_license。
这两项也可以作为门禁:-fail-on deprecated,license。
注册表中缺少的版本
当某个新版本未被注册表索引收录,但同一软件包的其他版本已列出时,lockvet 会明确指出:
+ flatmap-stream 0.1.1 (added) via event-stream
▲ not in registry index: 0.1.1 unknown to deps.dev though other
versions are listed — unpublished/deleted release, or published
minutes ago; verify before trusting
这很重要:**当注册表拉取恶意版本时,漏洞就是这样的。**在我们的案例研究中,每个恶意版本——[email protected]、[email protected]、[email protected]、[email protected],以及 2026 年 8 月 keyv/Cacheable 蠕虫重放中涉及的全部六个版本——都在攻击后被撤下,因此,任何仍固定到其中某个版本的锁文件,引用的都是其所属注册表已不再承认的版本。lockvet 无需等待安全公告发布,就能标记这些版本。以下是 2025 年 9 月 chalk + debug npm 劫持事件在两小时实际攻击窗口期间的重放:

为确保判断可靠,这一标记刻意采取保守策略,以下情况不会发出提示:
- 注册表完全不索引的软件包(私有注册表、未覆盖的生态系统)——只有其他版本已列出的软件包才会被标记;
- 工作区成员、git 和路径依赖(锁文件本身表明它们并非来自注册表);
- Go 伪版本和 pnpm 风格的装饰版本字符串。
对于 npm、PyPI、crates.io、RubyGems、Packagist、NuGet、Hex、Pub、JSR、CocoaPods、Go、Maven、Hackage、Bazel 和 conda 软件包,以及 Terraform/OpenTofu 提供程序,此标记会与注册表本身进行二次核验:deps.dev 可能比注册表延迟数天,因此在作出任何判断前,lockvet 会从 registry.npmjs.org / PyPI 的 simple API / crates.io 稀疏索引 / RubyGems 紧凑索引 / Packagist 的 Composer 元数据端点 / NuGet 的注册索引 / hex.pm 和 pub.dev 的软件包 API / jsr.io 的 meta.json(Deno 本身也会依据同一文档进行解析;撤回的版本仍会列在其中,因此缺失确实是信号)/ 分片式 CocoaPods CDN 索引(pod install 解析时使用的同一文件)/ Go 模块代理 / Terraform 注册表的逐版本端点和 OpenTofu 注册表的版本索引 / Maven Central 上逐版本的 POM(若不可用,则回退到 Google 的 Maven 仓库,AndroidX 生态就位于其中)/ Hackage 的逐软件包版本映射(已弃用的版本仍会列在其中,因此缺失确实是信号)/ Bazel Central Registry 中每个模块的 metadata.json(被撤回的版本仍会列在其中——且该注册表是 git 仓库,版本只会新增,绝不会被悄悄删除——因此缺失确实是信号)/ anaconda.org 的逐发布版本端点(渠道会直接拉取恶意上传;将构建标记为损坏会使其继续保留在列表中,因此缺失确实是信号——只有在对软件包文档执行 HEAD 请求并确认软件包本身存在后才会报告),获取软件包的真实版本列表,并清除注册表仍提供的任何版本的标记。最终保留下来的,是注册表本身已不再列出的版本——这个区别影响重大:在 crates.io 上,被撤回的版本仍会留在索引中,而已删除的(恶意)版本则会彻底消失;在 RubyGems 上,撤回会将发布版本从索引中完全移除,因此,升级到被撤回或被管理员删除的 gem 仍会保留此标记(如今重放 2019 年 strong_password 0.0.7 劫持事件仍会触发标记)。这不只是恶意软件的问题:HashiCorp 曾因回归问题从 Terraform 注册表中撤下 AWS 提供程序 5.71.0——GitHub 上的标签仍然存在,但锁文件若固定到 5.71.0,就会因注册表自身返回 404 而收到 ▲ 标记。
NuGet 是唯一将“取消列出”作为原生概念的注册表,而 lockvet 也像 NuGet 一样对其进行区分:某个稳定版本完全不存在于注册索引中——这是管理员删除(恶意)包的特征——会保留 ▲ 标记;而作者只是将版本取消列出(从搜索中隐藏,但仍可还原)的情况,则会归入弃用类别。不存在的预发布版本会被排除,而不是标记:在 NuGet 上,这些版本绝大多数是 CI 源每日构建的版本(例如 Roslyn 夜间构建及类似版本),packages.lock.json 无法将它们归属到真实的软件源。
刚发布几分钟的版本也可能尚未编入索引——此标记是提醒你检查,而不是让你恐慌。使用 -fail-on unlisted 以此作为门控条件;JSON 包含 unlisted / unlisted_versions;SARIF 会发出 unlisted-version 警告;queue 会将受影响的 PR 排在前面。
疑似拼写抢注
所有注册表上最古老的伎俩:发布 lodahs、reqeusts 或 rustdecimal,然后等着别人打错字。lockvet 会标记名称与同一注册表中的热门包仅差一个字符的新依赖项——前提是该版本也比较新(≤ 30 天)或年龄未知:
+ python3-dateutil 2.9.0 (added)
≈ name resembles python-dateutil: a new dependency one edit away
from a popular package, and the release is young — the shape of
a typosquat; make sure this is the package you meant
这个示例对应的是 2019 年真实发生的 PyPI 攻击——它从未获得 OSV 安全公告,因此只有这个标记能发现它。2022 年 crates.io 上的 rustdecimal 攻击和 npm 上的 lodahs 也会触发此标记。
此检查完全在本地进行:热门包列表(npm 的高影响力列表、热门 PyPI 包、crates.io 下载量最高的包、通过 ecosyste.ms 获取的 RubyGems 下载量最高的包,以及 Packagist 的最受欢迎包)都嵌入在二进制文件中,因此在 -offline 模式和浏览器 Playground 中也同样可用。Ruby 部分会重现 2020 年 2 月 RubyGems 攻击活动中的首个示例(用 rspec-mokcs 冒充 rspec-mocks——一次行动就发布了 760 多个恶意 gem)。一如既往,重点在于控制噪声:
- 只检查新加入依赖树的包——升级无法更改包名,而与热门近似包共存多年的名称只是起得不巧,并非攻击(年龄门槛);
- 注册表自身视为同一包的名称组合绝不会触发标记(PyPI 的
-/_/.等价规则、crates.io 对-/_冲突的禁止规则);而 npm/RubyGems/Packagist 中作为分隔符互换、实际属于不同包的情况会触发标记(rack_cache与rack-cache并存时会触发); - 新增的包本身不能位于热门列表中,并且会跳过名称非常短的包。
使用 -fail-on typosquat 以此作为门控条件;在 .lockvetignore 中添加 typosquat:pkgname 行,以确认有意使用的近似名称;JSON 包含 typosquat_of;SARIF 会发出 typosquat-suspect 警告;queue 会将受影响的 PR 排在前面。
GitHub Actions 工作流也是锁文件
每一行 uses: 都固定了一个依赖,Dependabot/Renovate 会像更新其他依赖一样更新这些固定版本。lockvet 会在所有模式下读取工作流文件——.github/workflows/*.yml、复合 action.yml 文件,以及 Gitea/Forgejo 工作流目录:lockvet pr <url>、本地差异、queue、audit。操作版本更新能获得普通差异中看不到的上下文:
-
SHA 固定版本会解析为发行版。 lockvet 会通过匿名 git smart-HTTP 获取操作仓库的标签(每个仓库一次 GET,不调用 API,也没有速率限制),并报告每个提交对应的发行版——Renovate 的摘要更新也因此一目了然:
.github/workflows/build-container-image.yml (GitHub Actions) ↑ actions/checkout df4cb1c (=v6.0.3) → d23441a (=v6.1.0) minor (direct) -
浮动主版本也会解析。
v4会报告为它今天指向的发行版,并根据真实版本对更新幅度进行分类——因此v5 → v7会显示为v5 (=v5.1.0) → v7 (=v7.0.1) MAJOR,并附上已验证的比较链接。 -
正确评估安全公告。 OSV.dev 有一个“GitHub Actions”生态系统,但其 API 无法在服务器端将版本与这些安全公告进行匹配——lockvet 会获取受影响的版本范围,并自行根据已解析的发行版进行评估。即使 GHSA 安全公告从未提及该哈希值,也能发现受影响的 SHA 固定版本;而对于在主版本内已修复的安全公告,也不会错误标记浮动的
v4。 -
▲ 不是发行版。 固定的提交在操作仓库中不匹配任何标签(也不是分支或分支头)。这正是 2025 年 3 月的 tj-actions/changed-files 攻击的情形:仓库中的版本标签被强制移到了一个恶意孤立提交上。现在重放遭攻击的固定版本:
$ lockvet diff old/ci.yml new/ci.yml new/ci.yml (GitHub Actions) ↑ tj-actions/changed-files v44 (=v44.0.0) → 0e58ed8 ? (direct) ▲ not a release: 0e58ed8 pinned ref matches no tag in the action's repository — release tags are how actions ship, and the tj-actions attack pinned exactly like this; verify the commit(
0e58ed8是该攻击中的实际恶意提交。)-fail-on unlisted可在 CI 中对此进行拦截。
分支固定版本(@main)是有意的选择,不会触发提示;托管在 github.com 之外的操作不会被标记为未列出;-changelogs 会显示更新所引入的每个操作发行版的发行说明。
…….pre-commit-config.yaml 也是如此
每个 repos: 条目都会将一个钩子仓库固定到确切的 rev:——pre-commit 会克隆这段代码,并在每位贡献者的机器上于每次提交时运行。pre-commit autoupdate 和 Renovate 会像更新其他依赖一样更新这些固定版本;在任何 git 托管平台上,它们都会像工作流固定版本一样接受处理(名称保留其托管平台):
$ lockvet HEAD~1 # after a `pre-commit autoupdate` commit
.pre-commit-config.yaml (pre-commit)
↑ github.com/astral-sh/ruff-pre-commit v0.8.0 → v0.14.14 minor (direct)
↑ github.com/pre-commit/pre-commit-hooks v4.4.0 → v5.0.0 MAJOR (direct)
SHA rev 会解析为其对应的发行版,更新幅度会根据真实版本进行分类,-changelogs 会获取每个钩子的发行说明;如果某个 rev 在钩子仓库中不匹配任何标签,就会触发 ▲ 不是发行版——这与发现 tj-actions 攻击模式的信号相同,保护的是整个团队在每次提交前都会运行的代码。repo: local 和 repo: meta 条目不受此检查;添加钩子前,可使用 lockvet pkg pre-commit:owner/repo 检查钩子仓库。
……以及 .gitlab-ci.yml
GitLab 流水线会在两个位置固定依赖,lockvet 会读取这两个位置——
.gitlab-ci.yml、后缀命名的变体(backend.gitlab-ci.yml),以及
.gitlab/ 或 .gitlab-ci/ 目录下的 CI 片段:
-
include: component:固定项 — CI/CD Catalog 组件,其配置会在每条流水线中 运行。每个固定项都会根据组件项目的真实标签进行验证,而 GitLab 的浮动形式会解析为它们今天所代表的版本:@2和@2.0(semver 范围简写)以及@~latest都会显示具体版本,因此像@2 → @~latest这样的升级清晰可读,而非晦涩难懂:.gitlab-ci.yml (Docker) ↑ gitlab.com/components/opentofu/full-pipeline 2.0.0 → 2.4.0 minor (direct) ↑ gitlab.com/components/secret-detection/secret-detection 2 (=2.3.0) → ~latest (=2.3.0) (direct)如果固定的版本与组件项目中的任何标签都不匹配,就会触发 ▲ 不是发布版本——这是针对 GitLab 的 tj-actions 攻击模式。比较 链接使用项目的真实标签,而
-changelogs会呈现此次升级所引入的 确切版本对应的组件CHANGELOG.md章节(包括托管在 GitLab 上的仓库)。lockvet pkg component:gitlab.com/components/opentofu/full-pipeline可在引入前验证组件。 -
image:和services:引用 — 每个作业运行所用的容器会接受与 DockerfileFROM固定项相同的注册表验证(摘要与标签、未知标签、Docker Hub 的存续时间)。Renovate 会升级这两类引用;其他工具都不会验证它们。
include: project: + ref: 固定项会作为版本行进行跟踪,但不会声明其可信性:文件从未记录托管该项目的 GitLab 实例,因此 lockvet 不会猜测。从磁盘读取时,以 $CI_SERVER_FQDN 为前缀的组件固定项也不会声明其可信性;但在 mr、compare 和 queue 模式下,URL 本身会指明实例,因此 lockvet 会针对该主机进行解析,并对这些固定项执行完整的标签验证。模板化引用($VARIABLES)和 include: local:/template:/remote: 条目不会固定任何内容。
……以及 .circleci/config.yml
CircleCI 流水线会在两个位置固定依赖,lockvet 会读取这两个位置——
.circleci/config.yml,以及 .circleci/ 目录下的延续配置/片段:
-
orbs:固定项 — 来自 CircleCI orb 注册表的可复用 CI 包,其命令会在 每条流水线中运行。orb 没有 OSV 生态系统,也没有 deps.dev 系统,因此 lockvet 会直接查询注册表:发布版本的存续时间(升级到发布仅数小时的 orb 版本值得暂停检查)、经过验证的比较链接和来自 orb 自身源代码仓库的-changelogs发布说明;此外,由于已发布的 orb 版本不可变,删除版本 需要由 CircleCI 支持团队处理,且仅限安全问题,因此对于完整版本列表中 未列出的固定项,还会进行经过注册表验证的 ▲ 不在注册表索引中 检查。浮动固定项会解析为它们今天获取的版本:.circleci/config.yml (Docker) ↑ circleci/aws-cli 4.1.0 → volatile (=5.4.2) ? (direct) (54d old) ↑ circleci/node 5.1.0 → 7.2.1 MAJOR (direct) (9mo old)dev:*版本按设计可变且会过期,因此不会声明其可信性;内联 orb 定义和 模板化引用也一样。lockvet pkg orb:circleci/node可在添加 orb 前进行验证。 -
docker executor
- image:引用 — 每个作业运行所用的容器会接受与 DockerfileFROM固定项相同的注册表验证(摘要与标签、未知标签、Docker Hub 的存续时间)。machine:executor 镜像是 CircleCI VM 镜像标签,而非 OCI 引用,因此会跳过。
Renovate 的 circleci manager 会升级这两类引用;其他工具都不会验证它们。
……以及 .tool-versions、mise.toml、.nvmrc 等文件
项目运行所用的工具链——node、python、terraform、kubectl——
会固定在 asdf/mise 文件中,mise up 和 Renovate 的 asdf/mise 管理器
会像更新锁文件条目一样更新这些固定版本,但没有任何工具会审核它们。lockvet
会读取这两种格式,并根据工具自身仓库中的标签验证每个固定版本,
遵循各个仓库的标签格式(go1.23.4、ruby 的 v3_4_2、Erlang 的
OTP-27.1.2、jq-1.7.1、kustomize/v5.8.1、
swift-6.1-RELEASE——精选并持续验证约 90 种工具):
.tool-versions (mise/asdf)
↑ erlang 27.0 (=OTP-27.0) → 27.1.2 (=OTP-27.1.2) minor (direct)
↑ golang 1.22.5 (=go1.22.5) → 1.23.4 (=go1.23.4) minor (direct)
↑ ruby 3.3.4 (=v3_3_4) → 3.4.2 (=v3_4_2) minor (direct)
↑ terraform 1.9.2 (=v1.9.2) → 1.9.99 patch (direct)
▲ not a release: 1.9.99 pinned version matches no tag in the tool's repository
这里的 (=tag) 表示已验证的解析结果:对比链接和
-changelogs 发布说明都来自真实标签。格式类似版本号的固定值若匹配不到
任何标签,就会标记为 ▲ 不是发布版本——这与在工作流中
识别 tj-actions 攻击模式的检查相同。mise 的模糊固定版本会解析为它们今天
实际会安装的版本(node = "22" → (=v22.23.2))。
mise 中带后端前缀的工具属于真实的注册表软件包,因此会接受 lockvet 的完整注册表审核——
npm:prettier → npm,cargo:eza → crates.io,
pipx:black → PyPI,gem:、dotnet:、go: 也一样(安全公告、版本
年限、弃用、拼写仿冒);ubi:/aqua:/github:/spm: 工具会根据
指定 GitHub 仓库的标签进行验证;gradle、maven 和
sbt 固定版本则来自 lockvet 已能验证的注册表(Gradle
发行版索引、Maven Central——过时的 sbt 固定版本可能带有真实的 GHSA)。
来自插件的工具(asdf:、vfox:)、system、latest、
lts、ref: 固定版本以及带供应商前缀的 Java 构建版本,都不会虚称
自己经过了验证。lockvet pkg tool:node 可在你切换版本前审核工具发布版本。
同样的处理也适用于单工具版本文件,所有版本管理器都会读取这些文件——
.nvmrc / .node-version(nvm、fnm、nodenv)、
.python-version(pyenv,包括多版本回退)、.ruby-version
(rbenv/rvm——可识别 ruby-3.3.4 这种写法)、.go-version、
.java-version、.terraform-version / .terragrunt-version(tfenv、
tgenv)——以及 SDKMAN 的 .sdkmanrc,其中
gradle=/maven=/sbt= 固定版本来自 lockvet 已能验证的注册表。Renovate 会使用专用管理器更新所有这些文件;没有其他工具会审核这些更新。
而 mise 自己的锁文件 mise.lock 也会接受完整的锁文件审核:除了精确解析出的版本,它还会记录 mise 下载制品在各个平台上的 sha256/blake3 校验和,lockvet
会将它们视为完整性固定值。如果版本不变而校验和发生变化,就属于下载内容遭到投毒的模式,会标记为
‼ 重新固定;新添加的平台永远不会触发标记(按制品范围进行比较,
规则与 Gradle 验证元数据相同):
mise.lock (mise/asdf)
‼ node 20.11.0 (=v20.11.0) REPINNED (version unchanged) (direct)
这种固定版本为何重要,可参见案例研究 11
——其中重现了 Codecov bash 上传器事件,将其模拟为工具链校验和替换,
还包括 Gradle wrapper 变体:lockvet 会将固定的
distributionSha256Sum 与 Gradle 实际发布的校验和交叉核对。
容器基础镜像
Dockerfile 也会固定依赖项——FROM 行和其他版本固定项一样,Renovate 和 Dependabot 会像处理其他版本固定项一样更新它,但却没人像审核其他版本固定项一样审核它。lockvet 会读取 Dockerfile / Containerfile(支持多阶段构建;展开 ARG 默认值;也包含 Dockerfile.alpine 和 dev.Dockerfile 等变体名称)、COPY --from= 镜像、# syntax= BuildKit 指令,以及 Compose 文件中的每个 image:——然后针对镜像注册表本身验证变更,因为整个镜像没有 OSV 生态系统,也没有 deps.dev 系统:
$ lockvet https://github.com/hairyhenderson/dockerfiles/pull/1108
freeciv/Dockerfile (Docker)
↻ debian trixie digest sha256:fac46bff2e02 → sha256:34cd9e9fd437 (direct) ⏱ published 3 days ago
✔ digest verified: the registry serves exactly this digest for the tag today
1 package changed · 1 direct · 0 transitive · 1 fresh (<7d old)
- 会根据标签当前提供的内容检查摘要固定项——完全匹配(索引摘要或任一平台的清单摘要)会显示
✔ digest verified;标签不再提供的固定摘要会归入‼ tag mismatch类别(镜像标签确实会变动——基础镜像重建时会出现这种情况,固定项从未来自该标签时也一样);注册表从未见过的摘要则标为▲未列出。 - 注册表不提供的标签——但镜像仓库本身已知——这就是已删除标签、错误仓库或伪造引用的表现:
▲ not in the registry,可用-fail-on unlisted将其设为拦截条件。 - 同一标签的摘要更新会照常显示为
↻(这只是注册表重新构建了该标签),新固定项也会通过验证——而不是显示为令人警觉的重新固定。 - 版本发布时间和 ⏱ 新鲜标记来自 Docker Hub 上托管镜像的各标签
last_updated信息。
查询仅针对固定的公共注册表允许列表(Docker Hub、ghcr.io、quay.io、mcr.microsoft.com、gcr.io、registry.k8s.io——旧版 k8s.gcr.io 固定项会针对 registry.k8s.io 验证,因为所有请求都会重定向到那里——public.ecr.aws、registry.gitlab.com)。来自任何其他主机的镜像——私有注册表、镜像站、localhost——绝不会被查询:lockvet 不会向其无法识别的主机发送请求。由本地 build: 上下文构建的 Compose 服务、阶段名称引用,以及仍包含未解析 ${VARIABLE} 的引用,都会被跳过,而不会进行猜测。
Dev Containers 也是镜像固定项。 devcontainer.json(以及 .devcontainer.json,包括子文件夹中的变体)指定了编辑器和 Codespaces 实际构建的容器:其中的 "image" 会像 Dockerfile FROM 一样进行验证;"features" 下的每个 Feature——例如 ghcr.io/devcontainers/features/go:1——都是通过同一分发 API 拉取的 OCI 工件,因此会以相同方式针对注册表验证其标签(或 @sha256: 摘要固定项)。Renovate 的 devcontainer 管理器会更新这些引用;没有其他工具会检查它们。系统可以识别 JSONC(VS Code 模板自带注释);基于 compose-/Dockerfile 的配置、本地路径 Feature、tarball URI,以及没有主机名的旧版 Feature ID,在这里确实不会固定任何内容。
Kubernetes 清单与 kustomization
同样的处理也涵盖集群拉取的内容。containers: / initContainers: / ephemeralContainers: 列表下的每个 image: 都是固定项(Deployment、StatefulSet、CronJob——任意嵌套层级均适用);kustomization.yaml 的 images: 转换器(newTag: / digest:——Renovate 和 Flux 镜像自动化会更新这些值)也属于固定项;两者都会接受上述完整的注册表验证。kustomization.yaml 的 helmCharts: 条目会针对图表仓库自身的 index.yaml 进行检查,与 Chart.yaml 依赖项的检查完全相同(包括版本发布时间、已弃用图表,以及受 prune 保护的未列出检查)。
Flux CR 也是固定版本。 spec.chart.spec.version 为精确固定版本的 HelmRelease 会被视为 Helm 包:当 sourceRef 的 HelmRepository 位于同一文件中时,其 URL 也会一并读取,并根据该 Chart 仓库的 index.yaml 验证升级(版本时效、已弃用的 Chart、未列出的版本、-changelogs);在其他文件中定义的 sourceRef,或 OCI、Git 源,仍会生成版本行并进行跳转分类,只是不提供仓库声明(没有 index.yaml,无法进行可信的检查)。现代 chartRef 配置也从另一侧覆盖:带有 spec.url: oci://… 和 spec.ref.tag / spec.ref.digest 的 OCIRepository 正是镜像固定版本,因此 Renovate 对 ref.tag 的升级会采用上文 Dockerfile 的仓库处理方式,包括 ✔/‼ 摘要验证。版本范围(spec.ref.semver、>=1.0.0)不固定任何版本,会被跳过。而在 HelmRelease 的 values: 中,标准 Chart 镜像映射——带有 repository: 和 tag: 子项的 image:(Bitnami 风格的 registry: 和 digest: 也包括在内)——会被读取为镜像固定版本:这正是 Flux 镜像自动化和 Renovate 在 home-ops 仓库中升级的格式,也包括旧版 Flux v1 的 Auto-release 提交。
Argo CD Application CR 也包括在内。 spec.source 中包含 chart: 的 Application 会在 targetRevision: 固定该 Chart 版本,而内联的 repoURL: 就是 Chart 仓库本身——因此会以同样方式根据该仓库的 index.yaml 验证升级(版本时效、已弃用的 Chart、未列出的版本、-changelogs)。多源 spec.sources 列表会逐项处理,ApplicationSet 模板(spec.template.spec.source/.sources)也会以相同方式读取,并将 {{ … }} 参数视为其本来的含义,而非字面固定版本。只有精确版本才会被计入:1.2.*、>=1.0.0、* 或分支名称这样的 targetRevision 会跟踪仓库,不会固定版本;Git 源 Application(使用 path: 而非 chart:)则不会处理。OCI repoURL 会生成版本行,但不提供仓库声明(没有可供检查的 index.yaml)。
Helm values 文件也包括在内。 相同的 Chart 镜像约定——带有 repository: 和 tag: 子项的 image: 映射(Bitnami 风格的 registry: 和 digest: 也包括在内)——会直接从 values 文件中读取:Chart 根目录下的 values.yaml、values-prod.yaml / app.values.yaml 覆盖文件,以及 GitOps 目录下任意命名的 helmfile 或 Argo 覆盖文件。这正是 Renovate 的 helm-values 管理器升级的格式,且没有其他工具会对其进行审核——values 文件名不包含 Chart 版本,Chart 也没有 OSV 生态系统,但其中的镜像固定版本会接受上文完整的仓库处理(摘要验证、未知标签、版本时效)。按约定命名的 values 文件也接受裸标量形式(image: ghcr.io/owner/app:v1.2.3),前提是存在标签或摘要——image: nginx 不会固定任何版本;仅通过目录约定或内容探测发现的文件,则要求使用结构化的 repository:/tag: 格式,这是 Helm 特有的格式。块标量正文(config: |——嵌入式应用配置)是不透明的:其中的 image: 行属于嵌入式应用,绝不属于此文件的固定版本。
由于普通清单没有专用文件名,lockvet 会严格将惯用目录(k8s/、kubernetes/、manifests/、deploy/、overlays/、base/、clusters/、gitops/、apps/、infrastructure/、infra/、flux/、flux-system/、chart/、charts/、argocd/、argo/、argo-cd/、applications/、applicationsets/)下的 *.yaml/*.yml 文件、名为 *.k8s.yaml 的文件,以及惯用基本文件名(deployment.yaml、statefulset.yaml、daemonset.yaml、replicaset.yaml、cronjob.yaml、pod.yaml,以及 Flux 惯例中的 helmrelease.yaml、helm-release.yaml、release.yaml、ocirepository.yaml、helmrepository.yaml 和 Argo CD 惯例中的 application.yaml、applicationset.yaml、appset.yaml)纳入处理范围——严格来说,文档必须声明顶层 apiVersion: 和 kind: 才算符合条件;匹配到的非 Kubernetes YAML 文件会被静默忽略;任何 Helm templates/ 目录下的内容都会排除(其中的镜像引用是模板插值,并非集群实际解析的内容)。仍包含 {{ … }}、$(VAR) 或 ${VAR} 的镜像值会被跳过,而不会进行猜测。
在差异模式(本地 git、pr/mr/compare、queue)下,发现范围会进一步扩大:其他所有已更改的 *.yaml/*.yml 文件都会使用相同的严格条件进行内容嗅探,因此采用任意布局的 GitOps 仓库(default/nzbget/nzbget.yaml)即使不符合任何惯例也能正常工作——已更改文件列表很小,因此过度匹配不会造成额外成本。远程获取时,每个差异最多嗅探 20 个候选文件,以降低大型重构 PR 的开销。目录遍历(lockvet audit)仍使用基于惯例的发现方式:在大型目录树中嗅探所有 YAML 会读取远多于实际发现数量的文件。
完整性与解析变更
锁文件锁定的不只是版本,还包括字节(完整性哈希)以及获取来源(解析 URL)。据我们所知,lockvet 是唯一会读取这些内容的锁文件差异工具:
完整性已变更,但版本未变 ‼ — 注册表绝不会更改已发布的制品。如果差异中 lodash 4.17.21 保持不变,但其 sha512-… 被替换,那么该版本锁定的压缩包已被替换:可能是注册表遭到篡改、镜像被劫持,或锁文件被手动编辑。遇到这些情况都应“停下来查明原因”:
‼ lodash 4.17.21 REPINNED (version unchanged) (direct)
‼ integrity changed: 4.17.21 same version, different content hash —
registries never change a published artifact, so the tarball this
pin expects was replaced; do not trust this without finding out why
哈希会按算法分别比较,因此,锁文件格式升级时从 sha1 切换到 sha512(或 yarn berry 缓存键升级)绝不会触发告警;Python 哈希集合也可能因新增 wheel 而合理扩大——只有两个集合完全不相交时才会触发告警。每种共同算法都会单独判断:相同字节在同一种算法下绝不可能产生不同哈希,因此,即使作为伪装的 sha1 resolved 片段未变,替换 yarn-classic 锁定项的 SRI sha512 仍会触发告警(yarn 在安装时验证的是 SRI 行,而非该片段)——同理,conda 锁文件中即使 sha256 行未变,替换 md5 也会触发告警(conda 锁文件可能包含任一哈希或两者;每种哈希都会按自身标签进行比较)。Hex 的双重校验和在同一种算法下也采用相同处理方式:mix.lock 和 rebar.lock 为每个软件包记录两个 sha256 值,它们对不同字节流计算摘要——压缩包内容(内部的 pkg_hash)和压缩包文件(外部的 pkg_hash_ext)——因此二者会分别按自身范围(contents#sha256:/tarball#sha256:)比较,单边替换也会触发告警,不会被未变更的另一个值掩盖;从旧格式迁移到新增外部校验和的新格式则不会触发告警。唯一合并处理的例外是 Terraform 的 h1/zh 组合:h1 哈希仅适用于构建锁文件时所用的平台,因此因平台变化而重新锁定、替换整个 h1 集合时不会触发告警,而注册表发布的 zh 集合仍会为该版本提供校验。
对于渲染后的版本会截断 Git 提交哈希的固定项(flake 输入的
date.rev[:8]、vcpkg 基线的 sha[:12]),还会将完整
修订版本记录为指针哈希,因此,仅靠穷举才发现的短哈希碰撞无法在版本「未变」的情况下
改写固定项——替换会标记为 ‼ REPINNED。指针哈希标明内容存放在哪里,但不会验证字节,因此不计入完整性移除的统计;仅匹配指针也绝不能证明字节相同:将 vcpkg 注册表换成另一个仓库,即便提交相同,仍会标记(此后每次基线更新都会来自新仓库);而 flake 所有者变更但 narHash 匹配时则不会标记——narHash 会验证实际字节。
完整性验证被移除,版本却未变 ‼ ——曾带有内容哈希的固定项如今变成没有哈希,同样会触发警报。现在已没有任何东西能验证制品;删除哈希——或将其换成任何算法都不认可的格式错误值——正是篡改固定项而不被发现的方式:
‼ http 1.2.0 REPINNED (version unchanged)
‼ integrity removed: 1.2.0 this version carried a content hash before
and carries none now — nothing verifies the artifact anymore;
restore the hash or find out why it vanished
两种合理情况不会触发警报:依赖项在版本不变的情况下切换为 git/path 覆盖项(固定项改为指向提交——注册表哈希理应消失);以及整个文件的哈希同时消失(锁文件由不会写入哈希的工具重新生成——这是配置变更,而非针对单个包的攻击)。
解析结果转向公共注册表 ⇄ ——经典的
依赖混淆
攻击会在公共注册表上发布更高版本的内部包名,解析器便会上当。在锁文件差异中,这一变化一目了然:包解析到的主机从你的私有注册表切换为 registry.npmjs.org / pypi.org / crates.io /
rubygems.org:
↑ acme-metrics 1.2.0 → 99.9.9 MAJOR (direct)
⇄ resolution moved: npm.acme-corp.internal → registry.npmjs.org —
the shape of a dependency-confusion attack; make sure the public
package is really yours
只有从私有注册表转向公共注册表才会触发警报(采用镜像很常见);内容哈希可证明未变的迁移不会触发警报;而五个或更多包在相同两个主机之间迁移时,lockvet 会将其视为注册表迁移(配置变更),不会触发警报。
同一项检查也会读取发布标签固定项:Package.resolved(Swift)和 composer.lock 会记录已发布标签指向的提交,Julia 的 Manifest.toml 则会记录注册表的源代码树哈希——版本号未变但提交发生变化,意味着上游标签已被移动,同样会触发警报。
这两种信号都完全离线地根据两个锁文件计算得出 —
无论使用 -offline、处于隔离网络的 CI,还是在浏览器中的
playground,都可以正常工作。会记录相关数据的格式包括:npm(v1–v3)、pnpm、yarn
(classic + berry)、bun.lock、deno.lock(npm + jsr)、Cargo、poetry、uv、
Pipfile.lock、requirements.txt --hash、pylock.toml(PEP 751 工件
哈希和索引主机 — 也会标记 Python 混淆模式),
Gemfile.lock(解析主机,以及 Bundler ≥ 2.6 的 CHECKSUMS
部分中每个 gem 的 sha256 — 平台 gem 保留自己的哈希),
go.sum(解析为仅包含固定项的账本:版本变更由 go.mod 体现,
不会产生重复行,但同一版本的 h1: 哈希编辑 — 即被投毒的 go.sum 模式,这是被篡改模块通过
go mod verify 检查的唯一方式,也是 GOPRIVATE 模块唯一的检查方式 —
会标记 ‼,SARIF 警报会锚定在被编辑的确切行上),
mix.lock、rebar.lock(pkg_hash/pkg_hash_ext)、Gleam 的 manifest.toml、pubspec.lock(哈希和主机 —
也会标记 Dart 混淆模式)、Podfile.lock(trunk podspec 校验和)、Package.resolved、composer.lock、Julia Manifest.toml、
.terraform.lock.hcl(provider h1:/zh: 哈希)、conda/pixi
锁文件(PyPI wheel 哈希 + channel 主机)、MODULE.bazel.lock(所选模块对应的 registry
source.json 哈希 — 如果同一版本的哈希发生变化,说明 registry 对该发行版的描述在你不知情时被改动 —
还包括 registry 主机,因此模块从私有 registry 移至公共 BCR 时会标记混淆路径;旧式
Bazel 7.0/7.1 锁文件会固定归档文件自身的 SRI 哈希)、build.zig.zon(Zig:根本不存在 registry,因此清单自身的 url + 哈希固定值就是
全部依据 — 同一版本的哈希替换或同一版本的源重新指向,正是被偷渡的编辑会呈现的样子;而将依赖项换成一个
具有新固定值的分支属于常规、可见的操作,不会触发警报),以及 flake.lock(Nix:同一修订版的 narHash 发生变化,说明固定的树被替换 — git
修订版的内容不会变化 — 输入重新指向其他仓库时会标记 ⇄ 路径;如果重新指向的 narHash 能证明内容相同,例如单纯的仓库重命名,则不会触发警报 — 但比较的是完整锁定修订版,而不是显示出来的 8 字符短格式,因此,若相似提交的哈希碰撞导致短修订版相同,会标记为重新固定,而不会以“版本未变”为由掩盖问题)。有两项刻意省略,
因为实际历史证明它们会产生大量误报:NuGet 的
contentHash(NuGet 在 2018 年对仓库重新签名,导致所有较早软件包的哈希都发生变化)和 conda
工件哈希(conda 经常会在新的构建编号下重新构建相同版本)。使用 -fail-on integrity,registry 进行拦截;JSON 包含 integrity_changed /
integrity_changed_versions / old_host / new_host / registry_moved;
SARIF 会生成 integrity-changed(错误)和 registry-moved(警告);
queue 会将受影响的 PR 排到最前面。完整重现 2021 年的
依赖项混淆攻击 — 包括其他 diff 都无法显示的同版本变体 — 请参阅案例研究 9。
与上游标签核验 SwiftPM 固定版本
这一系列检查中,有一项需要联网。Package.resolved 记录了版本、该版本标签解析到的提交,以及这些信息所属的仓库——因此可以对照事实来源核验固定版本。lockvet 会获取仓库实际的标签列表(每个仓库发出一次匿名 git smart-HTTP 请求,使用的通道与 git fetch 相同——不调用 API,也没有速率限制),并针对每个新增固定版本检查两件事:
- 该版本的标签仍然存在。版本固定只会解析自标签,因此,如果上游没有与固定版本匹配的标签,就意味着有人在解析后删除或重命名了标签 → ▲ 并非正式发布版本;
- 固定的提交是否为该标签当前指向的提交。发布标签理应不可变——如果不匹配,意味着标签自解析后被重新指向(tj-actions 攻击就是以这种方式发起的),或者有人编辑了锁文件,在显示看似无害的版本号时改为获取其他提交 → ‼ 标签不匹配:
↑ github.com/Alamofire/Alamofire 5.9.0 → 5.9.1 patch
‼ tag mismatch: 5.9.1 pinned at deadbeefdead, upstream tag 5.9.1 is
at f455c2975872 — released tags are immutable; either the tag has
been moved since this was resolved, or the lockfile was edited;
verify the commit before trusting it
同样的标签列表核验也适用于 工作流中的 uses: 固定版本。带注释的标签会解析为其对应的提交;带 v 前缀的标签可匹配不带前缀的版本;无法匿名获取的仓库(私有或已迁移)不会产生任何判断。使用 -fail-on integrity(标签不匹配)和 -fail-on unlisted(标签已删除)设置门禁;SARIF 会输出 tag-mismatch(错误);lockvet audit 会核验你当前持有的所有 Swift 固定版本;而 lockvet pkg swift:Alamofire/Alamofire 会在你添加 Swift 包之前对其进行检查(latest = 最高稳定标签)。
升级时新增的安装脚本
npm 包可以在安装时运行任意代码(preinstall / install / postinstall)。大多数包从不这样做——因此,看起来只是例行操作的升级如果突然新增了安装脚本,就值得在合并前仔细检查。Shai-Hulud 蠕虫——以及它在 2026 年 8 月对 keyv/Cacheable 的回归——和许多规模较小的 npm 攻击,都是通过获取安装时的执行能力来投放恶意载荷的。
lockvet 会向 npm 注册表查询哪些版本会运行安装脚本,并标记这种变化:
↑ core-js 2.6.5 → 2.6.6 patch (direct)
⚙ install scripts added: 2.6.6 the old version ran no install scripts,
this one does — a favourite payload vehicle for hijacked npm
packages; review before trusting
只标记变化,因此不会产生太多无关提示:全新依赖包含安装脚本很常见(用于原生构建),而某个包一直都有安装脚本也不会带来新信息。在包含 92 项变更的 npm/cli 发布差异和包含 165 项变更的 React 差异中,它都没有标记任何内容。
使用 -fail-on scripts 设置门禁;JSON 会包含 install_scripts_added / scripted_versions;SARIF 会输出 install-scripts-added 警告;queue 会将受影响的 PR 排到最前面。目前仅支持 npm(其他生态系统要么没有安装钩子,要么不会在注册表元数据中公开这些信息)。
升级导致来源证明缺失
越来越多的 npm 包会发布带有 sigstore 来源证明 attestation 的版本,PyPI 项目会发布带有 PEP 740 attestation 的版本,crates.io crate 会通过可信发布发布,RubyGems 版本则带有 sigstore attestation ——这些是加密证明(或者对于 crates.io,是由注册表记录的保证),表明该版本由项目自己的 CI 从其公开仓库构建并发布。这种证明对审查者有一项实用价值:被盗的发布令牌可以上传一个版本,却无法让项目的流水线为其出具证明。
因此,当一个始终提供来源证明的包突然发布了一个没有来源证明的版本时,lockvet 会将其标记出来:
↑ acme 1.4.2 → 1.4.3 patch (direct) (2d old)
⛨ provenance dropped: 1.4.3 every previous version was published with
sigstore provenance, this one wasn't — legitimate CI keeps
attesting, a stolen publish token can't; verify the release
有三个条件让它保持低误报:即将发布的版本必须带有来源证明;该包提供来源证明的做法必须已经确立(即将发布版本之前的所有稳定版本都带有证明——偶尔采用一次的不算);并且即将发布的版本必须是近期发布(≤ 30 天)——这是一个在公告发布前、用于及时发现问题的触发器,而不是历史审计。按照这些规则,目前约 100 个热门 npm 包的升级中没有任何标记;排名前 1 000 的 PyPI 包中只有一个版本升级被标记——这是一个发布于 7 天前、打破了项目连续来源证明记录的真实版本(几乎可以肯定是无害的手动发布,而这正是该标记旨在提示“值得查看”的情况);排名前 100 的 crate 中则为零,其中十分之一已经通过可信流水线发布(cc、time、getrandom,……)。如果令牌窃取者在项目 CI 之外发布版本,该检查会立即触发。
使用 -fail-on provenance 将其设为门禁;JSON 会包含 provenance_dropped / unattested_versions;SARIF 会发出 provenance-dropped 警告;queue 会将受影响的 PR 排在最前面。数据来自与安装脚本和未列出检查相同的注册表文档——无需额外请求。支持 npm、PyPI、crates.io 和 RubyGems(对于 PyPI,只有当发布版本中的每个文件都带有来源证明时,该版本才算有证明;只有完全没有带证明文件的版本才会被标记——混合上传属于发布设置问题,并非风险信号;对于 crates.io,每个版本的注册表 trustpub_data 是判定依据;对于 RubyGems,则使用每个版本的 attestation API——目前其采用率仍然较低,因此这些门禁暂时不会触发;随着 gem 开始提供来源证明,保护会自动启用)。
内联发布说明(-changelogs)
每次版本升级都会附上已验证的上游标签间差异链接。添加
-changelogs 后,lockvet 还会获取发布说明,包括多版本升级中跳过的版本:
↑ body-parser 1.19.0 → 1.20.1 minor via express (3y old)
▤ 1.20.1
* deps: [email protected]
* perf: remove unnecessary object clone
▤ 1.20.0
* Fix internal error when inflated body exceeds limit
* Prevent loss of async hooks context
…
▤ 1.19.2
▤ 1.19.1
因此,1.19.0 → 1.20.1 会显示 1.19.1、1.19.2、1.20.0 和
1.20.1 中的更新(每个包最多显示 5 个版本;其余内容可通过比较链接查看)。
在 -md 模式下,每个包的说明都会折叠在 <details> 块中——
在 PR 评论里看起来就像 Dependabot 的发布说明部分,但会涵盖差异中的所有包,包括传递依赖。适用于所有模式(pr、mr、compare、diff,以及通过
changelogs: 'true' 使用的 Action、通过 "changelogs": true 使用的 MCP)。
说明来自(已验证的)源代码仓库中的 GitHub Releases;如果项目不发布版本说明(Phoenix 于 2020 年停止发布;许多 crate 和 gem 从未发布过;托管在 GitLab、Codeberg 和 Bitbucket 上的项目也没有),lockvet 会改为读取仓库的变更日志文件:
在已验证标签处获取 CHANGELOG.md / CHANGES.md / NEWS.md / HISTORY.md,并截取升级涉及的确切版本章节。
无论来源如何,摘录都会经过截断处理和控制字符清理。发布版本说明的路径使用 GitHub API——匿名访问可用于少量包;使用 GITHUB_TOKEN / 已登录的 gh 可提高限额;变更日志文件则通过代码托管平台的原始文件端点获取,不受速率限制。
支持的锁文件
| 生态系统 | 文件 |
|---|---|
| JavaScript | package-lock.json、npm-shrinkwrap.json、pnpm-lock.yaml、yarn.lock(v1 和 berry)、bun.lock、deno.lock — deno.lock 中的 JSR 包直接从 jsr.io 获取发布时间、撤回/归档标记,以及经注册表验证的未列出检查 |
| Rust | Cargo.lock |
| Python | uv.lock、poetry.lock、pdm.lock、Pipfile.lock、requirements.txt(== 固定版本)、pylock.toml + 命名为 pylock.*.toml 的文件(PEP 751 — 标准化锁文件,由 uv、pip、pipenv 和 pdm 写入;vcs/目录/路径来源不检查注册表) |
| Go | go.mod — replace 指令解析为实际生效的固定版本:replace m => m v1.2.3 会覆盖所需版本(replace 指令降级会显示为降级,安全公告等也照常显示);replace a => b v1.2.3 会显示为已移除 + 新引入(即解析劫持模式);文件系统替换(=> ../local)不检查注册表声明 — go.sum 则作为仅记录固定版本的账本:版本变化仍由 go.mod 说明,但同一版本的 h1: 哈希编辑(即 go.sum 中毒模式:篡改后的模块只有在编辑 go.sum 使其匹配后才能安装,而且对于 GOPRIVATE 模块,校验和数据库永远不会提出异议)会标记为 ‼ |
| PHP | composer.lock |
| Ruby | Gemfile.lock(包括 Appraisal 的 *.gemfile.lock 变体;Bundler ≥ 2.6 的 CHECKSUMS 哈希会成为完整性固定值) |
| Elixir | mix.lock — 直接从 hex.pm 获取发布时间、弃用状态和未列出检查;重命名的分支({:hex, :ts_chatterbox, …})会按其真实 Hex 包名解析 |
| Erlang | rebar.lock — Hex 安全公告、发布时间/弃用状态/未列出信息直接来自 hex.pm;从锁文件自身的层级编号获取直接依赖;pkg_hash/pkg_hash_ext 校验和分别作为各自范围内的完整性固定值 — 即使另一个哈希保持不变,同一版本的任一哈希变更都会显示为 ‼ 已重新固定;重命名的分支({<<"uuid">>,{pkg,<<"uuid_erl">>,…}})会按其真实 Hex 包名解析;git/路径依赖不检查 |
| Dart / Flutter | pubspec.lock — 发布时间、已停用/已撤回标记以及未列出检查直接来自 pub.dev(git/路径/SDK/私有主机包不检查) |
| Java / JVM | pom.xml(Maven 没有 npm 风格的锁文件 — POM 版本本身就是固定版本,Dependabot 会直接修改这些版本:解析 ${property} 的版本、<parent> 升级(如 spring-boot-starter-parent)、BOM 导入、插件和配置文件;${project.version} 兄弟项如实不作声明)、build.gradle / build.gradle.kts(以及 settings.gradle(.kts) 和任何 *.gradle(.kts) 脚本 — 对大多数 Gradle 项目而言,构建脚本本身就是固定版本文件:坐标字面值、Groovy map / Kotlin 命名参数形式,以及解析为 Plugin Portal 标记的 plugins { id(…) version … } 块;$version 插值会根据同一文件中的 ext/val 赋值解析,而文件以外定义的内容如实不作声明)、gradle.lockfile、libs.versions.toml(Gradle 版本目录 — Renovate 和 Dependabot 实际修改的文件;插件会作为 id:id.gradle.plugin 标记在 Gradle Plugin Portal 中解析)verification-metadata.xml(Gradle 依赖验证 — 无人审查的满屏 XML 差异会转化为包变更,同一版本的校验和变更会显示为 ‼ 已重新固定),以及构建工具包装器本身 — gradle-wrapper.properties 中的 distributionUrl 固定值会对照 services.gradle.org 上 Gradle 自身的版本索引进行验证(发布时间、已撤回且存在问题的版本、索引中从未出现的版本,以及与 Gradle 实际发布的校验和交叉核对的固定 distributionSha256Sum:包装器会直接使用已中毒的校验和验证已中毒的发行包,因此与任何官方校验和都不匹配的固定值会列入 ‼ 类别);.mvn/wrapper/maven-wrapper.properties 中的固定值会解析为普通 Maven 坐标并接受完整 Maven 检查(两者中的企业镜像主机均如实不作声明)— 升级到重定位存根(mysql:mysql-connector-java → com.mysql:mysql-connector-j)会列入弃用类别,未列出检查会对照 Maven Central、Google 的 Maven 仓库和 Plugin Portal 验证 |
| Scala | build.sbt(以及任何 *.sbt,还有 project/Dependencies.scala 惯例 — Scala 没有 npm 风格的锁文件,构建定义本身就是固定版本文件,scala-steward 会直接修改坐标):"org" %% "artifact" % "1.2.3" 会根据同一文件中的 scalaVersion 解析 Scala 二进制后缀,因此注册表工件(cats-core_2.13)会获取 OSV 安全公告、发布时间和未列出检查;版本 val 会在文件内解析;addSbtPlugin(…) 固定值会使用 sbt 交叉后缀解析(sbt-scalafmt_2.12_1.0 — Central 实际提供的名称);CrossVersion.full / for3Use2_13 会映射到其文档所述的后缀;仅凭文件无法确定的内容(看不到 scalaVersion、%%% 平台工件)会保留版本行,但如实不作声明。project/build.properties 固定 sbt 本身 — 即 Central 上的 org.scala-sbt:sbt,并提供实际安全公告(试试 lockvet pkg maven:org.scala-sbt:[email protected]) |
| .NET | packages.lock.json |
| Swift | Package.resolved |
| C / C++ | conan.lock(Conan 2 扁平锁文件和 Conan 1 图锁文件)— 发布时间直接来自 ConanCenter,按各版本最早的配方修订日期计算,因此重新导出不会让旧版本看起来像刚发布(带 user/channel 的引用不检查;引用不记录其远程来源,因此不作未列出声明)。每个引用中的 #recipe-revision 都会作为固定值跟踪:版本不变但修订版发生变化 — 即配方(构建脚本)在你不知情时发生变更 — 会显示为中性的 ↻ recipe revision 行,类似容器镜像同标签的摘要升级:以前无声无息的变化现在可见,但不会触发警报,因为 ConanCenter 经常会重新导出旧版本的配方 |
| vcpkg | vcpkg.json + vcpkg-configuration.json — builtin-baseline 提交就是锁文件(每个依赖都会解析到注册表在该提交中记录的版本):会在注册表仓库中验证基线是否存在(只能在分支中访问的基线属于注册表中毒模式),并以该提交自身的日期作为发布时间,同时提供修订版本对比链接;overrides 版本固定值会对照 microsoft/vcpkg 的只增不减版本数据库检查;vcpkg-configuration.json 中的注册表基线也会接受同样的处理,替换 default-registry 会显示为解析目标变更(overlay-ports 或自定义注册表中的覆盖项如实不作声明) |
| Bazel | MODULE.bazel.lock(包括现代注册表哈希格式和 Bazel 7.0/7.1 依赖图格式),以及 MODULE.bazel 本身(bazel_dep 固定精确版本,更新机器人会像 go.mod 一样直接修改版本)— 直接从 Bazel Central Registry 获取已撤回版本及其原因、源代码仓库变更日志链接和经注册表验证的未列出检查(私有注册表中的模块以及 git_override/local_path_override 不检查) |
| iOS / CocoaPods | Podfile.lock — 发布日期、已弃用 Pod 标记、许可证变更和未列出检查直接来自 CocoaPods CDN 和 trunk 注册表(git/路径 Pod 和私有 specs 仓库不检查) |
| R | renv.lock — CRAN + Bioconductor 安全公告(RSEC-…);发布年龄、已归档包标记、许可证变更、变更日志链接,以及直接来自 CRAN 本身且可避免镜像延迟问题的未列出检查(GitHub/GitLab/本地/git 安装不检查) |
| Julia | Manifest.toml(也包括 Manifest-v1.11.toml 这种格式)— General 注册表安全公告(JLSEC-…),以及来自清单依赖图的传递依赖链 |
| Haskell | stack.yaml.lock、cabal.project.freeze、cabal.config(Stackage 固定版本)— Hackage 安全公告(HSEC-…);发布年龄、已弃用包(包含维护者建议的替代项)和已弃用版本;经验证的变更日志链接,以及直接从 Hackage 获取的经注册表验证的未列出检查 |
| Gleam | manifest.toml — Hex 安全公告、传递依赖链、来自 [requirements] 的直接依赖;发布时间/弃用状态/未列出信息来自 hex.pm(git/路径包不检查) |
| Conda | pixi.lock(v4–v7)、conda-lock.yml(也包括 *.pixi.lock、*.conda-lock.yml)— 发布年龄、存在问题的版本标记、许可证变更,以及直接来自 anaconda.org、经注册表验证的未列出检查(锁文件解析所用的任何频道 — conda-forge、bioconda 等);其中的 pip 包会获取完整的 PyPI 漏洞/年龄数据。Artifact sha256 哈希会作为限定到工件文件名范围内的完整性固定值读取(openssl-3.3.1-h4bc722e_2.conda#sha256:…),因此同一工件的哈希变更会标记为 ‼ 已重新固定,而 conda-forge 在新构建编号下常规发布的同版本重建则不会触发提示 — 本地构建的来源(如 - pypi: ./rerun_py 这样的路径安装、git 检出)完全不记录完整性信息,因为其哈希每次重建都会合理地发生变化 |
| Terraform / OpenTofu | .terraform.lock.hcl(也包括带后缀名称的 *.terraform.lock.hcl)— 发布年龄、已归档/已移除/已屏蔽 Provider 标记、经验证的变更日志链接,以及直接来自 Terraform 和 OpenTofu 注册表、经注册表验证的未列出检查(自定义注册表主机不检查) |
| Helm | Chart.lock、Chart.yaml(大多数仓库只提交清单 — 读取精确版本固定值,跳过范围约束)、requirements.yaml(Helm v2,通过内容识别并与 Ansible Galaxy 文件区分)、requirements.lock(Helm v2;rye 的 pip 风格 requirements.lock 会自动识别并按 PyPI 处理)— 发布年龄、已弃用 Chart 标记、经验证的变更日志链接,以及直接来自各 Chart 仓库自身 index.yaml 的防止误报的未列出检查;这是 helm dependency update 实际解析所依据的文档(oci:// 和 file:// 引用不检查) |
| Ansible | requirements.yml / requirements.yaml — Galaxy 集合(namespace.name)和传统角色,仅检查精确版本固定值(跳过范围约束);发布年龄、已弃用集合标记、经验证的变更日志链接,以及直接来自 galaxy.ansible.com 的经注册表验证的未列出检查(包括 v3 集合索引本身 ansible-galaxy 所解析的索引,以及传统 v1 角色索引);git/file/url 条目和私有 Automation Hub 来源不检查;角色永远不会产生缺失声明(其索引只有在所有者重新导入时才会更新) |
| Nix | flake.lock |
| Zig | build.zig.zon — Zig 除此之外没有锁文件,也没有注册表:每个依赖都固定了源 URL + 内容哈希,lockvet 会读取两者(从 rev/tag/hash 派生的版本、‼ 同版本哈希替换、⇄ 同版本源地址重定向、经验证的标签对比链接 + 依赖自身仓库的发布说明) |
| CI / GitHub Actions | .github/workflows/*.yml、action.yml/action.yaml(复合操作)、.gitea/workflows、.forgejo/workflows — 每个 uses: 固定值;SHA 和浮动标签固定值会根据操作仓库的真实标签解析,OSV 的 GitHub Actions 生态系统安全公告在客户端评估 |
| CI / GitLab | .gitlab-ci.yml(也包括后缀命名的变体,如 backend.gitlab-ci.yml,以及 .gitlab/ / .gitlab-ci/ 下的片段)— 根据组件项目的真实标签验证 include: component: CI/CD Catalog 固定值(将浮动的 @2 / @2.0 / @~latest 形式解析为当前实际获取的版本);以 Dockerfile 镜像的相同方式验证作业/默认项 image: 和 services: 引用;跟踪 include: project: + ref: 固定值,但不作声明 |
| CI / CircleCI | .circleci/config.yml(也包括续接配置和 .circleci/ 下的片段)— 每个 orbs: 注册表固定值:发布年龄、将浮动的 volatile / 5 / 5.1 形式解析为当前实际获取的版本、经注册表验证的未列出检测,以及直接来自 CircleCI orb 注册表的变更日志链接(dev: 版本和内联 orb 不检查);以 Dockerfile 镜像的相同方式验证 Docker 执行器的 - image: 引用(跳过 machine: VM 镜像标签) |
| 容器 | Dockerfile / Containerfile(也包括 Dockerfile.alpine、dev.Dockerfile 这样的变体名称)和 Compose 文件(docker-compose.yml、compose.yaml、覆盖文件)— 每个 FROM 基础镜像(识别多阶段构建:跳过阶段引用、展开 ARG 默认值、忽略 --platform 标志)、COPY --from= 镜像、Renovate 会升级的 # syntax= BuildKit 指令,以及 Compose services: 下的每个 image:(跳过从本地 build: 上下文构建的服务)。标签和摘要固定值会对照镜像注册表本身验证 — 请参阅容器基础镜像 |
| 开发容器 | devcontainer.json / .devcontainer.json(包括子文件夹中的变体,支持 JSONC)— "image" 固定值,以及 "features" 下所有通过 OCI 引用的 Feature(ghcr.io/devcontainers/features/…:tag 或 @sha256: 摘要固定值);像 Dockerfile 镜像一样对照注册表验证;跳过本地路径/压缩包/不含主机名的旧式 Feature ID |
| Kubernetes | 清单(常规目录下的 *.yaml — k8s/、kubernetes/、manifests/、deploy/、overlays/、base/、clusters/、gitops/、apps/、infrastructure/、flux-system/、chart(s)/ 等 — 以及 *.k8s.yaml 和常规基名,如 deployment.yaml、helmrelease.yaml、ocirepository.yaml — 必须有顶层 apiVersion: + kind:,排除 Helm templates/):以 Dockerfile 镜像的相同方式验证 containers: / initContainers: / ephemeralContainers 下的每个 image:。Flux HelmRelease Chart 版本固定值(根据同一文件中的 HelmRepository URL 对照 Chart 仓库的 index.yaml 验证)和 OCIRepository ref.tag/ref.digest 固定值(按镜像方式验证)。Argo CD Application / ApplicationSet Chart 来源(chart: + 精确的 targetRevision:,支持单源和多源;根据内嵌 repoURL 的 index.yaml 验证;常规基名 application.yaml、applicationset.yaml、appset.yaml 和目录 argocd/、argo/、applications/ 也会匹配)。kustomization.yaml / kustomization.yml:images: 转换器固定值(newTag:、digest:),以及根据 Chart 仓库自身的 index.yaml 检查的 helmCharts: 条目。Helm values 文件(values.yaml、values-*.yaml、*-values.yaml、*.values.yaml — 以及任何带有相应结构形式、通过内容识别的 YAML):带有 repository:/tag:(+ registry:/digest:)子项的 image: 映射,以及惯例命名文件中带标签的 image: 标量;不透明的块标量内容会跳过,模板化的值也会跳过 — 请参阅 Kubernetes 清单与 kustomization |
| pre-commit | .pre-commit-config.yaml — 每个 repos: 条目的 rev: 固定值,支持任何 git 托管平台(保留其主机名);像工作流固定值一样,针对钩子仓库的真实标签验证 rev:SHA 固定值会解析到其对应的发行版,rev 与任何标签都不匹配时标记为 ▲,升级会提供经验证的对比链接 + 发布说明(repo: local/meta 不检查) |
| asdf / mise | .tool-versions、mise.toml(也包括 .mise.toml、mise.local.toml、mise/config.toml 这些位置)— 通过精选的约 90 种工具映射,根据工具自身仓库的标签验证每个工具链固定值;该映射支持各仓库的标签格式(go1.23.4、v3_4_2、OTP-27.1.2、REL_18_4、swift-6.1-RELEASE 等):经验证的 (=tag) 解析结果附带对比链接和发布说明,模糊固定值(node = "22")会解析为当前实际安装的版本,具体固定值与任何标签都不匹配时标记为 ▲。mise 后端工具属于真实注册表包,会接受完整检查(npm: → npm、cargo: → crates.io、pipx:/pip: → PyPI、gem:、dotnet:、go:);ubi:/aqua:/github:/spm: 会根据指定的 GitHub 仓库验证;gradle/maven/sbt 使用其现有注册表;asdf:/vfox: 插件、符号选择器(latest、lts、system、ref:)和带供应商前缀的 Java 构建如实不作声明。单工具版本文件也会接受相同处理:.nvmrc / .node-version、.python-version(包括多版本 pyenv 回退项)、.ruby-version(支持 rvm 的 ruby-3.3.4 拼写)、.go-version、.java-version、.terraform-version / .terragrunt-version,以及 SDKMAN 的 .sdkmanrc(验证 gradle=/maven=/sbt=/kotlin= 固定值;带供应商后缀的 Java 构建不作声明)。mise.lock 会添加按平台记录的 sha256/blake3 校验和,并将其作为限定到工件范围内的完整性固定值读取 — 同一版本的校验和变更会标记为 ‼ 已重新固定,新增平台不会触发提示 |
| SBOM | CycloneDX 和 SPDX JSON:bom.json、sbom.json、*.cdx.json、*.spdx.json — 支持多种生态系统,包括 Alpine/Debian/Wolfi OS 包 |
注:当锁定文件记录了依赖关系图时,会显示 direct/via … 来源标签:npm、pnpm、yarn、Cargo、uv、poetry、Composer、Bundler、renv、pixi/conda-lock、Julia manifests、Gleam、CocoaPods 和 Go modules(go.mod 的 // indirect 标记可区分直接/传递依赖,但不提供依赖链)。仅固定扁平版本的格式(requirements.txt、mix.lock、Gradle、……)会跳过该标签。 | |
Deno 的 jsr: 软件包、CocoaPods、conda channels、Terraform providers、Helm charts、Bazel modules、Nix flakes、container base images 和 pre-commit hook pins 尚无 OSV.dev 生态系统,因此这些差异会在没有漏洞数据的情况下说明(Conan 有一个——“ConanCenter”——但目前几乎没有内容;随着内容增加,conan.lock 差异中的安全公告会自动显示)——不过,pixi.lock / conda-lock.yml 中的 pip/pypi 软件包会像其他 Python 锁定文件一样,与 PyPI 安全公告进行匹配。 | |
SBOM 软件包会根据各自的 purl 保留其生态系统;如果 purl 标明了发行版(distro=alpine-3.18.4 → Alpine:v3.18),OS 软件包就会获得 OSV 数据——Ubuntu/RPM 软件包则会在没有这些数据的情况下进行差异比较。 | |
发行时间 / 弃用状态 / 许可证变更来自 deps.dev,该服务涵盖 npm、crates.io、PyPI、Go、Maven、NuGet 和 RubyGems——其他生态系统会直接跳过这些检查。PHP、BEAM 世界、Dart、JSR、iOS、C/C++、Terraform、R、Haskell、Bazel、conda、Helm 和 Ansible 是例外:deps.dev 根本没有 Composer、Hex、Pub、JSR、CocoaPods、Conan、Terraform、CRAN、Hackage、Bazel、conda、Helm 或 Ansible 系统,因此 lockvet 会直接查询 Packagist、hex.pm、pub.dev、jsr.io、CocoaPods 注册表、ConanCenter、Terraform/OpenTofu 注册表、CRAN(通过 METACRAN 的 crandb)、Hackage、Bazel Central Registry、anaconda.org、Ansible Galaxy 以及各 Helm chart 仓库自己的 index.yaml——发行时间、弃用警告(Composer 的 abandoned、Hex 退役、pub.dev 停止维护的软件包和撤回的版本、JSR 下架的版本和归档的软件包、带有指定替代项的 CocoaPods 弃用 pods、归档/下架的 Terraform providers、CRAN 上归档的软件包、Hackage 上弃用的软件包和单个版本——包括替代项建议、从 Bazel Central Registry 下架的 Bazel module 版本及注册表给出的原因、标记为损坏的 conda 发行版、在其仓库索引中标记为 deprecated 的 Helm charts、在 Galaxy 上标记为弃用的 Ansible collections)、变更日志链接和未列出检查也适用于 composer.lock、mix.lock、rebar.lock、Gleam 的 manifest.toml、pubspec.lock、deno.lock 中的 jsr: 软件包、Podfile.lock、.terraform.lock.hcl、renv.lock、stack.yaml.lock、cabal.project.freeze、MODULE.bazel.lock、MODULE.bazel、pixi.lock、conda-lock.yml、Chart.lock、Chart.yaml 和 Ansible requirements.yml(Composer、CocoaPods、CRAN 和 conda 也会检查许可证变更;Hex、pub.dev、jsr.io、Terraform 注册表、Hackage、Bazel Central Registry、Helm chart 索引和 Ansible Galaxy 没有可用的逐发行版许可证历史,因此会如实跳过该项检查)。Ansible 的检查遵循自身的审慎规则:经典 roles 从不声称版本不存在(v1 role 索引只有在所有者重新导入时才会更新,因此缺少某个版本只能说明索引滞后,不能证明存在恶意);而在 Galaxy 2023 年迁移前发布的所有版本都标注了迁移日期,因此历史时间只是上限。Helm 的未列出检查带有防修剪保护:chart 仓库会定期从索引中修剪旧发行版,因此只有当缺少的版本排序位置不早于索引中仍列出的最旧发行版时,才会标记该版本。Helm 的发行时间检查也有防重生成保护:有些仓库会重建 index.yaml,并用生成时间标记历史条目(这样所有 chart 看起来都会像是今天发布的),因此当某个 chart 的三个或更多版本共享一个一小时的 created 时间窗口时,这些时间戳会被视为生成产物,不会据此声称发行时间——时间不在该区间内的版本仍保留其真实发行时间。Bazel Central Registry 也不记录发布时间(这些时间仅存在于其 git 历史中),因此 Bazel modules 不会提供发行时间或 ⏱ 冷却标记。CRAN 的未列出检查可抵御镜像延迟:在做出任何判断前,都会直接向 cran.r-project.org 再次确认是否缺失(同时检查当前发行版和 Archive——CRAN 会归档旧版本,从不删除它们,因此一个版本若两处都没有,就说明它从未在 CRAN 上发布过)。 | |
conan.lock 只会获取发行时间:Conan 引用不会记录其来自哪个远程源,因此在 ConanCenter 中找不到某个版本并不能证明任何事情,lockvet 不会据此做出未列出或弃用的判断。 | |
vcpkg 清单固定了基线:builtin-baseline 提交决定每个依赖的版本,因此 lockvet 会验证该提交是否存在于注册表仓库中(只能在某个 fork 中访问的基线,就是被污染的注册表模式——构建只能在攻击者的检出版本中解析成功,在其他地方都不行),记录其日期,在注册表仓库上提供 old...new 链接,并根据 microsoft/vcpkg 的只增版本数据库检查 overrides 版本固定。只有在 outgoing 值能够解析时才会执行这两项判断(使用 overlay ports 或自定义注册表的项目不会触发这些判断),而在清单配置中声明 overlay-ports 会彻底关闭 override 判断。 | |
Nix flake inputs 固定的是 git 修订,而非版本——lockvet 会将它们显示为 <commit-date>.<short-rev>,使差异仍按时间顺序排列;它会将每个 input 的 lastModified 转换为发行时间(⏱ 冷却标记可用于 update-flake-lock PR,完全离线),并将每次升级链接到 forge 的 rev...rev 比较页面,只需点击一次即可查看“nixpkgs 实际改了什么”。上文提到的 narHash / 仓库重定向检查同样适用于 flakes。 | |
| 有你关心的项目遗漏了吗?提出问题 — | |
| 每个解析器大约只有 50 行代码。 |
工作原理
git diff --name-only <base> <target>找出发生变更的锁文件。- 使用
git show读取每个锁文件版本,并将其解析为package → pinned versions(支持处理同一软件包的多个版本 — npm 嵌套依赖、Cargo 重复主版本)。 - 对两个快照进行比较,并使用宽松的版本解析器为每项变更分类;该解析器可处理语义化版本、Python 后续发布版本和 Go
伪版本。
如果锁文件还记录了依赖边和根依赖,lockvet 会通过广度优先搜索遍历依赖图,为每项变更标注
(direct)或via <chain>— 无需清单文件或网络。 - 将旧版本和新版本提交给 OSV.dev 批量 API 检查。若漏洞影响新版本但不影响旧版本,则为引入;反之则为已修复;两者都受影响则为未解决。同一问题的别名公告(GHSA/CVE/PYSEC/RUSTSEC)会合并。
- 通过 deps.dev 批量 API 查询每个引入版本的发布日期、弃用状态和许可证 — 也会查询被替换的版本,以便标记新旧版本之间的许可证变更(npm、crates.io、PyPI、Go、Maven、NuGet、RubyGems)。如果注册表未列出引入版本 — 但列出了该软件包的其他版本 — 则会添加
unlisted标记:未发布(通常是恶意)的版本就是这种情况(对于 npm、PyPI、crates.io、RubyGems、Packagist、NuGet、Hex、Pub、Go、Maven、CRAN、Hackage、Bazel 和 conda,此标记还会与注册表本身的数据进行二次核对;注册表还会告知 lockvet,某次升级是否突然增加了安装脚本或悄然移除了来源证明,以及某个版本是否已撤回,或其项目是否已归档或隔离)。发布时间短于-fresh-days(默认为 7 天)的版本会获得 ⏱ 标记 — 供应链攻击通常会在发布后的几天内被发现并撤回,因此短暂的冷却期可以过滤掉大多数此类版本。对于 RubyGems,紧凑索引中的created_at时间会补充 deps.dev 尚未收录的版本年龄,因此几分钟前发布的 gem 也会获得 ⏱ 标记;NuGet 注册信息中的published时间也会为 .NET 软件包提供相同的数据;对于 Composer、Hex、Dart、JSR、R、Haskell 和 conda 软件包,版本年龄直接取自 Packagist、hex.pm、pub.dev、jsr.io、CRAN 自身的逐版本时间线(通过 METACRAN 的 crandb)、Hackage 的上传时间接口和 anaconda.org 的逐版本上传时间,这些数据 deps.dev 完全没有覆盖;pod 的数据取自 CocoaPods trunk API 中逐版本的时间戳;Terraform/OpenTofu 提供程序的数据取自注册表中的逐版本发布时间(因此,几小时前发布的提供程序版本也会获得 ⏱ 标记);几分钟前创建的 Go 标签数据取自模块代理的.info接口,伪版本则在版本字符串中包含提交时间,因此无需额外查询即可得知其版本年龄;Maven 构件则回退使用 Central 或 Google Maven 仓库中 POM 的Last-Modified上传时间。代理中最新的go.mod还会提供撤回信息(包括作者的理由注释)和// Deprecated:模块通知 — 同时会遵循GOPROXY设置,因此私有代理也能使用。 - 获取每个发生变更的软件包的源代码仓库(来自 deps.dev)的标签列表,使用 git 的智能 HTTP 协议 — 每个仓库只需匿名 GET 一次,与
git ls-remote发出的请求相同。将旧版本和新版本与真实标签进行匹配(尝试v1.2.3、1.2.3、[email protected]、pkg-v1.2.3、Go 的dir/v1.2.3和伪版本提交哈希),只有经过验证的匹配项才会生成比较链接或发布链接 — 确保每个链接都有效。 隐私: 唯一的网络流量是对 OSV.dev 和 deps.dev 的批量查询(包名 + 版本)、匿名获取以下生态系统中已更改软件包的元数据:npm 注册表 / PyPI / crates.io / RubyGems / Packagist / NuGet / hex.pm / pub.dev / jsr.io / CocoaPods-CDN-and-trunk / Go-module-proxy / Terraform-and-OpenTofu-registry / Maven-Central-and-Google-Maven / ConanCenter / CRAN(crandb + cran.r-project.org)/ Hackage / Bazel-Central-Registry / anaconda.org,以及上文所述的匿名 git 标签列表。-offline会禁用所有这些操作;-no-vulns/-no-meta可分别禁用漏洞查询和元数据 + 链接查询。拼写抢注检查完全不会发送请求——热门列表随二进制文件一同提供。 永远不会收集遥测数据。
隔离网络环境中的漏洞检查(-osv-db)
-offline 不一定意味着“没有漏洞数据”。将 lockvet 指向本地 OSV 数据库目录——即 OSV.dev 发布的各生态系统 all.zip 文件——即可完全从磁盘运行漏洞检查:
# On a machine with network: downloads (and later revalidates) the
# databases for whatever ecosystems the diff actually touches.
lockvet -osv-db ~/osvdb
# Air-gapped / locked-down CI: same check, zero network.
lockvet -offline -osv-db ~/osvdb -fail-on vuln
重要细节:
- 检查结果相同。 范围评估在客户端进行,使用的遍历方式与 lockvet 检查 GitHub Actions 公告时相同;已撤回的记录会被排除,与 API 的行为一致。在实际代码库中,输出与 API 路径逐字节相同——包括
MAL-…恶意软件记录,因此chalk/debug 回放即使在拔掉网线后也能运行。 - 更新成本低。 下载采用条件请求(ETag):数据库未更改时只需一次 304。
LOCKVET_OSV_DB可设置默认目录,因此 CI 可以像缓存其他构建产物一样缓存它。 - 手动复制 ZIP 文件也能用。 如果隔离网络环境只允许通过文件传输流程接收文件,可以手动复制
all.zip文件——lockvet 会在首次使用时在本地为其建立索引。 - 大小如实说明。 大多数生态系统的数据库只有几 MB;npm 约为 200 MB,因为其中包含所有
MAL-…恶意软件记录——这正是所需的。npm 首次建立索引约需 15 s;之后的运行只需几秒。 - 缺少数据库时会明确报错。 如果差异中包含某个生态系统,但本地没有对应数据库,程序会跳过漏洞检查并给出说明,绝不会悄悄报告“没有漏洞”。
缓存: 注册表和公告查询结果会在磁盘上缓存一小时(~/.cache/lockvet,可通过 LOCKVET_CACHE_DIR 覆盖),因此重复运行速度很快——先运行 lockvet,再运行 lockvet -md 生成 PR 评论,不会对每个注册表重复查询——也能遵守匿名访问速率限制。Forge 数据(PR 状态、文件内容)绝不会缓存,因此始终检查实时差异;阴性注册表查询结果——即 ▲ 未列出标记的依据——每次运行都会重新验证,因此刚发布的版本会立即清除该标记。-no-cache 可绕过缓存;-cache-ttl 15m 可调整缓存时长(-cache-ttl 0 会禁用缓存)。绝不会将你的代码库中的任何内容写入缓存。
当网络无法连接时——包括 DNS、TCP、TLS 或超时问题——lockvet 会使用过期的缓存条目(最多 24 小时前的数据),而不是在整个故障期间停止提供公告和注册表检查,并会就回退行为和数据时效发出一次警告。只要服务器有响应,无论返回什么状态,都会尊重其响应:回退只适用于“无法查询”,绝不适用于“不喜欢这个结果”。禁用缓存后,不会启用回退。
依赖项: 无。纯 Go 标准库。
对比情况
锁文件上的 git diff | whatsdiff v2.6 | dependency-review-action | lockvet | |
|---|---|---|---|---|
| 锁文件格式 | 任意(原始文本) | 3 种(composer、npm、pnpm) | GitHub 依赖图生态系统 | 60 种,覆盖 30 多个生态系统,另支持 CycloneDX/SPDX SBOM |
| 易读的逐包摘要 | ✗ | ✓ | ✓(作业摘要 / PR 评论) | ✓ |
| 更改引入 / 修复的漏洞 | ✗ | ✗ | 仅引入的漏洞(GitHub Advisory DB) | ✓ 两者兼有(OSV.dev) |
| 版本发布时长 + ⏱ 新版本冷却期标记 | ✗ | ✗ | ✗ | ✓(deps.dev) |
| 弃用警告 | ✗ | ✗ | ✗ | ✓(deps.dev) |
许可证变更标记(MIT → BUSL-1.1) | ✗ | ✗ | ✗(改用 SPDX 许可名单门禁) | ✓(deps.dev) |
| 已更改依赖项的 OpenSSF Scorecard 评分 | ✗ | ✗ | ✓(警告阈值) | ✗ |
| 标记其注册表已不再列出的版本(已下架的恶意软件) | ✗ | ✗ | ✗ | ✓ (unlisted) |
| 标记与热门名称仅差一个字符的新依赖项(拼写仿冒) | ✗ | ✗ | ✗ | ✓ (≈ typosquat,离线,npm/PyPI/crates.io) |
| 标记突然新增 npm 安装脚本的版本升级 | ✗ | ✗ | ✗ | ✓ (⚙ scripts) |
| 标记悄然失去来源证明(sigstore / 可信发布)的新版本 | ✗ | ✗ | ✗ | ✓ (⛨ provenance) |
| 标记未更改版本的完整性变更,以及私有→公共注册表迁移(依赖混淆) | ✗ | ✗ | ✗ | ✓ (‼ integrity / ⇄ resolution,离线) |
| 使用本地数据库进行隔离环境漏洞检查 | ✗ | ✗ | ✗ | ✓ (-osv-db,包括 MAL-… 恶意软件记录) |
GitHub Actions uses: 固定版本(SHA → 版本解析、标签攻击检测) | ✗ | ✗ | 通过依赖图支持 GitHub Actions 生态系统 | ✓(格式 #31,支持全部五种托管平台 + 本地) |
| 根据上游标签验证 SwiftPM 固定版本(标签已移动 / 发布已删除) | ✗ | ✗ | ✗ | ✓ (‼ tag mismatch) |
区分直接与传递依赖,并显示引入链(via a › b) | ✗ | ✗ | 直接 / 间接标签,无引入链 | ✓ |
| 无需克隆即可审查 PR / MR / 比较 URL | ✗ | ✗ | 仅限 GitHub PR(以 PR 工作流的身份运行) | ✓(GitHub + GitLab + Bitbucket + Gitea/Forgejo + Azure DevOps,也包括可自托管实例) |
| 一次性处理所有未关闭的 Dependabot/Renovate PR | ✗ | ✗ | ✗ | ✓(lockvet queue <org>,支持全部五种托管平台) |
| 对比两个容器镜像的 SBOM,并检查发行版 CVE | ✗ | ✗ | ✗ | ✓(lockvet diff old.cdx.json new.cdx.json) |
| 审计当前固定的版本,而不仅是变更 | ✗ | ✗ | ✗ | ✓ (lockvet audit:公告、未列出、已弃用、新发布) |
| 安装前审查软件包 | ✗ | ✗ | ✗ | ✓ (lockvet pkg npm:left-pad:公告、版本时长、拼写仿冒——最新版本或任意版本) |
| CI 门禁 | ✗ | 逐包 check 退出代码 | ✓(fail-on-severity、许可证列表、deny-packages) | 策略门禁(-fail-on major|vuln|fresh|deprecated|unlisted|typosquat|scripts|provenance|integrity|registry|license)+ GitHub Action |
| 在不关闭门禁的情况下确认已处理某项发现 | ✗ | ✗ | ✓(allow-ghsas,无过期时间) | .lockvetignore(按公告 / 软件包设置规则并指定过期日期;忽略的发现仍会显示) |
| 输出格式 | 文本 | 文本、JSON、markdown | 作业摘要 + PR 评论 | 文本、JSON、markdown、SARIF(代码扫描警报) |
| 查看上游有哪些变更 | ✗ | ✓(获取变更日志文本) | ✗ | ✓(经过验证的标签间差异链接 + -changelogs 获取发行说明,也包括传递依赖) |
| MCP 服务(让 AI 助手审查 PR) | ✗ | ✓ | ✗ | ✓(lockvet mcp:审查 URL、本地仓库、文件、安装前软件包、审计结果和整个队列) |
| 交互式 TUI | ✗ | ✓ | ✗ | ✗ |
| 在本地 / CI 之外运行 | ✓ | ✓ | ✗ | ✓(CLI、浏览器试用区、任意 CI) |
| 运行环境 | — | PHP(提供二进制文件) | GitHub 运行器上的 JS Action;公共仓库可免费使用,私有仓库需要 GitHub Advanced Security | 单个静态 Go 二进制文件,无依赖 |
| 如果你使用 composer/npm,并且想要变更日志和 TUI,whatsdiff 是个不错的工具。如果你全面使用 GitHub,GitHub 的 dependency-review-action 是个可靠的默认选择——它会读取依赖关系图(因此也能发现锁文件未记录的仅清单文件变更),并显示 OpenSSF Scorecard 评分;但它只检查已知的安全公告和许可证,只适用于 GitHub PR,而且私有仓库需要 Advanced Security 许可证。lockvet 的关注点不同:我应该信任这个差异吗?——无论你的仓库使用什么代码托管平台和语言,它都能检查,并识别注册表层面的篡改信号,即使尚无任何安全公告数据库发现这些信号;无论是在本地还是任何 CI 环境中都可以。 |
“这不就是……吗?”
osv-scanner? Google 的 osv-scanner 是已知漏洞扫描器的标杆:将它指向一个代码树或容器镜像,它就会列出所有 OSV 安全公告;它还提供实验性的 fix 模式,可为你规划升级。lockvet 回答的是另一个问题——这次变更会带来什么影响?有没有什么地方看起来不对劲? 安全公告只是答案的一部分;发布时间、弃用情况、许可证变更、注册表已不再列出的版本、拼写仿冒包、安装脚本和来源证明的变化,以及完整性/解析篡改,都是其余部分——这些信号可能在任何安全公告出现之前触发(参见案例研究)。如果你需要整个代码树的 CVE 清单和修复规划,请使用 osv-scanner——lockvet audit 的功能有重叠之处,也会在每条安全公告中标出修复版本,但它不会替你修改清单文件。如果你想知道是否应该合并 Dependabot PR,那就用 lockvet。
npq? npq 会在 npm install 之前审查 npm 包——检查发布时间、安装脚本、拼写仿冒包、来源证明退化和已知漏洞——并提供交互式提示和包管理器别名,因此可以成为你的日常 npm。如果你主要使用 npm,它是很棒的日常工具。lockvet pkg 会在约 30 种生态系统中运行同类安装前检查(npm、PyPI、crates.io、RubyGems、Go、Maven、NuGet、Composer、Hex、pub.dev、JSR、CocoaPods、Terraform、CRAN、Helm、GitHub Actions、CircleCI orbs、工具链,等等)——但它只生成一次性报告,不是安装包装器;而安装前审查只是这款工具的一个子命令,它的主要用途是检查差异和设置 CI 门禁。
Socket? Socket 是一个商业平台,在一个重要方面比 lockvet 做得更多:它会静态分析包的代码,检查恶意行为和高风险能力。lockvet 从不读取包代码——它只读取锁文件字节和公开的注册表元数据,无需账户、无需上传代码,也不依赖公共注册表之外的任何服务。两者采用不同的信任模型:如果你可以使用托管服务并且需要代码级分析,Socket 能发现 lockvet 无法发现的问题;如果你想要一个无需账户的静态二进制文件,可在任何代码托管平台、任何 CI 环境或隔离网络中(-offline)运行,那就选 lockvet。
Dependabot / Renovate? 它们是更新工具,不是审查工具——它们负责创建 PR,lockvet 则负责审查这些 PR(lockvet queue <org> 可一次性处理所有尚未关闭的机器人 PR)。两者本就是互补的。
非目标
- 不是代码分析器——lockvet 从不下载或检查包的内容,只读取锁文件和注册表元数据。Socket 之类的工具或人工审查才负责这项工作。
- 不是更新工具——Dependabot/Renovate 负责创建 PR;lockvet 告诉你是否应该合并。
- 没有交互式 TUI(参见上文的 whatsdiff)——lockvet 始终是一次性运行的命令,输出可直接放入 PR 评论。
许可证
MIT © Matteo Sung
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。