# lockvet [Health: Active]

**Category:** 💻 Developer Tools  
**Repository:** https://github.com/matteo-sung/lockvet  
**GitHub Stars:** 0  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/lockvet

## Description
Explain any lockfile change: bumps, new & fixed vulns, release ages, deprecations — 29 formats

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "lockvet": {
    "command": "npx",
    "args": ["-y","lockvet"]
  }
}
```

## Documentation & README

# lockvet

[![ci](https://github.com/matteo-sung/lockvet/actions/workflows/ci.yml/badge.svg)](https://github.com/matteo-sung/lockvet/actions/workflows/ci.yml)
[![release](https://img.shields.io/github/v/release/matteo-sung/lockvet)](https://github.com/matteo-sung/lockvet/releases/latest)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/matteo-sung/lockvet/badge)](https://securityscorecards.dev/viewer/?uri=github.com/matteo-sung/lockvet)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13978/badge)](https://www.bestpractices.dev/projects/13978)
[![Go Reference](https://pkg.go.dev/badge/github.com/matteo-sung/lockvet.svg)](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).

![lockvet catching a RUSTSEC advisory hidden in a routine dependabot patch bump](https://raw.githubusercontent.com/matteo-sung/lockvet/HEAD/docs/demo.gif)

*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?](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md)** — event-stream,
the chalk/debug takeover, the Shai-Hulud worm (and its
[August 2026 return](https://github.com/matteo-sung/lockvet/blob/HEAD/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)
```

