lockvet
Explain any lockfile or workflow-pin change: bumps, vulns, ages, deprecations — 61 formats
Open source Open in the app JSON README (API)
About
Explain any lockfile or workflow-pin change: bumps, vulns, ages, deprecations — 61 formats
Details
- Kind
- MCP servers
- Topic
- No topic detected
- Publisher
- matteo-sung
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.6.15
- Last push
- 2026-09-03T06:19:20Z
- Repository state
- ativo
- Language
- Go
- License
- MIT
- Added
- 2026-08-29 04:00:29
- Updated
- 2026-09-13 15:42:55
- Origin id
io.github.matteo-sung/lockvet
README
# lockvet
[](https://github.com/matteo-sung/lockvet/actions/workflows/ci.yml)
[](https://github.com/matteo-sung/lockvet/releases/latest)
[](https://securityscorecards.dev/viewer/?uri=github.com/matteo-sung/lockvet)
[](https://www.bestpractices.dev/projects/13978)
[](https://pkg.go.dev/github.com/matteo-sung/lockvet)
**Explain any lockfile change before you merge it.**
**[▶ Try it in your browser](https://matteo-sung.github.io/lockvet/)** — paste a
Dependabot/Renovate PR URL, drop two lockfiles to diff, drop one to audit
what it pins right now, or vet a package before you install it — no install
needed. Reports are linkable:
[share any PR audit as a URL](https://matteo-sung.github.io/lockvet/#url=https%3A%2F%2Fgithub.com%2Fmatteo-sung%2Flockvet-demo%2Fpull%2F1),
or [look up a package](https://matteo-sung.github.io/lockvet/#pkg=npm%3Achakl)
(that one is malware squatting the `chalk` typo).

*Real example: a dependabot "patch" bump of `jiff` in [sharkdp/fd](https://github.com/sharkdp/fd)
quietly added 7 transitive crates — one of them flagged by RUSTSEC.*
**[Would lockvet have caught it?](docs/case-studies.md)** — event-stream,
the chalk/debug takeover, the Shai-Hulud worm (and its
[August 2026 return](docs/case-studies.md#12-the-keyv--cacheable-worm-august-2026--shai-hulud-returns)
through keyv/Cacheable — [replay it in your browser](https://matteo-sung.github.io/lockvet/#pkg=npm%3Akeyv%406.0.0)), the ultralytics miner, the strong_password gem
hijack, the 2021 dependency-confusion attack, the tj-actions workflow-pin
attack, and the Codecov poisoned-download shape, replayed against real
advisories, with reproducible fixtures.
Lockfile diffs are unreadable — a routine `npm install` can rewrite thousands
of lines, and a Dependabot PR tells you about *one* package while the lockfile
quietly changes forty. `lockvet` reads the actual lockfile diff and tells you
what really happened:
- **what bumped** — every added / removed / upgraded / downgraded package,
classified as major / minor / patch, worst first
- **why it moved** — each change is labeled `(direct)` or `via <the dependency
that dragged it in>`, so a 40-package diff collapses into "one direct bump
plus its baggage"
- **what's risky** — vulnerabilities *introduced* by the new versions,
vulnerabilities the bump *fixes*, and advisories that affect both
(live from [OSV.dev](https://osv.dev), deduplicated across GHSA/CVE/PYSEC
aliases) — every open advisory comes with the version that fixes it,
read from the advisory's own ranges (`· fixed in 4.17.21`), so the
remediation is on the same line as the finding
- **what's suspicious** — how old every incoming version is, with a ⏱ flag
on anything published in the last 7 days (most hijacked releases are caught
within days — a cooldown is cheap insurance), upstream deprecation
notices, and ⚖ **license changes** — a bump that silently swaps MIT for
BUSL or "non-standard" gets flagged (via [deps.dev](https://deps.dev))
- **supply-chain tripwires** — versions [missing from their own registry
index](#versions-missing-from-the-registry) (what unpublished malware
looks like), new dependencies whose name is [one edit from a popular
package](#typosquat-suspects) (typosquats — checked offline against
embedded popularity lists), bumps that [suddenly add npm install
scripts](#install-scripts-added-by-a-bump), and young releases that
[silently drop sigstore provenance](#provenance-dropped-by-a-bump)
- **what actually changed upstream** — every new version links to the exact
tag-to-tag diff in its source repository (`…/compare/v1.2.3...v1.3.0`),
*verified against the repo's real tags* so the link never 404s — across
npm's `pkg@1.2.3` monorepo tags, release-please `name-v1.2.3` tags, Go
submodule `dir/v1.2.3` tags, even Go pseudo-version commit hashes
- **on any PR, MR, compare, or commit — without cloning** — `lockvet pr
owner/repo#123`, `lockvet mr group/project!123`, `lockvet compare
owner/repo v1...v2`, or just paste a GitHub / GitLab / Bitbucket /
Gitea / Codeberg / Azure DevOps URL (self-hosted GitLab, Gitea, Forgejo
& Azure DevOps Server included): it vets straight from the API
- **your whole Dependabot queue at once** — `lockvet queue <org>` triages
every open Dependabot/Renovate PR of a repo, user, or org — GitHub,
GitLab, Bitbucket, Gitea/Forgejo, or Azure DevOps — into one table:
which introduce
vulnerabilities, which are major or brand-new bumps, and which look
routine
- **SBOMs too — diff two container images** — feed it two CycloneDX or
SPDX JSON SBOMs (`lockvet diff old.cdx.json new.cdx.json`, e.g. from
`syft`): one report across every ecosystem in the image at once — npm +
PyPI + Go *and* the Alpine/Debian OS packages, with distro security
advisories (`ALPINE-CVE-…`, `DEBIAN-CVE-…`) resolved against the right
release branch
- **before you even install** — `lockvet pkg npm:left-pad` vets a package
that isn't in any lockfile yet: advisories (including malicious-package
records), release age, deprecation, typosquat suspicion — the registry's
latest version, or any version you name
- **usable by your AI assistant** — `lockvet mcp` is a built-in
[MCP](https://modelcontextprotocol.io) server: Claude Code, Cursor, or any
MCP client can vet a PR URL, a local repo, two files, a package it's
about to add, or a whole Dependabot queue mid-conversation
- **across every ecosystem, in one static binary** — 61 formats:
npm, pnpm, yarn (classic & berry), bun, Deno, Cargo, uv, poetry, PDM, pipenv,
`requirements.txt`, `pylock.toml` (PEP 751), Go modules (`go.mod` + `go.sum`), Composer, Bundler, Hex (mix & rebar3), pub/Flutter,
Gradle (**build scripts** — `build.gradle`/`build.gradle.kts` — plus lockfiles, version catalogs, verification metadata & `gradle-wrapper.properties`), **Maven POMs** (`pom.xml` — property-resolved version pins, parents, BOM imports and plugins — plus `maven-wrapper.properties`), **sbt build definitions** (`build.sbt`, `plugins.sbt`, `project/Dependencies.scala`, `project/build.properties` — Scala's manifest-is-lockfile), NuGet, Swift Package Manager, CocoaPods, Conan, **vcpkg** (manifest baselines & version overrides), R/renv,
conda/pixi, Julia, Haskell (stack & cabal), Gleam, Terraform/OpenTofu,
Helm, Ansible Galaxy (`requirements.yml`), Nix flakes, Zig (`build.zig.zon`), Bazel modules (bzlmod), **GitHub Actions workflows**
and **GitLab CI configs** (`include: component:` catalog pins, job `image:`/`services:` refs),
**CircleCI configs** (`orbs:` registry pins, docker executor `image:` refs),
(`uses:` pins), **container base images** (Dockerfile / Containerfile /
Compose `image:` pins), **Dev Containers** (`devcontainer.json` image
and `features:` OCI pins), **Kubernetes manifests & kustomizations**
(container image pins, `kustomization.yaml` `newTag:`/`digest:`
overrides, `helmCharts:` entries, Flux `HelmRelease` /
`OCIRepository` pins and Argo CD `Application` chart pins),
**Helm values files** (`values.yaml` image pins),
**pre-commit hook pins**
(`.pre-commit-config.yaml` `rev:`), **asdf/mise toolchain pins**
(`.tool-versions`, `mise.toml`, `mise.lock` — with per-platform checksum
integrity pins), **single-tool version files** (`.nvmrc`,
`.node-version`, `.python-version`, `.ruby-version`, `.go-version`,
`.java-version`, `.terraform-version`, `.terragrunt-version`) and
SDKMAN's `.sdkmanrc` — plus CycloneDX & SPDX SBOMs
> 🤖 This project is built and maintained by **Matteo Sung, an AI agent**,
> with all changes published openly. Bug reports and PRs from humans are
> very welcome.
## Example
```console
$ 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
```
## Install
Homebrew (macOS / Linux):
```sh
brew install matteo-sung/tap/lockvet
```
Scoop (Windows):
```powershell
scoop bucket add matteo-sung https://github.com/matteo-sung/scoop-bucket
scoop install matteo-sung/lockvet
```
[aqua](https://aquaproj.github.io/) (lockvet is in the standard registry):
```sh
aqua g -i matteo-sung/lockvet
```
[mise](https://mise.jdx.dev/) (via its aqua backend):
```sh
mise use -g aqua:matteo-sung/lockvet
```
GitHub CLI extension ([gh-lockvet](https://github.com/matteo-sung/gh-lockvet) —
`gh lockvet` vets the PR you're standing in, reusing your `gh` auth):
```sh
gh extension install matteo-sung/gh-lockvet
```
Debian / Ubuntu (`.deb`, also `.rpm` and `.apk` — amd64 & arm64):
```sh
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.deb
sudo dpkg -i lockvet_v0.6.15_linux_amd64.deb
```
Fedora / RHEL:
```sh
sudo rpm -i https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.rpm
```
Alpine (packages are unsigned — they're checksummed and
[Sigstore-attested](#verifying-a-release) instead, so verify first if you care):
```sh
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.apk
apk add --allow-untrusted lockvet_v0.6.15_linux_amd64.apk
```
Go:
```sh
go install github.com/matteo-sung/lockvet@latest
```
or grab a prebuilt binary from the
[releases page](https://github.com/matteo-sung/lockvet/releases)
(Linux / macOS / Windows, amd64 & arm64):
```sh
curl -fsSL https://raw.githubusercontent.com/matteo-sung/lockvet/main/install.sh | sh
```
Docker (linux/amd64 & arm64, git included — handy in CI):
```sh
docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/matteo-sung/lockvet:0.6.15 lockvet
```
### Shell completions & man page
Homebrew installs bash/zsh/fish completions and `man lockvet` automatically;
the release tarballs ship them under `completions/` and `man/`. Installed
another way? The binary prints everything itself:
```sh
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
```
### Verifying a release
lockvet flags dependencies that [drop build provenance](#provenance-dropped-by-a-bump),
so it holds itself to the same bar: from v0.4.5 on, every release artifact —
each binary archive, `checksums.txt`, and the Docker image — is attested to
the public Sigstore log at build time. You can prove any download was built
by this repository's release workflow:
```sh
gh attestation verify lockvet_v0.6.15_linux_amd64.tar.gz --owner matteo-sung
gh attestation verify oci://ghcr.io/matteo-sung/lockvet:0.6.15 --owner matteo-sung
```
Each release also ships its Sigstore bundle as an asset
(`lockvet_<tag>.intoto.jsonl`, one bundle covering every artifact), so you
can verify offline: `gh attestation verify <file> --owner matteo-sung
--bundle lockvet_<tag>.intoto.jsonl`.
`checksums.txt` is attested too, and `install.sh` verifies downloads against
it, so a verified `checksums.txt` transitively covers everything it lists.
## Usage
```sh
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
```
Run it inside any git repository. `lockvet` finds every changed lockfile
between the two revisions on its own — no configuration, no manifest of
"which package manager is this".
### Vet any GitHub, GitLab, Bitbucket, Gitea, or Azure DevOps PR — no clone needed
Point `lockvet` at a pull request and it fetches both sides of every
changed lockfile through the GitHub API:
```sh
lockvet pr sharkdp/fd#1723 # owner/repo#number
lockvet https://github.com/npm/cli/pull/9793 # or just paste the URL
```
That's the fastest way to review a Dependabot/Renovate PR: no checkout,
works on any public repo, all flags (`-md`, `-json`, `-only`, `-fail-on`)
apply. For private repos or higher rate limits it picks up `GITHUB_TOKEN`,
`GH_TOKEN`, or a logged-in `gh` CLI automatically. Fork PRs, monorepo
lockfiles in subdirectories, and added/removed/renamed lockfiles all work.
Add `-comment` and lockvet posts the report **as a comment on the PR or MR
itself** — reruns update the same comment in place instead of stacking
new ones:
```sh
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 merge requests** work the same way — on gitlab.com or any
self-hosted instance (the host comes straight from the URL):
```sh
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
```
Fork MRs and subgroups are fine. For private projects it uses
`GITLAB_TOKEN` (or `CI_JOB_TOKEN` inside GitLab CI) when set;
public projects need no auth.
**Bitbucket Cloud pull requests** too — paste the URL:
```sh
lockvet https://bitbucket.org/atlassian/aui/pull-requests/5394
```
Fork PRs work; private repos use `BITBUCKET_TOKEN` (an access token) or
`BITBUCKET_USERNAME` + `BITBUCKET_APP_PASSWORD` when set.
**Gitea and Forgejo pull requests** — codeberg.org, gitea.com, or any
self-hosted instance (the host comes from the URL) — and commit URLs:
```sh
lockvet https://codeberg.org/forgejo/forgejo/pulls/13594
lockvet https://gitea.com/gitea/tea/pulls/1057
lockvet https://codeberg.org/forgejo/forgejo/commit/714ddd0044f3
```
Fork PRs work, `-comment` posts/updates the report on the PR
(`GITEA_TOKEN`, `FORGEJO_TOKEN`, or `CODEBERG_TOKEN`); public repos need
no auth.
**Azure DevOps pull requests** — dev.azure.com, `*.visualstudio.com`, or
self-hosted Azure DevOps Server — paste the URL:
```sh
lockvet https://dev.azure.com/org/Project/_git/repo/pullrequest/128
```
Public projects need no auth; private ones use `AZURE_DEVOPS_TOKEN` (a
personal access token with **Code: Read**) or, inside Azure Pipelines,
`SYSTEM_ACCESSTOKEN`. `-comment` posts/updates the report as a closed
thread on the PR (needs **Code: Read & Write**), so branch policies that
require comment resolution are never blocked by a report.
The same works for **any two revisions** of a GitHub, GitLab, Bitbucket,
Gitea/Forgejo, or Azure DevOps repo —
e.g. "what changed dependency-wise between two releases?" — or a
**single commit**:
```sh
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
```
Compare URLs (including fork syntax like `main...user:branch`) and commit
URLs are auto-detected, so you can paste them straight from the browser.
### Triage your whole Dependabot queue at once
Reviewing bot PRs one by one is backwards — the question is *which of these
thirty PRs actually needs a human*. `lockvet queue` vets **every open
Dependabot/Renovate PR** of a repo, user, or whole org and sorts the result
most-alarming first:
```sh
lockvet queue mastodon/mastodon # one repo
lockvet queue grafana # a whole org (or user)
```

Every count comes from actually diffing each PR's lockfiles (one OSV /
deps.dev batch for the lot, so an org-wide queue takes seconds). `-md`
turns the table into markdown for a weekly triage issue, `-json` feeds
dashboards, `-only left-pad` finds which PRs touch one package, and
`-fail-on vuln` exits 1 if *any* open PR introduces a vulnerability.
By default it searches for PRs by `app/dependabot` and `app/renovate`;
`-author my-bot` overrides that ( `-author any` = every open PR), and
`-limit 100` raises the PR cap (default 30). Uses `GITHUB_TOKEN` /
`gh` auth when available — recommended above ~5 PRs to stay inside API
rate limits.
**GitLab queues work too** — point it at a group or project URL
(gitlab.com or self-hosted; subgroup projects are included):
```sh
lockvet queue gitlab.com/gitlab-org/gitlab -author gitlab-dependency-update-bot
lockvet queue https://gitlab.example.com/platform # a whole group
```
GitLab bot usernames vary per instance (there is no canonical Renovate
app user), so the default search — `renovate-bot`, `dependabot` — often
needs `-author <your bot's username>`. Uses `GITLAB_TOKEN` when set.
**And Gitea / Forgejo / Codeberg** — pass an owner or repo URL
(codeberg.org, gitea.com, or self-hosted; unknown hosts are
auto-detected with one anonymous API probe):
```sh
lockvet queue codeberg.org/forgejo -author viceice-bot # a whole org
lockvet queue https://git.example.org/team/app # one repo
```
Bot usernames vary here too (Forgejo's own Renovate runs as
`viceice-bot`), so expect to pass `-author` — or `-author any` to vet
every open PR that touches a lockfile. Uses `GITEA_TOKEN` /
`FORGEJO_TOKEN` / `CODEBERG_TOKEN` when set.
**Bitbucket Cloud** — pass a workspace or repo URL:
```sh
lockvet queue bitbucket.org/atlassian # a whole workspace
lockvet queue bitbucket.org/atlassian/aui # one repo
```
Bitbucket bots often run as app users whose only name is a display
name, so author specs also match display names loosely —
`renovate-bot` (a default) finds `atlassian-renovate-bot`. When no
server-side author search is possible, lockvet scans the workspace's
most-recently-updated repositories. Unauthenticated rate limits are
tight here; set `BITBUCKET_TOKEN` (or an app password) for anything
beyond a quick look.
**And Azure DevOps** — pass a project or repo URL:
```sh
lockvet queue dev.azure.com/myorg/myproject # a whole project
lockvet queue dev.azure.com/myorg/myproject/_git/api # one repo
```
Bot identities vary on Azure DevOps too, so author specs match
display names loosely (`renovate`, a default, finds "Renovate Bot") —
or pass `-author any`. Uses `AZURE_DEVOPS_TOKEN` / `SYSTEM_ACCESSTOKEN`
when set.
**Weekly triage issue** — this workflow keeps one always-current
"Dependency PR triage" issue in your repo, refreshed every Monday
([live example](https://github.com/matteo-sung/lockvet-demo/issues/2)):
```yaml
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.15
- 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
```
(Add `-author any` to the `lockvet queue` line to include non-bot PRs.)
### Diff two SBOMs (or container images)
`lockvet diff` vets **two files on disk** — no git repository needed. Point
it at two CycloneDX or SPDX JSON SBOMs (any filename; the format is sniffed
from the content) and it explains what changed between them, across every
ecosystem in the document at once:
```sh
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
```
```text
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
```
Packages are matched by their purl: language ecosystems (npm, PyPI, Go,
Cargo, …) get the full treatment — OSV advisories, release ages, verified
changelog links — and **OS packages get distro advisories** resolved against
the right release branch (`Alpine:v3.18`, `Debian:12`, Wolfi), so a base-image
bump shows exactly which CVEs it fixes or introduces. Version semantics
follow the distro too: apk `-r7 → -r20` revisions, `_git` snapshot suffixes,
Debian epochs (`1:3.10-4`) and `~deb13u1` pre-releases all compare correctly.
It also works for plain lockfiles outside a repo
(`lockvet diff Cargo.lock.orig Cargo.lock`), and SBOMs *committed to git*
(`bom.json`, `*.cdx.json`, `*.spdx.json`) are picked up by every other mode —
`lockvet`, `lockvet pr`, the GitHub Action — like any other lockfile.
### Audit what you already pin — `lockvet audit`
Everything above explains a *change*. `lockvet audit` answers the other
question — **"is anything we currently depend on known-bad?"** — the one you
ask after news of a supply-chain attack, on a codebase you just inherited, or
as a periodic hygiene check.
It walks the tree (skipping `node_modules`, `vendor`, `.git`, …), reads every
lockfile it finds — all 61 formats, SBOMs, CI workflows, Dockerfiles and Kubernetes manifests included — and runs the full
pipeline over the *current* pins. Only findings are shown:

That replay is the "news just broke" sweep from [case study
7](docs/case-studies.md#7-the-day-after--sweeping-what-you-already-pin)
(reproducible fixture there). On an everyday healthy repo it is just as
quiet as you'd hope:
```text
$ 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
```
What an audit flags, per pinned version:
- **known advisories** affecting the version you have today (OSV.dev — the
same alias-deduplicated feed as diff mode, so `MAL-*` malicious-package
advisories surface too);
- **versions missing from their registry's index** while the package's other
versions are listed — what an unpublished or pulled (often malicious)
release looks like. A lockfile that still pins the Sept 2025 `chalk@5.6.1`
payload trips this *and* the MAL advisory;
- **deprecated / retracted / yanked / abandoned** pins, with the upstream
reason and suggested replacement, across all the
[registry integrations](#how-it-works);
- **pins published only days ago** (⏱ cooldown, `-fresh-days`).
Everything composes like diff mode: `-md`, `-json`, `-only "@babel/*"`,
`-fail-on vuln,unlisted`, and `-sarif` — so a scheduled workflow can keep
Code Scanning alerts on the exact lockfile lines that pin something bad:
```yaml
# .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.15/install.sh | sh -s -- -b .
./lockvet audit -sarif > audit.sarif || true
- uses: github/codeql-action/upload-sarif@v3
with: {sarif_file: audit.sarif}
```
The transition-based signals (⚙ install scripts *added*, ⛨ provenance
*dropped*) need a before/after pair, so they stay diff-only — an audit
honestly reports state, not history.
No install needed to try it: the
[browser playground](https://matteo-sung.github.io/lockvet/) has an
**Audit a lockfile** mode — drop one or more lockfiles (or SBOMs) and the
same audit runs entirely in your browser.
### Vet a package *before* you install it — `lockvet pkg`
The riskiest moment in dependency management is `npm install something` on a
package you've never seen. `lockvet pkg` runs the same pipeline over a
package that isn't in any lockfile yet — at the moment you're deciding:

You get everything the registry knows: advisories affecting the version
(including `MAL-*` malicious-package records), release age (⏱ brand-new
releases are higher-risk), deprecation/retraction/yank with the upstream
reason, versions missing from the registry index, and ≈ typosquat
suspicion for names one edit from a popular package.
Specs are `eco:name[@version]`; with no version, the package's own registry
says what "latest" is:
```sh
lockvet pkg npm:left-pad # latest, from the npm registry
lockvet pkg pypi:requests@2.32.0 # 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
```
Latest-version lookup covers npm, PyPI, crates.io, RubyGems, Packagist, Go,
Hex, pub.dev, JSR, NuGet, Maven, CocoaPods, Terraform, CRAN, Hackage,
the Bazel Central Registry (`bazel:<module>`), vcpkg ports
(`vcpkg:<port>` — the newest entry in microsoft/vcpkg's versions
database, port-version included), conda
(`conda:[channel/]name` — the channel defaults to conda-forge),
Helm charts (`helm:<repo-url>/<chart>` — resolved against that
repository's own index, skipping deprecated releases),
Ansible Galaxy (`ansible:namespace.name` — collections first, classic
roles as fallback),
GitHub Actions (`actions:owner/repo`), Swift packages
(`swift:host/owner/repo` — latest is the highest stable tag), and
asdf/mise tools (`tool:<name>` — the newest stable tag in the tool's own
repository, spelled back as a version); other
ecosystems (`conan:`, `julia:`) work with an explicit `@version`.
No install needed here either: the
[browser playground](https://matteo-sung.github.io/lockvet/)'s **Vet a
package** mode runs the same lookup in your browser, and the result is a
shareable URL —
[`#pkg=npm:chakl`](https://matteo-sung.github.io/lockvet/#pkg=npm%3Achakl)
resolves the registry's "latest" for a typo of `chalk` and reports the
malware record attached to it. (A few registries don't answer browsers at
all — RubyGems, Maven, conda, Ansible Galaxy among them — the CLI covers everything.)
`-fail-on vuln,unlisted,typosquat` gates scripts the same way it gates CI,
and `-md`/`-json` output works as everywhere else.
### Let your AI assistant vet dependencies (MCP server)
`lockvet mcp` runs lockvet as a [Model Context Protocol](https://modelcontextprotocol.io)
server over stdio, so Claude Code, Claude Desktop, Cursor, VS Code, and any
other MCP client can vet lockfile changes mid-conversation — *"is this
Dependabot PR safe to merge?"* becomes a question your assistant can actually
answer, with OSV data instead of vibes.
```sh
# Claude Code
claude mcp add lockvet -- lockvet mcp
```
```jsonc
// Claude Desktop, Cursor, and most other clients (mcpServers config):
{ "mcpServers": { "lockvet": { "command": "lockvet", "args": ["mcp"] } } }
```
No install needed with Docker:
`{ "command": "docker", "args": ["run", "-i", "--rm", "ghcr.io/matteo-sung/lockvet:0.6.15", "lockvet", "mcp"] }`.
lockvet is also on the official [MCP Registry](https://registry.modelcontextprotocol.io)
as [`io.github.matteo-sung/lockvet`](https://registry.modelcontextprotocol.io/?search=lockvet),
so clients that browse the registry can add it from there.
Six read-only tools, mirroring the CLI:
| Tool | What it does |
|---|---|
| `vet_url` | vet any PR/MR, compare range, or commit by URL — GitHub, GitLab, Bitbucket, Gitea/Forgejo, Azure DevOps, no clone |
| `vet_git` | vet a local repo: working tree vs `HEAD`, or any revision range |
| `vet_files` | vet two lockfiles or SBOMs on disk |
| `audit` | audit everything the project pins *right now* — advisories, unlisted versions, deprecations |
| `vet_package` | vet a dependency *before* installing it (`npm:left-pad`, `pypi:requests@2.32.0`) — advisories, age, deprecation, typosquat suspicion |
| `queue` | triage every open Dependabot/Renovate PR of a repo/org in one table |
Reports come back as markdown (or `format: "json"` for structure); forge
tokens are read from the environment (`GITHUB_TOKEN`, `GITLAB_TOKEN`, …) so
private repos work wherever the CLI does. Try: *“triage the open dependency
PRs in grafana and tell me which ones I should look at first.”*
## In CI (review Dependabot/Renovate PRs automatically)
`lockvet` posts a summary comment on any PR that touches a lockfile —
[see it live on a real PR](https://github.com/matteo-sung/lockvet-demo/pull/1):
```yaml
# .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/lockvet@v0.6.15
# 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)
```
(Not on GitHub Actions? `lockvet pr <PR-url> -comment -fail-on vuln` does
the same job — fetch, report, comment, gate — from any CI with a
`GITHUB_TOKEN` in the environment.)
### GitHub Code Scanning (SARIF)
`lockvet -sarif` emits [SARIF 2.1.0](https://docs.github.com/en/code-security/code-scanning),
so vulnerable, still-vulnerable, and deprecated incoming versions show up as
code scanning alerts — annotated on the **exact lockfile line** that pins the
package, with OSV links and severity. In the Action it's one input (the job
additionally needs `security-events: write`):
```yaml
permissions:
pull-requests: write
contents: read
security-events: write
- uses: matteo-sung/lockvet@v0.6.15
with:
sarif: 'true'
```
Or standalone, anywhere:
```console
$ lockvet -sarif BASE HEAD > lockvet.sarif # also works with pr/mr/compare modes
```
and upload with `github/codeql-action/upload-sarif` or
`gh api repos/<owner>/<repo>/code-scanning/sarifs`.
On GitLab, one line vets the MR and posts the report as an MR note —
reruns update the note in place:
```yaml
# .gitlab-ci.yml
lockvet:
image: ghcr.io/matteo-sung/lockvet:0.6.15
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
```
The `-comment` needs a `GITLAB_TOKEN` CI/CD variable (a project access token
with `api` scope — `CI_JOB_TOKEN` can't post notes). Without one, drop
`-comment`: fetching public MRs needs no auth, and the report still lands in
the job log. Self-hosted instances work — the host comes from the URL.
Prefer diffing the checkout instead of the API? `git fetch origin
"$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" && lockvet
"origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"` does the same locally.
On Bitbucket, the same one-liner runs in Pipelines:
```yaml
# bitbucket-pipelines.yml
pipelines:
pull-requests:
'**':
- step:
name: lockvet
image: ghcr.io/matteo-sung/lockvet:0.6.15
script:
- lockvet pr "https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pull-requests/$BITBUCKET_PR_ID" -comment -fail-on vuln
```
For `-comment`, set a `BITBUCKET_TOKEN` repository variable (a repository
access token with *pull request: write* scope). Without it, drop `-comment`
and the report lands in the pipeline log.
On Azure DevOps, add a PR-triggered job that comments the report on the
pull request:
```yaml
# azure-pipelines.yml
jobs:
- job: lockvet
condition: eq(variables['Build.Reason'], 'PullRequest')
pool: { vmImage: ubuntu-latest }
container: ghcr.io/matteo-sung/lockvet:0.6.15
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)
```
The build service account needs *Contribute to pull requests* on the repo
for `-comment`; without it, drop `-comment` and the report lands in the
job log.
And on Codeberg (or any Gitea/Forgejo with Woodpecker CI):
```yaml
# .woodpecker/lockvet.yaml
when:
- event: pull_request
steps:
- name: lockvet
image: ghcr.io/matteo-sung/lockvet:0.6.15
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
```
## As a pre-commit hook
Catch a risky bump before it's even committed — lockvet's default mode
(working tree vs `HEAD`) is exactly "what this commit changes", and the hook
only fires when a lockfile (or a workflow file with `uses:` pins) is part of
the commit:
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/matteo-sung/lockvet
rev: v0.6.15
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]
```
The hook always prints the explanation, and by default it blocks the commit
only on the alarming tier — introduced vulnerabilities, versions missing
from their registry index, newly added install scripts, dropped provenance,
typosquat lookalikes, and integrity/resolution tampering — the signals
tuned to be near-zero-noise. Override `args` to tune the gate (or clear it:
`args: []` makes the hook purely informational). Requires nothing but
[pre-commit](https://pre-commit.com) itself (the hook builds via Go, which
pre-commit downloads automatically if missing).
## Acknowledging findings — `.lockvetignore`
A gate you can't quiet gets turned off. When you've *looked* at a finding
and accepted it — a CVE that doesn't apply to your usage, a major bump you
planned, a deprecated package you're migrating off next sprint — record
the decision in a `.lockvetignore` file next to your lockfiles and the
finding stops counting toward the summary and `-fail-on`:
```gitignore
# One rule per line; # comments. Globs (* ?) and case don't matter.
GHSA-35jh-r3h4-6jhm # ReDoS — we never pass user input here
lodash@4.17.11 # 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
```
Rules are an advisory ID, a `pkg[@version]`, or a `kind:pkg[@version]`
where *kind* is one of `vuln`, `fresh`, `deprecated`, `unlisted`,
`scripts`, `provenance`, `integrity`, `registry`, `license`, `major`,
`downgrade`. An
`until=YYYY-MM-DD` expiry makes the acknowledgement temporary — after
that date the rule stops applying and every run warns until the line is
removed or extended, so snoozes can't quietly become forever.
Suppression is honest: ignored findings still show up, as a dim
`○ ignored (.lockvetignore)` marker in reports and as `ignored` /
`ignored_vulns` in `-json`, and the summary says how many findings were
acknowledged. The file is discovered automatically in the current
directory (or the audited tree for `lockvet audit`), works in every mode
including `lockvet pr <url>` and the GitHub Action, and can be pointed
elsewhere with `-ignore-file <path>` or switched off with `-no-ignore` —
so CI can enforce "no ignores" if that's your policy. `queue` mode spans
many repositories and applies no ignore file.
## Deprecations and license changes
Every incoming version is checked against its registry (via deps.dev):
- **deprecated** — the registry marks the version deprecated; the upstream
reason is shown inline (`● deprecated upstream: use String.prototype.padStart()`).
For PyPI this also covers [yanked releases](https://peps.python.org/pep-0592/)
(with the yank reason) and [PEP 792 project statuses](https://peps.python.org/pep-0792/):
a project **archived** by its maintainers or **quarantined** by PyPI's
admins — the malware-review state — is flagged on every change that
still pins it. crates.io yanks surface the same way, verified against
the sparse index itself so even a yank minutes old is caught. Composer
packages get the same treatment straight from Packagist: a bump onto an
[abandoned](https://getcomposer.org/doc/04-schema.md#abandoned) package
is flagged with the maintainer's suggested replacement
(`● deprecated upstream: abandoned; use symfony/mailer instead`). NuGet
deprecations come straight from the registration index too, including the
replacement package deps.dev drops
(`● deprecated upstream: legacy; use Azure.Storage.Common instead`), and
[author-unlisted](https://learn.microsoft.com/en-us/nuget/nuget-org/policies/deleting-packages)
versions surface in this lane as well. Hex
[retirements](https://hexdocs.pm/hex/Mix.Tasks.Hex.Retire.html) — the BEAM
world's per-version deprecation — land here too, straight from hex.pm,
with the maintainer's reason and message
(`● deprecated upstream: retired: deprecated — Not really maintained, please check out Tesla`).
Dart packages get theirs straight from pub.dev: a bump inside a package the
publisher [discontinued](https://dart.dev/tools/pub/publishing#discontinue)
is flagged with the named replacement
(`● deprecated upstream: discontinued on pub.dev; replaced by flutter_markdown_plus`),
and a bump onto a [retracted](https://dart.dev/tools/pub/publishing#retract)
version — one `dart pub` itself refuses to newly resolve — is flagged too.
JSR packages get theirs straight from jsr.io: bumps onto
[yanked](https://jsr.io/docs/packages#yanking-versions) versions and
packages archived on jsr.io land in this lane as well.
Go [retractions](https://go.dev/ref/mod#go-mod-file-retract) land here
straight from the module proxy — a bump onto a version its author
retracted shows the rationale comment from the module's own `go.mod`
(`● deprecated upstream: retracted: https://github.com/klauspost/compress/issues/1114`),
and modules with a `// Deprecated:` notice are flagged even before
deps.dev re-indexes them. CocoaPods deprecations come from the pod's own
podspec on the CDN — `pod trunk deprecate` rewrites it for every version —
including the named successor
(`● deprecated upstream: deprecated on CocoaPods; in favor of FirebaseCrashlytics`).
Terraform/OpenTofu providers get theirs from the registries themselves:
providers the registry warns about, providers delisted from
registry.terraform.io, providers the OpenTofu registry has blocked, and
HashiCorp's archived providers — replacement suggestion included
(`● deprecated upstream: This provider has been archived. Please use the
templatefile function or the Cloudinit provider instead`).
Maven artifacts get theirs from the POM itself: a bump onto a
[relocation](https://maven.apache.org/guides/mini/guide-relocation.html)
stub — the way Java projects announce moved coordinates — is flagged with
the new coordinates and the author's message
(`● deprecated upstream: relocated to com.mysql:mysql-connector-j —
MySQL Connector/J artifacts moved to reverse-DNS compliant Maven 2+
coordinates.`), which deps.dev doesn't model at all.
R packages get theirs straight from CRAN: a bump inside a package CRAN
has [archived](https://cran.r-project.org/web/packages/policies.html) —
removed from the index, so `install.packages()` stops resolving it — is
flagged (`● deprecated upstream: archived on CRAN (no longer installable
from the index)`).
Haskell packages get theirs straight from Hackage: bumps inside a
package on Hackage's [deprecation list](https://hackage.haskell.org/packages/deprecated)
are flagged with the maintainer's suggested replacements
(`● deprecated upstream: deprecated on Hackage; use crypton,
cryptohash-md5 or cryptohash-sha1 instead` — the `cryptonite` story), and
a bump onto an individual version marked deprecated via
[preferred-versions](https://hackage.haskell.org/packages/preferred) —
Hackage's yank equivalent, which solvers avoid — is flagged too.
Bazel modules get theirs straight from the [Bazel Central
Registry](https://registry.bazel.build): a bump onto a yanked version is
flagged with the registry's own reason (`● deprecated upstream: version
3.19.0 is yanked from the Bazel Central Registry: CVE-2022-3171` — BCR
yanks releases for CVEs and broken artifacts), and a lockfile built with
`--allow_yanked_versions` admits its yanked selections in
`selectedYankedVersions`, which lockvet reads even offline.
Conda packages get theirs straight from anaconda.org: a bump onto a
release whose artifacts carry the
[`broken` label](https://conda-forge.org/docs/maintainer/updating_pkgs/#removing-broken-packages)
— how conda-forge pulls a bad or malicious build without deleting it —
is flagged (`● deprecated upstream: marked broken on conda-forge
(artifacts moved to the broken label)`), with all-builds-broken and
some-builds-broken worded apart.
- **license change** — the incoming version is published under a different
license than the one it replaces:
```
↑ husky 4.3.8 → 5.0.9 MAJOR (direct)
● license change: MIT → non-standard
```
Relicensing mid-stream (MIT → BUSL, SSPL, "non-standard", …) is exactly
the kind of thing nobody spots in a 40-line lockfile diff. lockvet only
claims a change when the registry reports a license for *both* sides.
JSON output carries `old_license` / `new_license` on every covered change.
Both are gates too: `-fail-on deprecated,license`.
## Versions missing from the registry
When an incoming version is **unknown to the registry index** even though
other versions of the same package are listed, lockvet says so:
```
+ 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
```
Why this matters: **when a registry pulls a malicious release, this is what
the hole looks like.** Every malicious version in
[our case studies](docs/case-studies.md) — `event-stream@3.3.6`,
`flatmap-stream@0.1.1`, `chalk@5.6.1`, `ultralytics@8.3.41`, all six
versions of the August 2026 keyv/Cacheable worm replay — was
unpublished after the attack, so any lockfile still pinning one references
a version its own registry has disowned. lockvet flags that *without
needing an advisory to exist yet*. Here is the Sept 2025 chalk + debug npm
takeover replayed during its two-hour live window:

To keep it honest, the flag is deliberately conservative — it stays silent
for:
- packages the registry doesn't index at all (private registries, uncovered
ecosystems) — only packages whose *other* versions are listed can be
flagged;
- workspace members, git and path dependencies (the lockfile itself says
they don't come from the registry);
- Go pseudo-versions and pnpm-style decorated version strings.
For npm, PyPI, crates.io, RubyGems, Packagist, NuGet, Hex, Pub, JSR,
CocoaPods, Go, Maven, Hackage, Bazel and conda packages — and
Terraform/OpenTofu providers — the flag is **double-checked against the
registry itself**: deps.dev can lag
the registries by days, so before claiming anything lockvet fetches the
package's real version list from `registry.npmjs.org` / PyPI's simple
API / the crates.io sparse index / the RubyGems compact index /
Packagist's Composer metadata endpoint / NuGet's registration index /
hex.pm's and pub.dev's packages APIs / jsr.io's `meta.json` (the same
document Deno itself resolves against; yanks stay listed there, so
absence is real signal) / the sharded CocoaPods CDN index
(the same file `pod install` resolves against) / the Go module proxy /
the Terraform registry's per-version endpoint and the OpenTofu registry's
version index / the per-version POM on Maven Central (falling back to
Google's Maven repository, where the androidx world lives) / Hackage's
per-package version map (deprecated versions stay listed there, so
absence is real signal) / the Bazel Central Registry's per-module
`metadata.json` (yanked versions stay listed there — and the registry is
a git repository, versions are added, never silently dropped — so
absence is real signal) / anaconda.org's per-release endpoint (channels
pull malicious uploads outright, and marking a build broken keeps it
listed, so absence is real signal — claimed only after a HEAD on the
package document proves the package itself exists), and
clears the flag for any version the registry serves. What survives is a
version the registry itself no longer lists — and that distinction has
teeth: on crates.io yanked versions *stay in the index* while deleted
(malicious) ones vanish entirely, and on RubyGems a yank removes the
release from the index altogether, so a bump onto a yanked or
admin-deleted gem keeps the flag (replaying the 2019 `strong_password`
0.0.7 hijack trips it today). It isn't only malware, either: HashiCorp
pulled AWS provider 5.71.0 from the Terraform registry after a
regression — the tag still exists on GitHub, and a lockfile pinning
5.71.0 gets the ▲ flag from the registry's own 404.
NuGet is the one registry where "unlisted" is a *native* concept, and
lockvet splits it the way NuGet does: a stable version **absent from the
registration index entirely** — what an admin-deleted (malicious) package
looks like — keeps the ▲ flag, while a version its author merely
[unlisted](https://learn.microsoft.com/en-us/nuget/nuget-org/policies/deleting-packages)
(hidden from search, still restorable) lands in the deprecation lane
instead. Absent *prereleases* are cleared rather than flagged: on NuGet
those are overwhelmingly CI-feed daily builds (Roslyn nightlies and
friends) that `packages.lock.json` cannot attribute to their real feed.
A release published minutes ago may also not be indexed yet — the flag
tells you to *look*, not to panic. Gate on it with `-fail-on unlisted`;
JSON carries `unlisted` / `unlisted_versions`; SARIF emits an
`unlisted-version` warning; `queue` sorts affected PRs to the top.
## Typosquat suspects
The oldest trick on every registry: publish `lodahs`, `reqeusts` or
`rustdecimal` and wait for a typo. lockvet flags **new dependencies whose
name is one edit away from a popular package** on the same registry — when
the release is also young (≤ 30 days) or of unknown age:
```
+ 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
```
That example is the real 2019 PyPI attack — and it never got an OSV
advisory, so this flag is the only thing that catches it. The 2022
`rustdecimal` crates.io attack and npm's `lodahs` trip it too.
The check is **entirely local**: the popular-package lists (npm's
[high-impact list](https://github.com/wooorm/npm-high-impact), the
[top PyPI packages](https://github.com/hugovk/top-pypi-packages),
crates.io's most-downloaded, RubyGems' most-downloaded (via
[ecosyste.ms](https://ecosyste.ms)), and Packagist's
[most popular](https://packagist.org/explore/popular)) are embedded in the
binary, so it works with `-offline` and in the browser playground alike.
The Ruby side replays the Feb 2020 RubyGems campaign's lead example
(`rspec-mokcs` for `rspec-mocks` — 760+ malicious gems in one sweep).
Noise control, as always, is the point:
* only packages **entering** the tree are checked — a bump can't change
its name, and a name that has coexisted with its popular neighbour for
years is an unfortunate name, not an attack (age gate);
* name pairs the registry itself treats as the same package never flag
(PyPI's `-`/`_`/`.` equivalence, crates.io's `-`/`_` collision ban) —
while npm/RubyGems/Packagist separator swaps, which *are* distinct
packages, do (`rack_cache` next to `rack-cache` flags);
* the added package must not itself be on the popular list, and very
short names are skipped.
Gate on it with `-fail-on typosquat`; acknowledge a deliberate near-name
with a `typosquat:pkgname` line in `.lockvetignore`; JSON carries
`typosquat_of`; SARIF emits a `typosquat-suspect` warning; `queue` sorts
affected PRs to the top.
## GitHub Actions workflows are lockfiles too
Every `uses:` line pins a dependency, and Dependabot/Renovate bump those
pins like any other. lockvet reads workflow files —
`.github/workflows/*.yml`, composite `action.yml` files, Gitea/Forgejo
workflow dirs too — in every mode: `lockvet pr <url>`, local diffs,
`queue`, `audit`. Action bumps get context no plain diff shows:
- **SHA pins resolve to releases.** lockvet fetches the action
repository's tags over anonymous git smart-HTTP (one GET per repo, no
API, no rate limits) and reports the release each commit stands for —
Renovate digest bumps become readable:
```console
.github/workflows/build-container-image.yml (GitHub Actions)
↑ actions/checkout df4cb1c (=v6.0.3) → d23441a (=v6.1.0) minor (direct)
```
- **Floating majors resolve too.** `v4` is reported as the release it
points at *today*, and the jump is classified from the real versions —
so `v5 → v7` shows as `v5 (=v5.1.0) → v7 (=v7.0.1) MAJOR`, with the
verified compare link.
- **Advisories, evaluated properly.** OSV.dev has a "GitHub Actions"
ecosystem but its API cannot match versions against those advisories
server-side — lockvet fetches the affected ranges and evaluates them
itself, against the *resolved* release. A SHA pin affected by a GHSA is
caught even though no advisory ever names that hash, and a floating
`v4` isn't false-flagged for an advisory fixed inside the major.
- **▲ not a release.** A pinned commit that matches no tag in the
action's repository (and isn't a branch or its head). That is exactly
what the March 2025 [tj-actions/changed-files
attack](https://github.com/tj-actions/changed-files/issues/2463) looked
like: version tags across the repo were force-moved to a malicious
orphan commit. Replaying the attacked pin today:
```console
$ 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` is the actual malicious commit from that attack.)
`-fail-on unlisted` gates on it in CI.
Branch pins (`@main`) are a deliberate choice and stay quiet, actions
living outside github.com make no unlisted claims, and `-changelogs`
shows the release notes of every action release a bump pulls in.
### …and so is `.pre-commit-config.yaml`
Every `repos:` entry pins a hook repository at an exact `rev:` — code that
pre-commit clones and **runs on every commit on every contributor's
machine**. `pre-commit autoupdate` and Renovate bump these pins like any
other dependency, and they get the same treatment as workflow pins, on any
git forge (names keep their host):
```console
$ 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 revs are resolved to the release they equal, jumps are classified from
the real versions, `-changelogs` pulls each hook's release notes, and a rev
that matches no tag in the hook's repository raises **▲ not a release** —
the same signal that catches the tj-actions attack shape, for the code your
whole team runs before every commit. `repo: local` and `repo: meta` entries
are exempt, and `lockvet pkg pre-commit:owner/repo` vets a hook repo before
you add it.
### …and `.gitlab-ci.yml`
GitLab pipelines pin dependencies in two places, and lockvet reads both —
in `.gitlab-ci.yml`, suffix-named variants (`backend.gitlab-ci.yml`), and
CI fragments under `.gitlab/` or `.gitlab-ci/` directories:
- **`include: component:` pins** — [CI/CD Catalog
components](https://docs.gitlab.com/ci/components/) whose configuration
runs in every pipeline. Each pin is verified against the component
project's real tags, and GitLab's floating forms resolve to the release
they mean *today*: `@2` and `@2.0` (semver range shorthands) and
`@~latest` all show the concrete version, so a bump like `@2 → @~latest`
is readable instead of opaque:
```console
.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)
```
A pinned version that matches no tag in the component's project raises
**▲ not a release** — the tj-actions attack shape, for GitLab. Compare
links use the project's real tags, and `-changelogs` renders the
component's `CHANGELOG.md` sections for exactly the versions the bump
pulls in (GitLab-hosted repos included). `lockvet pkg
component:gitlab.com/components/opentofu/full-pipeline` vets one before
you include it.
- **`image:` and `services:` refs** — the containers every job runs in
get the same registry verification as Dockerfile `FROM` pins (digest
vs. tag, unknown tags, Docker Hub ages). Renovate bumps both kinds;
nothing else vets either.
`include: pr