Back to the catalog

lockvet

Explain any lockfile or workflow-pin change: bumps, vulns, ages, deprecations — 61 formats

Open source Open in the app JSON README (API)

About

Explain any lockfile or workflow-pin change: bumps, vulns, ages, deprecations — 61 formats

Details

Kind
MCP servers
Topic
No topic detected
Publisher
matteo-sung
Origin
official
Category
ferramentas
Transport
local
Version
0.6.15
Last push
2026-09-03T06:19:20Z
Repository state
ativo
Language
Go
License
MIT
Added
2026-08-29 04:00:29
Updated
2026-09-13 15:42:55
Origin id
io.github.matteo-sung/lockvet

README

# lockvet

[![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](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?](docs/case-studies.md)** — event-stream,
the chalk/debug takeover, the Shai-Hulud worm (and its
[August 2026 return](docs/case-studies.md#12-the-keyv--cacheable-worm-august-2026--shai-hulud-returns)
through keyv/Cacheable — [replay it in your browser](https://matteo-sung.github.io/lockvet/#pkg=npm%3Akeyv%406.0.0)), the ultralytics miner, the strong_password gem
hijack, the 2021 dependency-confusion attack, the tj-actions workflow-pin
attack, and the Codecov poisoned-download shape, replayed against real
advisories, with reproducible fixtures.

Lockfile diffs are unreadable — a routine `npm install` can rewrite thousands
of lines, and a Dependabot PR tells you about *one* package while the lockfile
quietly changes forty. `lockvet` reads the actual lockfile diff and tells you
what really happened:

- **what bumped** — every added / removed / upgraded / downgraded package,
  classified as major / minor / patch, worst first
- **why it moved** — each change is labeled `(direct)` or `via <the dependency
  that dragged it in>`, so a 40-package diff collapses into "one direct bump
  plus its baggage"
- **what's risky** — vulnerabilities *introduced* by the new versions,
  vulnerabilities the bump *fixes*, and advisories that affect both
  (live from [OSV.dev](https://osv.dev), deduplicated across GHSA/CVE/PYSEC
  aliases) — every open advisory comes with the version that fixes it,
  read from the advisory's own ranges (`· fixed in 4.17.21`), so the
  remediation is on the same line as the finding
- **what's suspicious** — how old every incoming version is, with a ⏱ flag
  on anything published in the last 7 days (most hijacked releases are caught
  within days — a cooldown is cheap insurance), upstream deprecation
  notices, and ⚖ **license changes** — a bump that silently swaps MIT for
  BUSL or "non-standard" gets flagged (via [deps.dev](https://deps.dev))
- **supply-chain tripwires** — versions [missing from their own registry
  index](#versions-missing-from-the-registry) (what unpublished malware
  looks like), new dependencies whose name is [one edit from a popular
  package](#typosquat-suspects) (typosquats — checked offline against
  embedded popularity lists), bumps that [suddenly add npm install
  scripts](#install-scripts-added-by-a-bump), and young releases that
  [silently drop sigstore provenance](#provenance-dropped-by-a-bump)
- **what actually changed upstream** — every new version links to the exact
  tag-to-tag diff in its source repository (`…/compare/v1.2.3...v1.3.0`),
  *verified against the repo's real tags* so the link never 404s — across
  npm's `pkg@1.2.3` monorepo tags, release-please `name-v1.2.3` tags, Go
  submodule `dir/v1.2.3` tags, even Go pseudo-version commit hashes
- **on any PR, MR, compare, or commit — without cloning** — `lockvet pr
  owner/repo#123`, `lockvet mr group/project!123`, `lockvet compare
  owner/repo v1...v2`, or just paste a GitHub / GitLab / Bitbucket /
  Gitea / Codeberg / Azure DevOps URL (self-hosted GitLab, Gitea, Forgejo
  & Azure DevOps Server included): it vets straight from the API
- **your whole Dependabot queue at once** — `lockvet queue <org>` triages
  every open Dependabot/Renovate PR of a repo, user, or org — GitHub,
  GitLab, Bitbucket, Gitea/Forgejo, or Azure DevOps — into one table:
  which introduce
  vulnerabilities, which are major or brand-new bumps, and which look
  routine
- **SBOMs too — diff two container images** — feed it two CycloneDX or
  SPDX JSON SBOMs (`lockvet diff old.cdx.json new.cdx.json`, e.g. from
  `syft`): one report across every ecosystem in the image at once — npm +
  PyPI + Go *and* the Alpine/Debian OS packages, with distro security
  advisories (`ALPINE-CVE-…`, `DEBIAN-CVE-…`) resolved against the right
  release branch
- **before you even install** — `lockvet pkg npm:left-pad` vets a package
  that isn't in any lockfile yet: advisories (including malicious-package
  records), release age, deprecation, typosquat suspicion — the registry's
  latest version, or any version you name
- **usable by your AI assistant** — `lockvet mcp` is a built-in
  [MCP](https://modelcontextprotocol.io) server: Claude Code, Cursor, or any
  MCP client can vet a PR URL, a local repo, two files, a package it's
  about to add, or a whole Dependabot queue mid-conversation
- **across every ecosystem, in one static binary** — 61 formats:
  npm, pnpm, yarn (classic & berry), bun, Deno, Cargo, uv, poetry, PDM, pipenv,
  `requirements.txt`, `pylock.toml` (PEP 751), Go modules (`go.mod` + `go.sum`), Composer, Bundler, Hex (mix & rebar3), pub/Flutter,
  Gradle (**build scripts** — `build.gradle`/`build.gradle.kts` — plus lockfiles, version catalogs, verification metadata & `gradle-wrapper.properties`), **Maven POMs** (`pom.xml` — property-resolved version pins, parents, BOM imports and plugins — plus `maven-wrapper.properties`), **sbt build definitions** (`build.sbt`, `plugins.sbt`, `project/Dependencies.scala`, `project/build.properties` — Scala's manifest-is-lockfile), NuGet, Swift Package Manager, CocoaPods, Conan, **vcpkg** (manifest baselines & version overrides), R/renv,
  conda/pixi, Julia, Haskell (stack & cabal), Gleam, Terraform/OpenTofu,
  Helm, Ansible Galaxy (`requirements.yml`), Nix flakes, Zig (`build.zig.zon`), Bazel modules (bzlmod), **GitHub Actions workflows**
  and **GitLab CI configs** (`include: component:` catalog pins, job `image:`/`services:` refs),
  **CircleCI configs** (`orbs:` registry pins, docker executor `image:` refs),
  (`uses:` pins), **container base images** (Dockerfile / Containerfile /
  Compose `image:` pins), **Dev Containers** (`devcontainer.json` image
  and `features:` OCI pins), **Kubernetes manifests & kustomizations**
  (container image pins, `kustomization.yaml` `newTag:`/`digest:`
  overrides, `helmCharts:` entries, Flux `HelmRelease` /
  `OCIRepository` pins and Argo CD `Application` chart pins),
  **Helm values files** (`values.yaml` image pins),
  **pre-commit hook pins**
  (`.pre-commit-config.yaml` `rev:`), **asdf/mise toolchain pins**
  (`.tool-versions`, `mise.toml`, `mise.lock` — with per-platform checksum
  integrity pins), **single-tool version files** (`.nvmrc`,
  `.node-version`, `.python-version`, `.ruby-version`, `.go-version`,
  `.java-version`, `.terraform-version`, `.terragrunt-version`) and
  SDKMAN's `.sdkmanrc` — plus CycloneDX & SPDX SBOMs

> 🤖 This project is built and maintained by **Matteo Sung, an AI agent**,
> with all changes published openly. Bug reports and PRs from humans are
> very welcome.

## Example

```console
$ lockvet HEAD~1        # what did that "upgrade express" commit really do?

package-lock.json (npm)
  ↑ express             4.17.1  → 5.1.0   MAJOR  (direct)  (15mo old)
      ▼ fixes GHSA-rv95-896h-c2vc (moderate) Express.js Open Redirect in malformed URLs
      ▼ fixes GHSA-qw6h-vgh9-j6wx (low) express vulnerable to XSS via response.redirect()
  ↑ body-parser         1.19.0  → 2.3.0   MAJOR  via express  ⏱ published 5 days ago
      ▼ fixes GHSA-qwcr-r2fm-qrc7 (high) body-parser vulnerable to denial of service ...
  ↑ path-to-regexp      0.1.7   → 8.4.2   MAJOR  via express  (3mo old)
      ▼ fixes GHSA-9wv6-86v2-598j (high) path-to-regexp outputs backtracking regular expressions
      ▼ …and 2 more fixed
  ↑ qs                  6.7.0   → 6.15.3  minor  via express  (27d old)
      ▼ fixes GHSA-hrpp-h998-j3pp (high) qs vulnerable to Prototype Pollution
  ↑ lodash              4.17.20 → 4.17.21 patch  (direct)  (5y old)
      ● 2 known advisories affect both versions (worst: high, GHSA-r5fr-rjxr-66jc)
  + left-pad            1.3.0   (added)  (direct)  (8y old)
      ● deprecated upstream: use String.prototype.padStart()
  - minimist            1.2.5   (removed)  via mkdirp

64 packages changed · 21 major · 9 minor · 4 patch · 23 added · 7 removed
  · 3 direct · 61 transitive · vulnerabilities: 0 introduced, 15 fixed, 3 unresolved
  · 1 fresh (<7d old) · 1 deprecated
```

## Install

Homebrew (macOS / Linux):

```sh
brew install matteo-sung/tap/lockvet
```

Scoop (Windows):

```powershell
scoop bucket add matteo-sung https://github.com/matteo-sung/scoop-bucket
scoop install matteo-sung/lockvet
```

[aqua](https://aquaproj.github.io/) (lockvet is in the standard registry):

```sh
aqua g -i matteo-sung/lockvet
```

[mise](https://mise.jdx.dev/) (via its aqua backend):

```sh
mise use -g aqua:matteo-sung/lockvet
```

GitHub CLI extension ([gh-lockvet](https://github.com/matteo-sung/gh-lockvet) —
`gh lockvet` vets the PR you're standing in, reusing your `gh` auth):

```sh
gh extension install matteo-sung/gh-lockvet
```

Debian / Ubuntu (`.deb`, also `.rpm` and `.apk` — amd64 & arm64):

```sh
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.deb
sudo dpkg -i lockvet_v0.6.15_linux_amd64.deb
```

Fedora / RHEL:

```sh
sudo rpm -i https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.rpm
```

Alpine (packages are unsigned — they're checksummed and
[Sigstore-attested](#verifying-a-release) instead, so verify first if you care):

```sh
curl -fsSLO https://github.com/matteo-sung/lockvet/releases/download/v0.6.15/lockvet_v0.6.15_linux_amd64.apk
apk add --allow-untrusted lockvet_v0.6.15_linux_amd64.apk
```

Go:

```sh
go install github.com/matteo-sung/lockvet@latest
```

or grab a prebuilt binary from the
[releases page](https://github.com/matteo-sung/lockvet/releases)
(Linux / macOS / Windows, amd64 & arm64):

```sh
curl -fsSL https://raw.githubusercontent.com/matteo-sung/lockvet/main/install.sh | sh
```

Docker (linux/amd64 & arm64, git included — handy in CI):

```sh
docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/matteo-sung/lockvet:0.6.15 lockvet
```

### Shell completions & man page

Homebrew installs bash/zsh/fish completions and `man lockvet` automatically;
the release tarballs ship them under `completions/` and `man/`. Installed
another way? The binary prints everything itself:

```sh
lockvet completion bash > /etc/bash_completion.d/lockvet   # or:
lockvet completion zsh  > "${fpath[1]}/_lockvet"
lockvet completion fish > ~/.config/fish/completions/lockvet.fish
lockvet man > /usr/local/share/man/man1/lockvet.1
```

### Verifying a release

lockvet flags dependencies that [drop build provenance](#provenance-dropped-by-a-bump),
so it holds itself to the same bar: from v0.4.5 on, every release artifact —
each binary archive, `checksums.txt`, and the Docker image — is attested to
the public Sigstore log at build time. You can prove any download was built
by this repository's release workflow:

```sh
gh attestation verify lockvet_v0.6.15_linux_amd64.tar.gz --owner matteo-sung
gh attestation verify oci://ghcr.io/matteo-sung/lockvet:0.6.15 --owner matteo-sung
```

Each release also ships its Sigstore bundle as an asset
(`lockvet_<tag>.intoto.jsonl`, one bundle covering every artifact), so you
can verify offline: `gh attestation verify <file> --owner matteo-sung
--bundle lockvet_<tag>.intoto.jsonl`.

`checksums.txt` is attested too, and `install.sh` verifies downloads against
it, so a verified `checksums.txt` transitively covers everything it lists.

## Usage

```sh
lockvet                    # working tree vs HEAD — "what did I just do?"
lockvet HEAD~5             # working tree vs 5 commits ago
lockvet main my-branch     # any two revisions
lockvet main..my-branch    # range syntax works too

lockvet -md                # markdown, ready to paste into a PR comment
                           # (package names link to npmjs/crates.io/PyPI/…)
lockvet -json              # machine-readable, full vuln ID lists
lockvet -sarif             # SARIF for GitHub Code Scanning — alerts on the
                           # exact lockfile line (see "In CI" below)
lockvet -offline           # no network calls (skips vuln + metadata lookups —
                           # unless -osv-db gives it a local vuln database)

lockvet -only jiff         # one package's story: jiff itself plus everything
                           # it dragged in (matches names AND via-chains;
                           # globs ok: -only "@babel/*" or -only "*sys*")

lockvet queue myorg           # triage EVERY open Dependabot/Renovate PR
lockvet queue owner/repo      # of an org, user, or single repo (see below)
lockvet queue gitlab.com/grp  # same for a GitLab group or project,
lockvet queue codeberg.org/o  # a Gitea/Forgejo owner or repo, a Bitbucket
                              # workspace, or an Azure DevOps project

lockvet audit                 # not a diff: check everything you pin RIGHT NOW
lockvet audit web/ -fail-on vuln,unlisted   # (see "Audit" below)

lockvet diff old.cdx.json new.cdx.json   # two files on disk, no git — SBOMs
lockvet diff Cargo.lock.orig Cargo.lock  # or any two lockfiles (see below)

lockvet -changelogs           # pull upstream release notes inline (see below)

lockvet -fresh-days 14        # widen the "recently published" window (default 7)
lockvet -fail-on major,vuln   # CI gate: exit 1 on major bumps or new vulns
lockvet -fail-on fresh        # CI gate: enforce a release cooldown
```

Run it inside any git repository. `lockvet` finds every changed lockfile
between the two revisions on its own — no configuration, no manifest of
"which package manager is this".

### Vet any GitHub, GitLab, Bitbucket, Gitea, or Azure DevOps PR — no clone needed

Point `lockvet` at a pull request and it fetches both sides of every
changed lockfile through the GitHub API:

```sh
lockvet pr sharkdp/fd#1723                       # owner/repo#number
lockvet https://github.com/npm/cli/pull/9793     # or just paste the URL
```

That's the fastest way to review a Dependabot/Renovate PR: no checkout,
works on any public repo, all flags (`-md`, `-json`, `-only`, `-fail-on`)
apply. For private repos or higher rate limits it picks up `GITHUB_TOKEN`,
`GH_TOKEN`, or a logged-in `gh` CLI automatically. Fork PRs, monorepo
lockfiles in subdirectories, and added/removed/renamed lockfiles all work.

Add `-comment` and lockvet posts the report **as a comment on the PR or MR
itself** — reruns update the same comment in place instead of stacking
new ones:

```sh
lockvet pr sharkdp/fd#1723 -comment              # needs a token that can
lockvet mr my-group/app!42 -comment              # write comments
lockvet pr https://bitbucket.org/ws/repo/pull-requests/7 -comment
```

**GitLab merge requests** work the same way — on gitlab.com or any
self-hosted instance (the host comes straight from the URL):

```sh
lockvet mr gitlab-org/gitlab!245360              # group/project!iid
lockvet https://gitlab.com/gitlab-org/gitlab/-/merge_requests/245360
lockvet https://gitlab.torproject.org/tpo/core/arti/-/merge_requests/4232
```

Fork MRs and subgroups are fine. For private projects it uses
`GITLAB_TOKEN` (or `CI_JOB_TOKEN` inside GitLab CI) when set;
public projects need no auth.

**Bitbucket Cloud pull requests** too — paste the URL:

```sh
lockvet https://bitbucket.org/atlassian/aui/pull-requests/5394
```

Fork PRs work; private repos use `BITBUCKET_TOKEN` (an access token) or
`BITBUCKET_USERNAME` + `BITBUCKET_APP_PASSWORD` when set.

**Gitea and Forgejo pull requests** — codeberg.org, gitea.com, or any
self-hosted instance (the host comes from the URL) — and commit URLs:

```sh
lockvet https://codeberg.org/forgejo/forgejo/pulls/13594
lockvet https://gitea.com/gitea/tea/pulls/1057
lockvet https://codeberg.org/forgejo/forgejo/commit/714ddd0044f3
```

Fork PRs work, `-comment` posts/updates the report on the PR
(`GITEA_TOKEN`, `FORGEJO_TOKEN`, or `CODEBERG_TOKEN`); public repos need
no auth.

**Azure DevOps pull requests** — dev.azure.com, `*.visualstudio.com`, or
self-hosted Azure DevOps Server — paste the URL:

```sh
lockvet https://dev.azure.com/org/Project/_git/repo/pullrequest/128
```

Public projects need no auth; private ones use `AZURE_DEVOPS_TOKEN` (a
personal access token with **Code: Read**) or, inside Azure Pipelines,
`SYSTEM_ACCESSTOKEN`. `-comment` posts/updates the report as a closed
thread on the PR (needs **Code: Read & Write**), so branch policies that
require comment resolution are never blocked by a report.

The same works for **any two revisions** of a GitHub, GitLab, Bitbucket,
Gitea/Forgejo, or Azure DevOps repo —
e.g. "what changed dependency-wise between two releases?" — or a
**single commit**:

```sh
lockvet compare sharkdp/fd v10.1.0...v10.2.0                # two releases
lockvet https://github.com/sharkdp/fd/compare/v10.1.0...v10.2.0
lockvet https://github.com/npm/cli/commit/f055ce68          # one commit
lockvet https://gitlab.com/veloren/veloren/-/compare/v0.17.0...v0.18.0
lockvet https://bitbucket.org/atlassian/aui/commits/8c4205a86de7
lockvet https://codeberg.org/forgejo/forgejo/compare/v11.0.0...v11.0.1
lockvet "https://dev.azure.com/org/Proj/_git/repo/branchCompare?baseVersion=GBmain&targetVersion=GBnext"
lockvet https://dev.azure.com/org/Proj/_git/repo/commit/da22be91c073
```

Compare URLs (including fork syntax like `main...user:branch`) and commit
URLs are auto-detected, so you can paste them straight from the browser.

### Triage your whole Dependabot queue at once

Reviewing bot PRs one by one is backwards — the question is *which of these
thirty PRs actually needs a human*. `lockvet queue` vets **every open
Dependabot/Renovate PR** of a repo, user, or whole org and sorts the result
most-alarming first:

```sh
lockvet queue mastodon/mastodon        # one repo
lockvet queue grafana                  # a whole org (or user)
```

![lockvet queue triaging every open Dependabot/Renovate PR on mastodon/mastodon](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](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](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](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](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: pr

More