![lockvet queue triaging every open Dependabot/Renovate PR on mastodon/mastodon](https://raw.githubusercontent.com/matteo-sung/lockvet/HEAD/docs/queue-demo.gif)

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:

![lockvet audit sweeping a tree the day an attack breaks: compromised npm and PyPI pins surface with malware advisories and the not-in-registry-index takedown signal](https://raw.githubusercontent.com/matteo-sung/lockvet/HEAD/docs/audit-demo.gif)

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:

![lockvet pkg vetting a typo'd package name that turns out to be squatted malware, then a deprecated classic](https://raw.githubusercontent.com/matteo-sung/lockvet/HEAD/docs/pkg-demo.gif)

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](https://github.com/matteo-sung/lockvet/blob/HEAD/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:

![lockvet replaying the Sept 2025 chalk+debug npm takeover: both malicious versions flagged as not in the registry index](https://raw.githubusercontent.com/matteo-sung/lockvet/HEAD/docs/supplychain-demo.gif)

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: project:` + `ref:` pins are tracked as version rows but stay
claim-free: the file never records which GitLab instance hosts the
project, so lockvet won't guess. `$CI_SERVER_FQDN`-prefixed component
pins are claim-free too **when read from disk** — but in `mr`, `compare`,
and `queue` modes the URL itself names the instance, so lockvet resolves
them against that host and they get the full tag-verification treatment.
Templated refs (`$VARIABLES`) and `include: local:/template:/remote:`
entries pin nothing.

### …and `.circleci/config.yml`

CircleCI pipelines pin dependencies in two places, and lockvet reads both —
in `.circleci/config.yml` and in continuation configs / fragments under the
`.circleci/` directory:

- **`orbs:` pins** — reusable CI packages from the [CircleCI orb
  registry](https://circleci.com/developer/orbs), whose commands run in
  every pipeline. There is no OSV ecosystem and no deps.dev system for
  orbs, so lockvet asks the registry itself: release ages (a bump onto an
  hours-old orb release is worth a pause), verified compare links and
  `-changelogs` release notes from the orb's own source repository, and —
  because published orb versions are immutable, deleting one is a
  CircleCI-support action reserved for security problems — a
  registry-verified **▲ not in registry index** check for pins the full
  version list omits. Floating pins resolve to the release they fetch
  *today*:

  ```console
  .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:*` versions are mutable by design and expire — they stay
  claim-free, as do inline orb definitions and templated references.
  `lockvet pkg orb:circleci/node` vets an orb before you add it.
- **docker executor `- image:` refs** — the containers every job runs in
  get the same registry verification as Dockerfile `FROM` pins (digest
  vs. tag, unknown tags, Docker Hub ages). `machine:` executor images are
  CircleCI VM image labels, not OCI refs, and are skipped.

Renovate's `circleci` manager bumps both kinds; nothing else vets either.

### …and `.tool-versions`, `mise.toml`, `.nvmrc` & friends

The toolchain a project runs — node, python, terraform, kubectl — is
pinned in asdf/mise files, `mise up` and Renovate's `asdf`/`mise` managers
bump those pins like lockfile entries, and nothing vets them. lockvet
reads both formats and verifies every pin **against the tool's own
repository tags**, speaking each repo's tag dialect (`go1.23.4`, ruby's
`v3_4_2`, Erlang's `OTP-27.1.2`, `jq-1.7.1`, `kustomize/v5.8.1`,
`swift-6.1-RELEASE` — ~90 tools curated and continuously validated):

```console
.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
```

That `(=tag)` is a *verified resolution*: the compare link and
`-changelogs` release notes come from the real tag. A version-shaped pin
matching **no** tag flags **▲ not a release** — the same check that
catches the tj-actions attack shape in workflows. mise's fuzzy pins
resolve to what they'd install today (`node = "22"` → `(=v22.23.2)`).

mise's backend-prefixed tools are real registry packages and get lockvet's
full registry treatment — `npm:prettier` → npm, `cargo:eza` → crates.io,
`pipx:black` → PyPI, `gem:`, `dotnet:`, `go:` likewise (advisories, ages,
deprecations, typosquats); `ubi:`/`aqua:`/`github:`/`spm:` tools are
verified against the named GitHub repo's tags; and `gradle`, `maven` and
`sbt` pins ride the registries lockvet already verifies (the Gradle
distribution index, Maven Central — where stale `sbt` pins carry real
GHSAs). Plugin-sourced tools (`asdf:`, `vfox:`), `system`, `latest`,
`lts`, `ref:` pins and vendor-prefixed Java builds honestly claim
nothing. `lockvet pkg tool:node` vets a tool release before you switch.

The same treatment covers the **single-tool version files** every version
manager reads — `.nvmrc` / `.node-version` (nvm, fnm, nodenv),
`.python-version` (pyenv, incl. multi-version fallbacks), `.ruby-version`
(rbenv/rvm — `ruby-3.3.4` spelling understood), `.go-version`,
`.java-version`, `.terraform-version` / `.terragrunt-version` (tfenv,
tgenv) — plus SDKMAN's `.sdkmanrc`, whose `gradle=`/`maven=`/`sbt=` pins
ride the registries lockvet already verifies. Renovate bumps all of these
with dedicated managers; nothing else vets the bumps.

And **`mise.lock`** — mise's own lockfile — gets the full lockfile
treatment: beyond exact resolved versions, it records per-platform
sha256/blake3 checksums of the artifacts mise downloads, and lockvet
reads them as **integrity pins**. A checksum that changes while its
version stays put is the poisoned-download shape and flags
**‼ REPINNED**; newly added platforms never flag (artifact-scoped
comparison, the same rule Gradle verification metadata gets):

```console
mise.lock (mise/asdf)
  ‼ node 20.11.0 (=v20.11.0)  REPINNED (version unchanged)  (direct)
```

Why this class of pin matters is [case study 11](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md#11-codecov-bash-uploader-2021--the-poisoned-toolchain-download)
— the Codecov bash uploader replayed as a toolchain checksum swap,
including the Gradle-wrapper variant where lockvet cross-checks the pinned
`distributionSha256Sum` against the checksum Gradle actually publishes.

## Container base images

Dockerfiles pin dependencies too — the `FROM` line is a version pin like any
other, Renovate and Dependabot bump it like any other, and nobody reviews it
like any other. lockvet reads `Dockerfile` / `Containerfile` (multi-stage
aware; `ARG` defaults expanded; variant names like `Dockerfile.alpine` and
`dev.Dockerfile` included), `COPY --from=` images, the `# syntax=` BuildKit
directive, and every `image:` in Compose files — then verifies the change
against the **image registry itself**, since whole images have no OSV
ecosystem and no deps.dev system:

```console
$ 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 pins are checked against what the tag serves *today*** — an
  exact match (index digest or any per-platform manifest digest) shows
  `✔ digest verified`; a pinned digest the tag no longer serves lands in the
  `‼ tag mismatch` lane (image tags *do* move — a rebuilt base looks like
  this, and so does a pin that never came from the tag); a digest the
  registry has never seen at all is `▲` unlisted.
- **A tag the registry doesn't serve** — while the image repository itself
  is known — is what a deleted tag, a wrong repository, or a fabricated
  reference looks like: `▲ not in the registry`, `-fail-on unlisted` gates
  on it.
- **Same-tag digest bumps render as routine `↻`** (that's just the registry
  rebuilding the tag), with the new pin verified — not as an alarming
  repin.
- **Release ages and the ⏱ fresh flag** come from Docker Hub's per-tag
  `last_updated` for Hub-hosted images.

Lookups run against a fixed allowlist of public registries (Docker Hub,
ghcr.io, quay.io, mcr.microsoft.com, gcr.io, registry.k8s.io — with
legacy `k8s.gcr.io` pins verified against registry.k8s.io, where every
request redirects — public.ecr.aws, registry.gitlab.com). Images from any other host — private
registries, mirrors, `localhost` — are **never queried**: lockvet doesn't
send requests to hosts it doesn't recognize. Compose services built from a
local `build:` context, stage-name references, and references that still
contain an unresolved `${VARIABLE}` are skipped rather than guessed at.

**Dev Containers are image pins too.** `devcontainer.json` (and
`.devcontainer.json`, subfolder variants included) names the container
your editor and Codespaces actually build: the `"image"` is verified
exactly like a Dockerfile `FROM`, and every Feature under `"features"` —
`ghcr.io/devcontainers/features/go:1` and friends — is an OCI artifact
pulled through the same distribution API, so its tag (or `@sha256:`
digest pin) is verified against the registry the same way. Renovate's
devcontainer manager bumps exactly these references; nothing else vets
them. JSONC is understood (the VS Code templates ship with comments);
compose-/Dockerfile-based configs, local-path Features, tarball URIs and
legacy host-less Feature ids honestly pin nothing here.

### Kubernetes manifests & kustomizations

The same treatment covers what your cluster pulls. Every `image:` under a
`containers:` / `initContainers:` / `ephemeralContainers:` list is a pin
(Deployments, StatefulSets, CronJobs — any nesting), and
`kustomization.yaml` `images:` transformers (`newTag:` / `digest:` — the
values Renovate and Flux image automation bump) are pins too; both get the
full registry verification above. `kustomization.yaml` `helmCharts:`
entries are checked against the chart repository's own `index.yaml`,
exactly like `Chart.yaml` dependencies (release ages, deprecated charts,
the prune-guarded unlisted check).

**Flux CRs are pins too.** A `HelmRelease` whose `spec.chart.spec.version`
is an exact pin becomes a Helm package: when the `sourceRef`'s
`HelmRepository` lives in the same file, its URL comes along and the bump
is verified against that chart repository's `index.yaml` (ages, deprecated
charts, unlisted versions, `-changelogs`); a sourceRef defined in another
file — or an OCI or Git source — still yields the version row and jump
classification, just no registry claims (there is no `index.yaml` to
honestly check). Modern chartRef setups are covered from the other side:
an `OCIRepository` with `spec.url: oci://…` plus `spec.ref.tag` /
`spec.ref.digest` is exactly an image pin, so Renovate's `ref.tag` bumps
get the Dockerfile registry treatment above — including ✔/‼ digest
verification. Version ranges (`spec.ref.semver`, `>=1.0.0`) pin nothing
and are skipped. And inside a `HelmRelease`'s `values:`, the standard
chart image mapping — `image:` with `repository:` and `tag:` children
(Bitnami-style `registry:` and `digest:` too) — is read as an image pin:
that's the exact shape Flux image automation and Renovate bump in
home-ops repos, old Flux v1 `Auto-release` commits included.

**Argo CD Application CRs too.** An `Application` whose `spec.source`
carries `chart:` pins that chart at `targetRevision:`, and the inline
`repoURL:` is the chart repository itself — so the bump is verified
against that repository's `index.yaml` the same way (ages, deprecated
charts, unlisted versions, `-changelogs`). Multi-source `spec.sources`
lists work per item, and `ApplicationSet` templates
(`spec.template.spec.source`/`.sources`) get the same reading — with
`{{ … }}` parameters treated as what they are, not literal pins. Only
exact revisions count: a `targetRevision` like `1.2.*`, `>=1.0.0`, `*`
or a branch name tracks the repository and pins nothing, and Git-source
Applications (`path:` instead of `chart:`) are left alone. OCI
`repoURL`s yield the version row without registry claims (no
`index.yaml` to check).

**Helm values files too.** The same chart image convention — an `image:`
mapping with `repository:` and `tag:` children (Bitnami-style
`registry:` and `digest:` too) — is read straight out of values files:
`values.yaml` at chart roots, `values-prod.yaml` / `app.values.yaml`
overlays, and arbitrarily named helmfile or Argo value overlays under
GitOps directories. That's the exact shape Renovate's `helm-values`
manager bumps, and nothing else vets it — the values file names no chart
version and charts have no OSV ecosystem, but the image pins inside get
the full registry treatment above (digest verification, unknown tags,
ages). Convention-named values files also accept the bare scalar form
(`image: ghcr.io/owner/app:v1.2.3`) when a tag or digest is present —
`image: nginx` pins nothing; files merely discovered by directory
convention or content sniffing require the structured
`repository:`/`tag:` shape, which is distinctively Helm. Block-scalar
bodies (`config: |` — embedded app configuration) are opaque: an
`image:` line inside one belongs to the embedded app, never to this
file's pins.

Since plain manifests have no reserved filename, lockvet takes
`*.yaml`/`*.yml` files under the conventional directories (`k8s/`,
`kubernetes/`, `manifests/`, `deploy/`, `overlays/`, `base/`,
`clusters/`, `gitops/`, `apps/`, `infrastructure/`, `infra/`, `flux/`,
`flux-system/`, `chart/`, `charts/`, `argocd/`, `argo/`, `argo-cd/`,
`applications/`, `applicationsets/`), files named `*.k8s.yaml`, and the
conventional basenames (`deployment.yaml`, `statefulset.yaml`,
`daemonset.yaml`, `replicaset.yaml`, `cronjob.yaml`, `pod.yaml`, plus the
Flux conventions `helmrelease.yaml`, `helm-release.yaml`, `release.yaml`,
`ocirepository.yaml`, `helmrepository.yaml` and the Argo CD conventions
`application.yaml`, `applicationset.yaml`, `appset.yaml`) — strictly: a
document must
declare top-level `apiVersion:` and `kind:` to count, matched files that
aren't Kubernetes YAML are silently ignored, and anything under a Helm
`templates/` directory is excluded (those image references are template
interpolations, not what the cluster resolves). Image values still
carrying `{{ … }}`, `$(VAR)` or `${VAR}` are skipped rather than guessed
at.

In **diff modes** (local git, `pr`/`mr`/`compare`, `queue`), discovery
goes further: every other changed `*.yaml`/`*.yml` is content-sniffed
with the same strict gate, so GitOps repos with arbitrary layouts
(`default/nzbget/nzbget.yaml`) work without matching any convention —
the changed-file list is small, so over-matching costs nothing. Remote
fetches cap sniff candidates at 20 files per diff to keep big refactor
PRs cheap. Directory walks (`lockvet audit`) keep the convention-based
discovery: sniffing every YAML in a large tree would read far more than
it finds.

## Integrity & resolution changes

Lockfiles don't just pin versions — they pin **bytes** (integrity hashes)
and **where to get them** (resolution URLs). lockvet is, as far as we know,
the only lockfile differ that reads them:

**Integrity changed, version didn't** ‼ — registries never change a
published artifact. If a diff keeps `lodash 4.17.21` but swaps its
`sha512-…`, the tarball that pin expects has been replaced: registry-side
tampering, a hijacked mirror, or a hand-edited lockfile. All of them are
"stop and find out why" events:

```
‼ 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
```

Hashes are compared per algorithm, so a lockfile-format upgrade that
switches `sha1` → `sha512` (or a yarn berry cache-key bump) never flags,
and Python hash *sets* may legitimately grow as wheels are added — only a
fully disjoint set flags. Each shared algorithm is judged on its own: the
same bytes can never hash two ways under one algorithm, so a yarn-classic
pin whose SRI `sha512` was swapped flags even when the sha1 `resolved`
fragment was left intact as camouflage (yarn verifies the SRI line at
install time, not the fragment) — and a conda lock's `md5` swapped under
an intact `sha256` line flags the same way (conda locks may carry either
hash or both; each is compared under its own label). Hex's dual checksums
get the same treatment *within* one algorithm: `mix.lock` and `rebar.lock`
record two sha256 values per package that digest different byte streams —
the tarball contents (inner, `pkg_hash`) and the tarball file (outer,
`pkg_hash_ext`) — so each is scoped on its own
(`contents#sha256:`/`tarball#sha256:`) and a one-sided swap flags instead
of hiding behind its untouched sibling; the old-format → new-format
migration that adds the outer checksum stays quiet. The one pooled exception is Terraform's
`h1`/`zh` pair: `h1` hashes exist only for the platforms the lock was
built with, so a platform re-lock that replaces the whole `h1` set stays
quiet while the registry-published `zh` set still vouches for the release.

**Integrity removed, version didn't** ‼ — a pin that carried a content
hash and now carries *none* is just as loud. Nothing verifies the
artifact anymore, and a hash deleted — or swapped to a malformed value no
algorithm claims — is how a tampered pin stays quiet:

```
‼ 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
```

Two legitimate shapes stay silent: a dependency switched to a git/path
override at the same version (the pin moved to a commit — registry hashes
rightly vanish), and a whole file losing its hashes at once (a lockfile
regenerated by tooling that writes none — a config change, not a
per-package attack).

**Resolution moved to the public registry** ⇄ — the classic
[dependency-confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610)
attack publishes a higher version of an *internal* package name on the
public registry, and the resolver takes the bait. In the lockfile diff
that is perfectly visible: the package's resolved host flips from your
private registry to `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
```

Only the private → public direction flags (adopting a mirror is routine),
a move whose content hash provably didn't change is quiet, and when five
or more packages move between the same two hosts lockvet treats it as a
registry migration (a config change) and stays quiet.

The same check reads **release-tag pins**: `Package.resolved` (Swift) and
`composer.lock` record the commit a released tag pointed at, and Julia's
`Manifest.toml` records the registry's source-tree hash — a version that
keeps its number but changes its commit means **the upstream tag was
moved**, and flags the same way.

Both signals are computed **entirely offline** from the two lockfiles —
they work with `-offline`, in air-gapped CI, and in the browser
playground. Formats that record the data: 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 artifact
hashes and index hosts — the Python confusion shape flags too),
Gemfile.lock (resolution hosts, plus per-gem sha256 from the Bundler ≥ 2.6
`CHECKSUMS` section — platform gems keep their own hash),
`go.sum` (parsed as a pins-only ledger: version churn is go.mod's story
and produces no duplicate rows, but a same-version `h1:` hash edit — the
poisoned-go.sum shape, the only way a tampered module gets past
`go mod verify`, and for `GOPRIVATE` modules the only check there is —
flags ‼, with the SARIF alert anchored on the exact edited line),
mix.lock, rebar.lock (`pkg_hash`/`pkg_hash_ext`), Gleam's manifest.toml, pubspec.lock (hashes **and** hosts —
the Dart confusion shape flags too), Podfile.lock (trunk podspec
checksums), Package.resolved, composer.lock, Julia Manifest.toml,
`.terraform.lock.hcl` (provider `h1:`/`zh:` hashes), conda/pixi
locks (PyPI wheel hashes + channel hosts), `MODULE.bazel.lock` (the
registry `source.json` hash per selected module — it changing for the
same version means the registry's description of that release changed
underneath you — plus registry hosts, so a module moving from a private
registry onto the public BCR flags the confusion lane; old-style
Bazel 7.0/7.1 lockfiles pin the archive's own SRI hash), `build.zig.zon`
(Zig: no registry exists at all, so the manifest's own url + hash pins are
the whole story — a same-version hash swap or a same-version source
re-point is exactly what a smuggled edit looks like, while swapping a
dependency to a fork *with* a new pin is routine, visible business and
stays quiet), and `flake.lock` (Nix: a
same-revision `narHash` change means the pinned tree was replaced — a
git revision's content never changes — and an input re-pointed at a
different repository flags the ⇄ lane; a re-point whose narHash proves
the content identical, like a plain repo rename, stays quiet). Two deliberate omissions,
because real history proved them noisy: NuGet's `contentHash` (NuGet's
2018 repository-resigning changed every older package's hash) and conda
artifact hashes (conda rebuilds the same version under new build
numbers routinely). Gate with `-fail-on
integrity,registry`; JSON carries `integrity_changed` /
`integrity_changed_versions` / `old_host` / `new_host` / `registry_moved`;
SARIF emits `integrity-changed` (error) and `registry-moved` (warning);
`queue` sorts affected PRs to the top. A full replay of the 2021
dependency-confusion attack — including the same-version variant that no
other diff shows — is [case study 9](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md#9-dependency-confusion-2021--the-attack-the-lockfile-itself-records).

### SwiftPM pins verified against upstream tags

One check in this family *is* online. `Package.resolved` records a
version, the commit its tag resolved to, **and** the repository it all
came from — which makes the pin verifiable against the source of truth.
lockvet fetches the repository's real tag list (one anonymous git
smart-HTTP request per repo, the same channel `git fetch` uses — no API,
no rate limits) and checks two things for every incoming pin:

- the version's **tag still exists**. Version pins only ever resolve
  from tags, so a pinned version with no matching tag upstream means the
  tag was deleted or renamed after someone resolved it → ▲ *not a
  release*;
- the pinned commit is **what the tag points at today**. Released tags
  are supposed to be immutable — a mismatch means the tag has been
  re-pointed since this was resolved (the tj-actions attack shipped
  exactly like that), or the lockfile was edited to fetch a different
  commit while displaying an innocent version → ‼ *tag mismatch*:

```
↑ 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
```

The same tag-list verification backs [workflow `uses:`
pins](#github-actions-workflows-are-lockfiles-too). Annotated tags are
peeled to their commit, `v`-prefixed tags match unprefixed versions, and
repositories that can't be fetched anonymously (private, moved) produce
no claims at all. Gate with `-fail-on integrity` (tag mismatch) and
`-fail-on unlisted` (deleted tag); SARIF emits `tag-mismatch` (error);
`lockvet audit` verifies every Swift pin you currently hold; and
`lockvet pkg swift:Alamofire/Alamofire` vets a Swift package (latest =
highest stable tag) before you add it.

## Install scripts added by a bump

npm packages can run arbitrary code at install time (`preinstall` /
`install` / `postinstall`). Most packages never do — so a routine-looking
bump that suddenly **adds** install scripts deserves a hard look before you
merge. Gaining execution-on-install is how the
[Shai-Hulud worm](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md#4-the-shai-hulud-worm-sept-2025) —
and its [August 2026 keyv/Cacheable return](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md#12-the-keyv--cacheable-worm-august-2026--shai-hulud-returns) —
and plenty of smaller npm attacks delivered their payload.

lockvet asks the npm registry which versions run install scripts and flags
the transition:

```
↑ 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
```

Only *transitions* are flagged, so the signal stays quiet: a brand-new
dependency with install scripts is ordinary (native builds), and a package
that has always had them tells you nothing new. In a 92-change `npm/cli`
release diff and a 165-change React one, it flags nothing at all.

Gate on it with `-fail-on scripts`; JSON carries `install_scripts_added` /
`scripted_versions`; SARIF emits an `install-scripts-added` warning;
`queue` sorts affected PRs to the top. npm-only for now (other ecosystems
either have no install hooks or don't expose them in registry metadata).

## Provenance dropped by a bump

A growing share of npm packages publish with [sigstore provenance
attestations](https://docs.npmjs.com/generating-provenance-statements),
PyPI projects with [PEP 740 attestations](https://peps.python.org/pep-0740/),
crates.io crates via [trusted publishing](https://rust-lang.github.io/rfcs/3691-trusted-publishing-cratesio.html),
and RubyGems releases with [sigstore attestations](https://guides.rubygems.org/trusted-publishing/)
— cryptographic proof (or, for crates.io, a registry-recorded guarantee)
that the release was built and published by the project's own CI from
its public repo. That proof has a useful
property for reviewers: **a stolen publish token can upload a release,
but it can't make the project's pipeline attest it.**

So when a package that consistently attests suddenly publishes a version
with no provenance, lockvet flags it:

```
↑ 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
```

Three conditions keep it near-silent: the outgoing version must be
attested, the package's provenance practice must be *established* (the
top stable versions below the incoming one all attested — one-off
adopters don't count), and the incoming release must be **young
(≤ 30 days)** — this is a while-it's-happening tripwire for the window
before advisories exist, not an audit of history. Under those rules it
flags nothing at all across current bumps of ~100 top npm packages,
exactly one bump across the top 1 000 PyPI packages — a real 7-day-old
release that broke its project's unbroken attestation streak (almost
certainly a benign manual publish, which is precisely the "worth a look"
this flag exists for) — and zero across the top 100 crates, a tenth of
which already publish via trusted pipelines (`cc`, `time`, `getrandom`,
…). It would fire the moment a token thief ships a release outside the
project's CI.

Gate on it with `-fail-on provenance`; JSON carries `provenance_dropped`
/ `unattested_versions`; SARIF emits a `provenance-dropped` warning;
`queue` sorts affected PRs to the top. Comes from the same registry
documents as the install-scripts and unlisted checks — no extra
requests. Covers npm, PyPI, crates.io and RubyGems (for PyPI a version
only counts as attested when *every* file of the release carries
provenance, and only a release with *no* attested files is ever flagged
— mixed uploads are a publishing setup, not a signal; for crates.io the
registry's `trustpub_data` on each version is the source of truth; for
RubyGems it's the per-release attestations API — adoption there is
still small, so today the gates simply stay silent, and protection
switches on automatically as gems start attesting).

## Release notes inline (`-changelogs`)

Every bump already links to the verified upstream tag-to-tag diff. Add
`-changelogs` and lockvet also *fetches the release notes* — including the
releases a multi-version jump skips over:

```
↑ body-parser  1.19.0 → 1.20.1  minor  via express  (3y old)
    ▤ 1.20.1
        * deps: qs@6.11.0
        * 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
```

So `1.19.0 → 1.20.1` shows what landed in 1.19.1, 1.19.2, 1.20.0, *and*
1.20.1 (up to 5 releases per package; the compare link covers the rest).
In `-md` mode each package's notes become a collapsed `<details>` block —
in a PR comment that reads like Dependabot's release-notes section, except
it covers **every** package in the diff, transitives included. Works in
every mode (`pr`, `mr`, `compare`, `diff`, the Action via
`changelogs: 'true'`, MCP via `"changelogs": true`).

Notes come from GitHub Releases of the (verified) source repo — and when a
project doesn't publish releases (Phoenix stopped in 2020; plenty of crates
and gems never started; GitLab-, Codeberg- and Bitbucket-hosted projects
don't have them), lockvet falls back to the repository's **changelog file**:
`CHANGELOG.md` / `CHANGES.md` / `NEWS.md` / `HISTORY.md` fetched at the
verified tag, sliced into the exact version sections your bump pulls in.
Excerpts are trimmed and control-character-sanitized either way. The
releases path uses the GitHub API — anonymous works for a handful of
packages, `GITHUB_TOKEN` / a logged-in `gh` raises the limit; changelog
files come off the forge's raw endpoint, which is not rate-limited.

## Supported lockfiles

| Ecosystem | Files |
|---|---|
| JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock` (v1 & berry), `bun.lock`, `deno.lock` — JSR packages in `deno.lock` get release ages, yank/archive flags and the registry-verified unlisted check straight from jsr.io |
| Rust | `Cargo.lock` |
| Python | `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt` (`==` pins), `pylock.toml` + named `pylock.*.toml` (PEP 751 — the standardized lockfile written by uv, pip, pipenv and pdm; vcs/directory/path sources exempt from registry checks) |
| Go | `go.mod` — `replace` directives resolve to the *effective* pin: `replace m => m v1.2.3` overrides the required version (a replace-directive downgrade renders as a downgrade, advisories and all), `replace a => b v1.2.3` renders as a removed + an introduced (the resolution-hijack shape), and filesystem replaces (`=> ../local`) stay exempt from registry claims — and `go.sum` as a pins-only ledger: version churn stays go.mod's story, but a same-version `h1:` hash edit (the poisoned-go.sum shape: a tampered module only installs if go.sum is edited to agree, and for `GOPRIVATE` modules no checksum database will ever object) flags ‼ |
| PHP | `composer.lock` |
| Ruby | `Gemfile.lock` (including Appraisal's `*.gemfile.lock` variants; Bundler ≥ 2.6 `CHECKSUMS` hashes become integrity pins) |
| Elixir | `mix.lock` — release ages, retirements and the unlisted check straight from hex.pm; renamed forks (`{:hex, :ts_chatterbox, …}`) resolve under their real Hex package name |
| Erlang | `rebar.lock` — Hex advisories, ages/retirements/unlisted from hex.pm, direct deps from the lock's own level numbers, `pkg_hash`/`pkg_hash_ext` checksums as integrity pins each scoped on its own — a same-version change of *either* hash surfaces as ‼ repinned, even with the other left intact; renamed forks (`{<<"uuid">>,{pkg,<<"uuid_erl">>,…}}`) resolve under their real Hex package name; git/path deps exempt |
| Dart / Flutter | `pubspec.lock` — release ages, discontinued/retracted flags and the unlisted check straight from pub.dev (git/path/SDK/private-host packages exempt) |
| Java / JVM | `pom.xml` (Maven has no npm-style lockfile — POM versions *are* the pins, and Dependabot bumps them in place: `${property}`-resolved versions, `<parent>` bumps like `spring-boot-starter-parent`, BOM imports, plugins and profiles; `${project.version}` siblings honestly claim-free), `build.gradle` / `build.gradle.kts` (plus `settings.gradle(.kts)` and any `*.gradle(.kts)` script — for most Gradle projects the build script *is* the pin file: coordinate literals, Groovy map / Kotlin named-argument forms, and `plugins { id(…) version … }` blocks resolved as Plugin Portal markers; `$version` interpolations resolve against same-file `ext`/`val` assignments, and anything defined elsewhere honestly claims nothing), `gradle.lockfile`, `libs.versions.toml` (Gradle version catalogs — the file Renovate and Dependabot actually bump, plugins resolved as their `id:id.gradle.plugin` marker against the Gradle Plugin Portal) `verification-metadata.xml` (Gradle dependency verification — the wall-of-XML diff nobody reviews becomes package changes, and a same-version checksum change surfaces as ‼ repinned) and the **build-tool wrappers themselves** — `gradle-wrapper.properties` `distributionUrl` pins are verified against Gradle's own version index at services.gradle.org (release ages, withdrawn-as-broken releases, versions the index has never heard of, and a pinned `distributionSha256Sum` cross-checked against the checksum Gradle actually publishes: the wrapper will happily verify a poisoned distribution against a poisoned checksum, so a pin matching no official checksum lands in the ‼ lane), while `.mvn/wrapper/maven-wrapper.properties` pins parse as ordinary Maven coordinates and get the full Maven treatment (corporate mirror hosts honestly claim-free in both) — bumps onto [relocation stubs](https://maven.apache.org/guides/mini/guide-relocation.html) (`mysql:mysql-connector-java` → `com.mysql:mysql-connector-j`) land in the deprecation lane, and unlisted checks are verified against Maven Central, Google's Maven repository and the Plugin Portal |
| Scala | `build.sbt` (and any `*.sbt`, plus the `project/Dependencies.scala` convention — Scala has no npm-style lockfile, the build definition *is* the pin file and scala-steward bumps coordinates in place): `"org" %% "artifact" % "1.2.3"` resolves the Scala binary suffix from the same file's `scalaVersion` so the *registry* artifact (`cats-core_2.13`) gets OSV advisories, release ages and unlisted checks; version `val`s resolve within the file; `addSbtPlugin(…)` pins resolve under the sbt cross-suffix (`sbt-scalafmt_2.12_1.0` — the name Central actually serves); `CrossVersion.full` / `for3Use2_13` map to their documented suffixes; anything unknowable from the file alone (no `scalaVersion` in sight, `%%%` platform artifacts) keeps its version row but honestly claims nothing. `project/build.properties` pins sbt itself — `org.scala-sbt:sbt` on Central, with real advisories (try `lockvet pkg maven:org.scala-sbt:sbt@1.10.7`) |
| .NET | `packages.lock.json` |
| Swift | `Package.resolved` |
| C / C++ | `conan.lock` (Conan 2 flat lockfiles and Conan 1 graph locks) — release ages straight from ConanCenter, dated by each version's oldest recipe revision so re-exports don't make old releases look fresh (refs with a user/channel exempt; a ref doesn't record its remote, so no unlisted claims). The `#recipe-revision` in each ref is tracked as a pin: a revision change under an unchanged version — the recipe (build script) changing under your feet — renders as a neutral ↻ `recipe revision` row, like a container's same-tag digest bump: visible where it used to be silent, but not an alarm, because ConanCenter re-exports recipes for old versions routinely |
| vcpkg | `vcpkg.json` + `vcpkg-configuration.json` — the `builtin-baseline` commit IS the lockfile (every dependency resolves to what the registry recorded at that commit): baselines are verified to exist in the registry's repository (a baseline only reachable in a fork is the poisoned-registry shape) with the commit's own date as the age and rev…rev compare links; `overrides` version pins are checked against microsoft/vcpkg's append-only versions database; registry baselines in `vcpkg-configuration.json` get the same treatment, and a swapped `default-registry` surfaces as a resolution move (overrides under `overlay-ports` or custom registries honestly claim nothing) |
| Bazel | `MODULE.bazel.lock` (both the modern registry-hash shape and the Bazel 7.0/7.1 dep-graph shape) and `MODULE.bazel` itself (`bazel_dep` pins exact versions and update bots bump them in place, go.mod-style) — yanked versions with the registry's reason, source-repo changelog links and the registry-verified unlisted check straight from the Bazel Central Registry (modules from private registries and `git_override`/`local_path_override` exempt) |
| iOS / CocoaPods | `Podfile.lock` — release ages, deprecated-pod flags, license changes and the unlisted check straight from the CocoaPods CDN and trunk registry (git/path pods and private specs repos exempt) |
| R | `renv.lock` — CRAN + Bioconductor advisories (`RSEC-…`); release ages, archived-package flags, license changes, changelog links and a mirror-lag-safe unlisted check straight from CRAN itself (GitHub/GitLab/local/git installs exempt) |
| Julia | `Manifest.toml` (also `Manifest-v1.11.toml` style) — General-registry advisories (`JLSEC-…`), via-chains from the manifest's dependency graph |
| Haskell | `stack.yaml.lock`, `cabal.project.freeze`, `cabal.config` (Stackage pins) — Hackage advisories (`HSEC-…`); release ages, deprecated packages (with the maintainer's suggested replacements) and deprecated versions, verified changelog links and the registry-verified unlisted check straight from Hackage |
| Gleam | `manifest.toml` — Hex advisories, via-chains, direct deps from `[requirements]`; ages/retirements/unlisted from hex.pm (git/path packages exempt) |
| Conda | `pixi.lock` (v4–v7), `conda-lock.yml` (also `*.pixi.lock`, `*.conda-lock.yml`) — release ages, broken-release flags, license changes and the registry-verified unlisted check straight from anaconda.org (any channel the lockfile resolves from — conda-forge, bioconda, …); pip packages inside get full PyPI vulnerability/age data. Artifact sha256 hashes are read as **integrity pins scoped to the artifact filename** (`openssl-3.3.1-h4bc722e_2.conda#sha256:…`), so a same-artifact hash swap flags ‼ REPINNED while conda-forge's routine same-version rebuilds under new build numbers stay quiet — and locally-built sources (path installs like `- pypi: ./rerun_py`, git checkouts) record no integrity at all, since their hash legitimately changes on every rebuild |
| Terraform / OpenTofu | `.terraform.lock.hcl` (also suffix-named `*.terraform.lock.hcl`) — release ages, archived/delisted/blocked-provider flags, verified changelog links and the registry-verified unlisted check straight from the Terraform & OpenTofu registries (custom registry hosts exempt) |
| Helm | `Chart.lock`, `Chart.yaml` (most repos commit only the manifest — exact version pins are read, range constraints skipped), `requirements.yaml` (Helm v2, content-sniffed apart from Ansible Galaxy files), `requirements.lock` (Helm v2; pip-style `requirements.lock` from rye is auto-detected and treated as PyPI) — release ages, deprecated-chart flags, verified changelog links and a prune-guarded unlisted check straight from each chart repository's own `index.yaml`, the exact document `helm dependency update` resolves against (`oci://` and `file://` references exempt) |
| Ansible | `requirements.yml` / `requirements.yaml` — Galaxy collections (`namespace.name`) and classic roles, exact version pins only (range constraints skipped); release ages, deprecated-collection flags, verified changelog links and the registry-verified unlisted check straight from galaxy.ansible.com (the v3 collection index `ansible-galaxy` itself resolves against, plus the classic v1 role index); `git`/`file`/`url` entries and private Automation Hub sources exempt; roles never get absence claims (their index only updates when the owner re-imports) |
| Nix | `flake.lock` |
| Zig | `build.zig.zon` — Zig has no lockfile beyond it and no registry: every dependency pins a source URL + content hash, and lockvet reads both (rev/tag/hash-derived versions, ‼ same-version hash swaps, ⇄ same-version source re-points, verified tag compare links + release notes from the dependency's own repo) |
| CI / GitHub Actions | `.github/workflows/*.yml`, `action.yml`/`action.yaml` (composite actions), `.gitea/workflows`, `.forgejo/workflows` — every `uses:` pin; SHA and floating-tag pins resolved against the action repo's real tags, advisories from OSV's GitHub Actions ecosystem evaluated client-side |
| CI / GitLab | `.gitlab-ci.yml` (suffix-named variants like `backend.gitlab-ci.yml` and fragments under `.gitlab/` / `.gitlab-ci/` too) — `include: component:` CI/CD Catalog pins verified against the component project's real tags (floating `@2` / `@2.0` / `@~latest` forms resolved to the release they fetch today), job/default `image:` and `services:` refs verified like Dockerfile images; `include: project:` + `ref:` pins tracked claim-free |
| CI / CircleCI | `.circleci/config.yml` (continuation configs and fragments under `.circleci/` too) — every `orbs:` registry pin: release ages, floating `volatile` / `5` / `5.1` forms resolved to the version they fetch today, registry-verified unlisted detection and changelog links straight from the CircleCI orb registry (`dev:` versions and inline orbs exempt); docker executor `- image:` refs verified like Dockerfile images (`machine:` VM image labels skipped) |
| Containers | `Dockerfile` / `Containerfile` (variant names like `Dockerfile.alpine`, `dev.Dockerfile` too) and Compose files (`docker-compose.yml`, `compose.yaml`, overrides) — every `FROM` base image (multi-stage aware: stage references skipped, `ARG` defaults expanded, `--platform` flags ignored), `COPY --from=` images, the `# syntax=` BuildKit directive Renovate bumps, and every `image:` under Compose `services:` (services built from a local `build:` context skipped). Tags and digest pins are verified against the image registry itself — see [Container base images](#container-base-images) |
| Dev Containers | `devcontainer.json` / `.devcontainer.json` (subfolder variants included, JSONC understood) — the `"image"` pin plus every OCI-referenced Feature under `"features"` (`ghcr.io/devcontainers/features/…:tag` or `@sha256:` digest pins), verified against the registry like Dockerfile images; local-path / tarball / legacy host-less Feature ids skipped |
| Kubernetes | manifests (`*.yaml` under conventional directories — `k8s/`, `kubernetes/`, `manifests/`, `deploy/`, `overlays/`, `base/`, `clusters/`, `gitops/`, `apps/`, `infrastructure/`, `flux-system/`, `chart(s)/`, … — plus `*.k8s.yaml` and conventional basenames like `deployment.yaml`, `helmrelease.yaml`, `ocirepository.yaml` — top-level `apiVersion:` + `kind:` required, Helm `templates/` excluded): every `image:` under `containers:` / `initContainers:` / `ephemeralContainers`, verified like Dockerfile images. Flux `HelmRelease` chart version pins (same-file `HelmRepository` URLs verified against the chart repo's `index.yaml`) and `OCIRepository` `ref.tag`/`ref.digest` pins (verified like images). Argo CD `Application` / `ApplicationSet` chart sources (`chart:` + exact `targetRevision:`, single- and multi-source, verified against the inline `repoURL`'s `index.yaml`; conventional basenames `application.yaml`, `applicationset.yaml`, `appset.yaml` and directories `argocd/`, `argo/`, `applications/` match too). `kustomization.yaml` / `kustomization.yml`: `images:` transformer pins (`newTag:`, `digest:`) plus `helmCharts:` entries checked against the chart repository's own `index.yaml`. Helm values files (`values.yaml`, `values-*.yaml`, `*-values.yaml`, `*.values.yaml` — and any sniffed YAML carrying the structured shape): `image:` mappings with `repository:`/`tag:` (+ `registry:`/`digest:`) children, plus tagged `image:` scalars in convention-named files; block-scalar bodies opaque, templated values skipped — see [Kubernetes manifests & kustomizations](#kubernetes-manifests--kustomizations) |
| pre-commit | `.pre-commit-config.yaml` — every `repos:` entry's `rev:` pin, on any git forge (names keep their host); revs verified against the hook repository's real tags exactly like workflow pins: SHA pins resolved to the release they equal, ▲ when a rev matches no tag, verified compare links + release notes for bumps (`repo: local`/`meta` exempt) |
| asdf / mise | `.tool-versions`, `mise.toml` (also `.mise.toml`, `mise.local.toml`, `mise/config.toml` locations) — every toolchain pin verified against the tool's own repository tags via a curated ~90-tool map that speaks each repo's tag dialect (`go1.23.4`, `v3_4_2`, `OTP-27.1.2`, `REL_18_4`, `swift-6.1-RELEASE`, …): verified `(=tag)` resolutions with compare links and release notes, fuzzy pins (`node = "22"`) resolved to what they'd install today, ▲ when a concrete pin matches no tag. mise backend tools are real registry packages and get the full treatment (`npm:` → npm, `cargo:` → crates.io, `pipx:`/`pip:` → PyPI, `gem:`, `dotnet:`, `go:`); `ubi:`/`aqua:`/`github:`/`spm:` verify against the named GitHub repo; `gradle`/`maven`/`sbt` ride their existing registries; `asdf:`/`vfox:` plugins, symbolic selectors (`latest`, `lts`, `system`, `ref:`) and vendor-prefixed Java builds honestly claim nothing. Single-tool version files get the same treatment: `.nvmrc` / `.node-version`, `.python-version` (multi-version pyenv fallbacks included), `.ruby-version` (rvm's `ruby-3.3.4` spelling understood), `.go-version`, `.java-version`, `.terraform-version` / `.terragrunt-version`, and SDKMAN's `.sdkmanrc` (`gradle=`/`maven=`/`sbt=`/`kotlin=` pins verified; vendor-suffixed Java builds claim-free). `mise.lock` adds per-platform sha256/blake3 checksums read as artifact-scoped **integrity pins** — a same-version checksum change flags ‼ REPINNED, added platforms never do |
| SBOMs | CycloneDX & SPDX JSON: `bom.json`, `sbom.json`, `*.cdx.json`, `*.spdx.json` — multi-ecosystem, incl. Alpine/Debian/Wolfi OS packages |

Notes: direct/`via …` origin labels appear where the lockfile records its
dependency graph: npm, pnpm, yarn, Cargo, uv, poetry, Composer, Bundler,
renv, pixi/conda-lock, Julia manifests, Gleam, CocoaPods, and Go modules (go.mod's `// indirect` markers give direct/transitive,
without chains). Formats that only pin flat versions (`requirements.txt`, `mix.lock`,
Gradle, …) skip the label.
Deno's `jsr:` packages, CocoaPods, conda channels, Terraform providers,
Helm charts, Bazel modules, Nix flakes, container base images, and
pre-commit hook pins have no OSV.dev ecosystem (yet), so those
diffs are explained without vulnerability data (Conan HAS one —
"ConanCenter" — which is still near-empty; advisories will surface on
`conan.lock` diffs automatically as it fills in) — but pip/pypi packages inside `pixi.lock` / `conda-lock.yml` are
matched against PyPI advisories like any other Python lockfile.
SBOM packages keep their own ecosystem per purl; OS packages get OSV data
when the purl names a release (`distro=alpine-3.18.4` → `Alpine:v3.18`) —
Ubuntu/RPM packages are diffed without it.
Release ages / deprecations / license changes come from deps.dev, which covers
npm, crates.io, PyPI, Go, Maven, NuGet, and RubyGems — other ecosystems simply
skip those checks. PHP, the BEAM world, Dart, JSR, iOS, C/C++, Terraform, R, Haskell, Bazel, conda, Helm and Ansible are the exceptions: deps.dev has no
Composer, Hex, Pub, JSR, CocoaPods, Conan, Terraform, CRAN, Hackage, Bazel, conda, Helm or Ansible system at all, so lockvet asks Packagist, hex.pm,
pub.dev, jsr.io, the CocoaPods registry, ConanCenter, the Terraform/OpenTofu registries, CRAN (via METACRAN's crandb), Hackage, the Bazel Central Registry, anaconda.org, Ansible Galaxy and each Helm chart repository's own `index.yaml` directly — release ages, deprecation warnings (Composer `abandoned`,
Hex retirements, pub.dev discontinued packages and retracted versions,
JSR yanked versions and archived packages,
CocoaPods deprecated pods with their named replacement, archived/delisted
Terraform providers, packages archived on CRAN, packages and individual
versions deprecated on Hackage — replacement suggestions included,
Bazel module versions yanked from the Bazel Central Registry with the
registry's reason, conda releases marked broken, Helm charts marked `deprecated` in their repository's index, Ansible collections marked deprecated on Galaxy), changelog links and the unlisted check all work for `composer.lock`,
`mix.lock`, `rebar.lock`, Gleam's `manifest.toml`, `pubspec.lock`, `deno.lock`'s `jsr:` packages, `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` and Ansible `requirements.yml` too (plus license
changes for Composer, CocoaPods, CRAN and conda; Hex, pub.dev, jsr.io, the Terraform registry, Hackage, the Bazel Central Registry, Helm chart indexes and Ansible Galaxy keep no usable per-release license history,
so that one check is honestly skipped there). Ansible's checks carry their own honesty rules: classic roles never get absence claims (the v1 role index only updates when the owner re-imports, so a missing version proves lag, not malice), and versions published before Galaxy's 2023 migration all carry the migration date, so historical ages are upper bounds. Helm's unlisted check carries a pruning guard: chart repositories routinely prune old releases from their index, so only a missing version that sorts at or above the oldest release the index still lists is flagged. Helm ages carry a regeneration guard too: some repositories rebuild `index.yaml` and stamp historical entries with the generation time (every chart would look published today), so when three or more versions of a chart share a one-hour `created` window those timestamps are treated as artifacts and claim no ages — versions outside the cluster keep their real ones. The Bazel Central Registry
also records no publish timestamps (they live only in its git history),
so Bazel modules honestly carry no release ages or ⏱ cooldown flags. CRAN's unlisted check is
mirror-lag-safe: before claiming anything, absence is double-checked
against cran.r-project.org itself (current release *and* the Archive —
CRAN archives old versions, it never deletes them, so a version in
neither place was never on CRAN).
`conan.lock` gets release ages only: a Conan reference doesn't record
which remote it came from, so absence from ConanCenter proves nothing and
lockvet makes no unlisted or deprecation claims there.
vcpkg manifests are baseline-pinned: the `builtin-baseline` commit decides
every dependency's version, so lockvet verifies the commit exists in the
registry's repository (a baseline only reachable in a fork is the
poisoned-registry shape — the build resolves on the attacker's checkout
and nowhere else), dates it, links `old...new` on the registry repo, and
checks `overrides` version pins against microsoft/vcpkg's append-only
versions database. Both claims are gated on the outgoing value resolving
(projects on overlay ports or custom registries never trip them), and
`overlay-ports` declared in the manifest's configuration turn override
claims off entirely.
Nix flake inputs pin git revisions, not versions — lockvet shows them as
`<commit-date>.<short-rev>` so the diff still reads chronologically, turns
each input's `lastModified` into release ages (the ⏱ cooldown flag works
on `update-flake-lock` PRs, fully offline), and links every bump to the
forge's `rev...rev` compare page so "what did nixpkgs actually change"
is one click. The narHash / re-pointed-repository checks above cover
flakes too.

Missing one you care about? [Open an issue](https://github.com/matteo-sung/lockvet/issues) —
parsers are ~50 lines each.

## How it works

1. `git diff --name-only <base> <target>` finds changed lockfiles.
2. Each lockfile version is read with `git show` and parsed into
   `package → pinned versions` (multiple versions per package are handled —
   npm nesting, Cargo duplicate majors).
3. The two snapshots are diffed and each change is classified with a lenient
   version parser that copes with semver, Python post-releases, and Go
   pseudo-versions.
   Where the lockfile also records dependency edges and root deps, lockvet
   BFS-walks the graph to label every change `(direct)` or `via <chain>` —
   no manifest files or network needed.
4. Old and new versions are checked against OSV.dev's batch API. A vulnerability
   that matches the new version but not the old one is **introduced**; the
   reverse is **fixed**; both is **unresolved**. Aliased advisories
   (GHSA/CVE/PYSEC/RUSTSEC for the same issue) are collapsed.
5. Every *incoming* version is looked up on deps.dev's batch API for its
   publish date, deprecation status, and license — departing versions are
   looked up too, so a license flip between old and new gets flagged (npm,
   crates.io, PyPI, Go, Maven, NuGet, RubyGems). An incoming version the
   registry doesn't list — while other versions of the package are listed —
   gets the [`unlisted` flag](#versions-missing-from-the-registry): that's
   what an unpublished (often malicious) release looks like (for npm,
   PyPI, crates.io, RubyGems, Packagist, NuGet, Hex, Pub, Go, Maven,
   CRAN, Hackage, Bazel and conda the flag is double-checked against the registry itself, which also tells lockvet when a bump [suddenly adds
   install scripts](#install-scripts-added-by-a-bump) or [silently drops
   provenance attestations](#provenance-dropped-by-a-bump), and when a
   release was yanked or its project archived or quarantined). Versions younger than `-fresh-days` (default 7) get a
   ⏱ flag — supply-chain attacks are usually discovered and yanked within
   days of publication, so a short cooldown filters most of them out. For
   RubyGems the compact index's own `created_at` times fill in ages
   deps.dev hasn't indexed yet, so a gem published minutes ago still gets
   its ⏱ flag; NuGet registration `published` times do the same for .NET
   packages; for Composer, Hex, Dart, JSR, R, Haskell and conda packages the ages come straight from
   Packagist, hex.pm, pub.dev, jsr.io, CRAN's own per-version timeline
   (via METACRAN's crandb), Hackage's upload-time endpoint and
   anaconda.org's per-release upload times, which deps.dev doesn't cover at all;
   pods get theirs from the CocoaPods trunk API's per-version timestamps;
Terraform/OpenTofu providers from the registries' per-version publish
times (so a provider release cut hours ago gets its ⏱ flag too); Go tags cut
   minutes ago get theirs from the module proxy's `.info` endpoint, and
   pseudo-versions carry their commit time in the version string itself,
   so those age for free; Maven artifacts fall back to the POM's
   `Last-Modified` upload time on Central or Google's Maven repository. The proxy's latest `go.mod` also supplies
   [retractions](https://go.dev/ref/mod#go-mod-file-retract) (with the
   author's rationale comment) and `// Deprecated:` module notices —
   `GOPROXY` is honoured, so private proxies work too.
6. Each changed package's source repository (from deps.dev) has its tag list
   fetched over git's smart-HTTP protocol — one anonymous GET per repo, the
   same request `git ls-remote` makes. Old and new versions are matched
   against the *real* tags (trying `v1.2.3`, `1.2.3`, `pkg@1.2.3`,
   `pkg-v1.2.3`, Go `dir/v1.2.3`, and pseudo-version commit hashes), and only
   verified matches become compare / release links — so every link works.

**Privacy:** the only network traffic is the OSV.dev and deps.dev batch
queries (package names + versions), anonymous npm-registry / 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
metadata fetches for
changed packages of those ecosystems, and the anonymous git tag listings
above.
`-offline` disables all of it; `-no-vulns` / `-no-meta` disable
vulnerability and metadata+links lookups individually. The typosquat check
makes no requests at all — the popularity lists ship inside the binary.
No telemetry, ever.

### Air-gapped vulnerability checks (`-osv-db`)

`-offline` doesn't have to mean "no vulnerability data". Point lockvet at a
directory of local OSV databases — the per-ecosystem `all.zip` files
[OSV.dev publishes](https://google.github.io/osv.dev/data/#data-dumps) — and
the vulnerability check runs entirely from disk:

```console
# 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
```

Details that matter:

- **Same answers.** Range evaluation happens client-side with the same
  walk lockvet already uses for GitHub Actions advisories; withdrawn
  records are excluded, matching the API. On real repos the output is
  byte-identical to the API path — including `MAL-…` malware records, so
  the [chalk/debug replay](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md) works with the cable
  unplugged.
- **Cheap refreshes.** Downloads are conditional (ETag): an unchanged
  database costs one 304. `LOCKVET_OSV_DB` sets a default directory, so
  CI can cache it like any other build cache.
- **Hand-copied zips work.** If your air-gapped side only accepts files
  through a transfer process, copy the `all.zip` files in by hand —
  lockvet indexes them locally on first use.
- **Sizes are honest.** Most ecosystems are a few MB; npm is ~200 MB
  because it includes every `MAL-…` malware record — that's the point.
  Indexing npm takes ~15 s once per refresh; warm runs are seconds.
- **Missing databases fail loud.** An ecosystem in the diff with no local
  database skips the vulnerability check with an instruction, never
  silently reports "no vulnerabilities".

**Caching:** registry and advisory answers are cached on disk for one hour
(`~/.cache/lockvet`, override with `LOCKVET_CACHE_DIR`), so repeat runs are
fast — running `lockvet`, then `lockvet -md` for the PR comment, doesn't ask
every registry twice — and stay inside anonymous rate limits. Forge data
(PR state, file contents) is never cached, so you always vet the live diff,
and negative registry answers — the evidence behind the ▲ unlisted flag —
are re-proven on every run, so a just-published version clears the flag
immediately. `-no-cache` bypasses it; `-cache-ttl 15m` tunes it
(`-cache-ttl 0` disables). Nothing from *your* repository is ever written
to the cache.

**Dependencies:** none. Pure Go standard library.

## How it compares

|  | `git diff` on the lockfile | [whatsdiff](https://github.com/whatsdiff/whatsdiff) v2.6 | [dependency-review-action](https://github.com/actions/dependency-review-action) | **lockvet** |
|---|---|---|---|---|
| Lockfile formats | any (raw text) | 3 (composer, npm, pnpm) | GitHub dependency-graph ecosystems | **60** across 30+ ecosystems, + CycloneDX/SPDX SBOMs |
| Readable per-package summary | ✗ | ✓ | ✓ (job summary / PR comment) | ✓ |
| Vulnerabilities introduced / fixed by the change | ✗ | ✗ | introduced only (GitHub Advisory DB) | ✓ both (OSV.dev) |
| Release age + ⏱ cooldown flag on fresh versions | ✗ | ✗ | ✗ | ✓ (deps.dev) |
| Deprecation warnings | ✗ | ✗ | ✗ | ✓ (deps.dev) |
| License-change flag (`MIT → BUSL-1.1`) | ✗ | ✗ | ✗ (SPDX allow-list gate instead) | ✓ (deps.dev) |
| OpenSSF Scorecard scores for changed dependencies | ✗ | ✗ | ✓ (warn threshold) | ✗ |
| Flag versions their own registry no longer lists (unpublished malware) | ✗ | ✗ | ✗ | ✓ ([`unlisted`](#versions-missing-from-the-registry)) |
| Flag new dependencies one edit from a popular name (typosquats) | ✗ | ✗ | ✗ | ✓ ([`≈ typosquat`](#typosquat-suspects), offline, npm/PyPI/crates.io) |
| Flag bumps that suddenly add npm install scripts | ✗ | ✗ | ✗ | ✓ ([`⚙ scripts`](#install-scripts-added-by-a-bump)) |
| Flag young releases that silently drop provenance (sigstore / trusted publishing) | ✗ | ✗ | ✗ | ✓ ([`⛨ provenance`](#provenance-dropped-by-a-bump)) |
| Flag integrity changes on unchanged versions & private→public registry moves (dependency confusion) | ✗ | ✗ | ✗ | ✓ ([`‼ integrity` / `⇄ resolution`](#integrity--resolution-changes), offline) |
| Air-gapped vulnerability check from a local database | ✗ | ✗ | ✗ | ✓ ([`-osv-db`](#air-gapped-vulnerability-checks--osv-db), incl. `MAL-…` malware records) |
| GitHub Actions `uses:` pins (SHA → release resolution, tag-attack detection) | ✗ | ✗ | GitHub Actions ecosystem via dependency graph | ✓ ([format #31](#github-actions-workflows-are-lockfiles-too), all five forges + locally) |
| Verify SwiftPM pins against upstream tags (moved tag / deleted release) | ✗ | ✗ | ✗ | ✓ ([`‼ tag mismatch`](#swiftpm-pins-verified-against-upstream-tags)) |
| Direct vs. transitive, with pull-in chain (`via a › b`) | ✗ | ✗ | direct/indirect label, no chain | ✓ |
| Vet a PR / MR / compare URL without cloning | ✗ | ✗ | GitHub PRs only (runs *as* the PR's workflow) | ✓ (GitHub + GitLab + Bitbucket + Gitea/Forgejo + Azure DevOps, self-hosted incl.) |
| Triage every open Dependabot/Renovate PR at once | ✗ | ✗ | ✗ | ✓ (`lockvet queue <org>`, on all five forges) |
| Diff two container images' SBOMs, with distro CVEs | ✗ | ✗ | ✗ | ✓ (`lockvet diff old.cdx.json new.cdx.json`) |
| Audit the *current* pins, not just a change | ✗ | ✗ | ✗ | ✓ ([`lockvet audit`](#audit-what-you-already-pin--lockvet-audit): advisories, unlisted, deprecated, fresh) |
| Vet a package *before* installing it | ✗ | ✗ | ✗ | ✓ ([`lockvet pkg npm:left-pad`](#vet-a-package-before-you-install-it--lockvet-pkg): advisories, age, typosquat — latest or any version) |
| CI gate | ✗ | per-package `check` exit codes | ✓ (`fail-on-severity`, license lists, `deny-packages`) | policy gate (`-fail-on major\|vuln\|fresh\|deprecated\|unlisted\|typosquat\|scripts\|provenance\|integrity\|registry\|license`) + GitHub Action |
| Acknowledge a finding without turning the gate off | ✗ | ✗ | ✓ (`allow-ghsas`, no expiry) | `.lockvetignore` (per-advisory / per-package rules with expiry dates; ignored findings stay visible) |
| Output formats | text | text, JSON, markdown | job summary + PR comment | text, JSON, markdown, SARIF (code scanning alerts) |
| See what changed upstream | ✗ | ✓ (fetches changelog text) | ✗ | ✓ (verified tag-to-tag diff links + `-changelogs` fetches release notes, transitives included) |
| MCP server (let AI assistants vet PRs) | ✗ | ✓ | ✗ | ✓ (`lockvet mcp`: vet URLs, local repos, files, pre-install packages, audits, whole queues) |
| Interactive TUI | ✗ | ✓ | ✗ | ✗ |
| Runs locally / outside CI | ✓ | ✓ | ✗ | ✓ (CLI, [browser playground](https://matteo-sung.github.io/lockvet/), any CI) |
| Runtime | — | PHP (binaries provided) | JS action on GitHub runners; free on public repos, private repos need GitHub Advanced Security | single static Go binary, zero deps |

whatsdiff is a fine tool if you live in composer/npm and want changelogs and
a TUI. GitHub's dependency-review-action is a solid default if you're all-in
on GitHub — it reads the dependency graph (so it also sees manifest-only
changes lockfiles don't record) and surfaces OpenSSF Scorecard scores; but
it only checks *known* advisories and licenses, only on GitHub PRs, and
private repos need an Advanced Security license. lockvet's focus is
different: *should I trust this diff?* — across whatever forge and language
your repos are in, with registry-level tampering signals no advisory
database has heard about yet, locally or in any CI.

### "Isn't this just …?"

**[osv-scanner](https://github.com/google/osv-scanner)?** Google's
osv-scanner is the reference *known-vulnerability* scanner: point it at a
tree or container image and it inventories every OSV advisory, with an
experimental `fix` mode that plans upgrades for you. lockvet asks a
different question — *what does this change do, and does anything about it
smell wrong?* Advisories are one column of the answer; release ages,
deprecations, license flips, versions the registry no longer lists,
typosquats, install-script and provenance transitions, and
integrity/resolution tampering are the rest — signals that can fire
*before* any advisory exists (see the [case studies](https://github.com/matteo-sung/lockvet/blob/HEAD/docs/case-studies.md)).
If you want tree-wide CVE inventory with remediation *planning*, use
osv-scanner — [`lockvet audit`](#audit-what-you-already-pin--lockvet-audit)
overlaps and names the fix version on every advisory line, but it won't
edit your manifests for you. If you want to know whether to merge a
Dependabot PR, that's lockvet.

**[npq](https://github.com/lirantal/npq)?** npq audits an npm package
*before* `npm install` — age, install scripts, typosquats, provenance
regressions, known vulns — with an interactive prompt and package-manager
aliasing so it can be your daily `npm`. If you live in npm, it's a great
daily driver. [`lockvet pkg`](#vet-a-package-before-you-install-it--lockvet-pkg)
runs the same kind of pre-install check across ~30 ecosystems (npm, PyPI,
crates.io, RubyGems, Go, Maven, NuGet, Composer, Hex, pub.dev, JSR,
CocoaPods, Terraform, CRAN, Helm, GitHub Actions, CircleCI orbs,
toolchains, …) — but it's a one-shot
report, not an install wrapper, and pre-install vetting is one subcommand
of a tool whose main job is diffs and CI gates.

**[Socket](https://socket.dev)?** Socket is a commercial platform that goes
further than lockvet in one important way: it statically analyzes package
*code* for malicious behavior and risky capabilities. lockvet never reads
package code — it reads lockfile bytes plus public registry metadata,
anonymously, with no account, no code upload, and no service dependency
beyond the public registries themselves. Different trust models: if you
can adopt a managed service and want code-level analysis, Socket sees
things lockvet can't; if you want a zero-account static binary you can run
on any forge, any CI, or air-gapped (`-offline`), that's lockvet.

**Dependabot / Renovate?** Updaters, not reviewers — they open the PRs,
lockvet is the reviewer on the other side of them
(`lockvet queue <org>` triages every open bot PR at once). Complementary
by design.

## Non-goals

- Not a code analyzer — lockvet never downloads or inspects package
  *contents*, only lockfiles and registry metadata. Tools like Socket or a
  human review do that job.
- Not an updater — Dependabot/Renovate open the PRs; lockvet tells you
  whether to merge them.
- No interactive TUI (see whatsdiff above) — lockvet stays a one-shot
  command whose output drops straight into a PR comment.

## License

MIT © Matteo Sung

