Back to Discover

lockvet

connector

matteo-sung

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

View on GitHub
0 starsSynced Aug 5, 2026

Install to Claude Code

/plugin marketplace add matteo-sung/lockvet

README

lockvet

Explain any lockfile change before you merge it.

▶ Try it in your browser — paste a Dependabot/Renovate PR URL or drop two lockfiles, no install needed. Reports are linkable: share any PR audit as a URL.

lockvet catching a RUSTSEC advisory hidden in a routine dependabot patch bump

Real example: a dependabot "patch" bump of jiff in sharkdp/fd quietly added 7 transitive crates — one of them flagged by RUSTSEC.

Would lockvet have caught it? — event-stream, the chalk/debug takeover, the Shai-Hulud worm, the ultralytics miner, and the strong_password gem hijack, 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, deduplicated across GHSA/CVE/PYSEC aliases)
  • 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)
  • 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 cloninglockvet 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 oncelockvet 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
  • usable by your AI assistantlockvet mcp is a built-in MCP server: Claude Code, Cursor, or any MCP client can vet a PR URL, a local repo, two files, or a whole Dependabot queue mid-conversation
  • across every ecosystem, in one static binary — 29 lockfile formats: npm, pnpm, yarn (classic & berry), bun, Deno, Cargo, uv, poetry, pipenv, requirements.txt, Go modules, Composer, Bundler, Hex/mix, pub/Flutter, Gradle, NuGet, Swift Package Manager, CocoaPods, R/renv, conda/pixi, Julia, Haskell (stack & cabal), Gleam, Terraform/OpenTofu, Helm, Nix flakes — 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

$ 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):

brew install matteo-sung/tap/lockvet

Scoop (Windows):

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

Go:

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

or grab a prebuilt binary from the releases page (Linux / macOS / Windows, amd64 & arm64):

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

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

docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/matteo-sung/lockvet:0.3.18 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:

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

Usage

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)

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 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:

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:

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):

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:

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:

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:

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:

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:

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

lockvet queue triaging every open Renovate PR on mastodon/mastodon

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):

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):

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:

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:

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):

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.3.18
      - 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:

syft -q alpine:3.18 -o cyclonedx-json > old.cdx.json
syft -q alpine:3.19 -o cyclonedx-json > new.cdx.json
lockvet diff old.cdx.json new.cdx.json
new.cdx.json (SBOM)
  ↑ ca-certificates-bundle 20241121-r1 → 20250911-r0  MAJOR  via apk-tools
  ↑ zlib                   1.2.13-r1   → 1.3.1-r0  minor  via apk-tools
  ↑ busybox                1.36.1-r7   → 1.36.1-r20  patch  via alpine-baselayout › busybox-binsh
      ▼ fixes ALPINE-CVE-2023-42363 A use-after-free vulnerability was discovered in xasprintf…
      ▼ fixes ALPINE-CVE-2023-42364 A use-after-free vulnerability in BusyBox v.1.36.1 allows…
  …
12 packages changed · 1 major · 1 minor · 10 patch · 2 direct · 10 transitive
· vulnerabilities: 0 introduced, 5 fixed, 4 unresolved

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.

Let your AI assistant vet dependencies (MCP server)

lockvet mcp runs lockvet as a Model Context Protocol 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.

# Claude Code
claude mcp add lockvet -- lockvet mcp
// 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.3.18", "lockvet", "mcp"] }. lockvet is also on the official MCP Registry as io.github.matteo-sung/lockvet, so clients that browse the registry can add it from there.

Four read-only tools, mirroring the CLI:

ToolWhat it does
vet_urlvet any PR/MR, compare range, or commit by URL — GitHub, GitLab, Bitbucket, Gitea/Forgejo, Azure DevOps, no clone
vet_gitvet a local repo: working tree vs HEAD, or any revision range
vet_filesvet two lockfiles or SBOMs on disk
queuetriage 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:

# .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'
      - '**/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.3.18
        # 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, 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):

permissions:
  pull-requests: write
  contents: read
  security-events: write

      - uses: matteo-sung/lockvet@v0.3.18
        with:
          sarif: 'true'

Or standalone, anywhere:

$ 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:

# .gitlab-ci.yml
lockvet:
  image: ghcr.io/matteo-sung/lockvet:0.3.18
  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:

# bitbucket-pipelines.yml
pipelines:
  pull-requests:
    '**':
      - step:
          name: lockvet
          image: ghcr.io/matteo-sung/lockvet:0.3.18
          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:

# azure-pipelines.yml
jobs:
  - job: lockvet
    condition: eq(variables['Build.Reason'], 'PullRequest')
    pool: { vmImage: ubuntu-latest }
    container: ghcr.io/matteo-sung/lockvet:0.3.18
    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):

# .woodpecker/lockvet.yaml
when:
  - event: pull_request

steps:
  - name: lockvet
    image: ghcr.io/matteo-sung/lockvet:0.3.18
    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 is part of the commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/matteo-sung/lockvet
    rev: v0.3.18
    hooks:
      - id: lockvet
        # optional: block the commit instead of just explaining it
        # args: [-fail-on, "vuln,fresh"]
        # optional: skip network lookups for instant commits
        # args: [-offline]

By default the hook is informational — it prints the explanation and lets the commit through. Add -fail-on to turn it into a gate. Requires nothing but pre-commit itself (the hook builds via Go, which pre-commit downloads automatically if missing).

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 (with the yank reason) and PEP 792 project statuses: 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 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 versions surface in this lane as well. Hex retirements — 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 is flagged with the named replacement (● deprecated upstream: discontinued on pub.dev; replaced by flutter_markdown_plus), and a bump onto a retracted version — one dart pub itself refuses to newly resolve — is flagged too. JSR packages get theirs straight from jsr.io: bumps onto yanked versions and packages archived on jsr.io land in this lane as well. Go retractions 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 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.

  • 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 studiesevent-stream@3.3.6, flatmap-stream@0.1.1, chalk@5.6.1, ultralytics@8.3.41 — 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

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 and Maven 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), 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 (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.

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 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, PyPI projects with PEP 740 attestations, crates.io crates via trusted publishing, and RubyGems releases with sigstore attestations — 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, so GitHub-hosted upstreams are covered; excerpts are trimmed and control-character-sanitized. Uses the GitHub API — anonymous works for a handful of packages, GITHUB_TOKEN / a logged-in gh raises the limit.

Supported lockfiles

EcosystemFiles
JavaScriptpackage-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
RustCargo.lock
Pythonuv.lock, poetry.lock, Pipfile.lock, requirements.txt (== pins)
Gogo.mod
PHPcomposer.lock
RubyGemfile.lock
Elixirmix.lock — release ages, retirements and the unlisted check straight from hex.pm; renamed forks ({:hex, :ts_chatterbox, …}) resolve under their real Hex package name
Dart / Flutterpubspec.lock — release ages, discontinued/retracted flags and the unlisted check straight from pub.dev (git/path/SDK/private-host packages exempt)
Java / JVMgradle.lockfile — bumps onto relocation stubs (mysql:mysql-connector-javacom.mysql:mysql-connector-j) land in the deprecation lane, and unlisted checks are verified against Maven Central and Google's Maven repository
.NETpackages.lock.json
SwiftPackage.resolved
iOS / CocoaPodsPodfile.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)
Rrenv.lock (CRAN + Bioconductor advisories, RSEC-…)
JuliaManifest.toml (also Manifest-v1.11.toml style) — General-registry advisories (JLSEC-…), via-chains from the manifest's dependency graph
Haskellstack.yaml.lock, cabal.project.freeze, cabal.config (Stackage pins) — Hackage advisories (HSEC-…)
Gleammanifest.toml — Hex advisories, via-chains, direct deps from [requirements]; ages/retirements/unlisted from hex.pm (git/path packages exempt)
Condapixi.lock (v4–v7), conda-lock.yml (also *.pixi.lock, *.conda-lock.yml) — pip packages inside get full PyPI vulnerability/age data
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)
HelmChart.lock, requirements.lock (Helm v2; pip-style requirements.lock from rye is auto-detected and treated as PyPI)
Nixflake.lock
SBOMsCycloneDX & 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, and Nix flakes have no OSV.dev ecosystem (yet), so those diffs are explained without vulnerability data — 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.4Alpine: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 and Terraform are the exceptions: deps.dev has no Composer, Hex, Pub, JSR, CocoaPods or Terraform system at all, so lockvet asks Packagist, hex.pm, pub.dev, jsr.io, the CocoaPods registry and the Terraform/OpenTofu registries 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), changelog links and the unlisted check all work for composer.lock, mix.lock, Gleam's manifest.toml, pubspec.lock, deno.lock's jsr: packages, Podfile.lock and .terraform.lock.hcl too (plus license changes for Composer and CocoaPods; Hex, pub.dev, jsr.io and the Terraform registry keep no per-release license history, so that one check is honestly skipped there). Nix flake inputs pin git revisions, not versions — lockvet shows them as <commit-date>.<short-rev> so the diff still reads chronologically.

Missing one you care about? Open an issue — 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: that's what an unpublished (often malicious) release looks like (for npm, PyPI, crates.io, RubyGems, Packagist, NuGet, Hex, Pub, Go and Maven the flag is double-checked against the registry itself, which also tells lockvet when a bump suddenly adds install scripts or silently drops provenance attestations, 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 and JSR packages the ages come straight from Packagist, hex.pm, pub.dev and jsr.io, 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 (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 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. No telemetry, ever.

Dependencies: none. Pure Go standard library.

How it compares

git diff on the lockfilewhatsdiff v2.6lockvet
Lockfile formatsany (raw text)3 (composer, npm, pnpm)29 across 20+ ecosystems, + CycloneDX/SPDX SBOMs
Readable per-package summary
Vulnerabilities introduced / fixed by the change✓ (OSV.dev)
Release age + ⏱ cooldown flag on fresh versions✓ (deps.dev)
Deprecation warnings✓ (deps.dev)
License-change flag (MIT → BUSL-1.1)✓ (deps.dev)
Flag versions their own registry no longer lists (unpublished malware)✓ (unlisted)
Flag bumps that suddenly add npm install scripts✓ (⚙ scripts)
Flag young releases that silently drop provenance (sigstore / trusted publishing)✓ (⛨ provenance)
Direct vs. transitive, with pull-in chain (via a › b)
Vet a PR / MR / compare URL without cloning✓ (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)
CI gateper-package check exit codespolicy gate (-fail-on major|vuln|fresh|deprecated|unlisted|scripts|provenance|license) + GitHub Action
Output formatstexttext, JSON, markdowntext, 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, whole queues)
Interactive TUI
RuntimePHP (binaries provided)single static Go binary, zero deps

whatsdiff is a fine tool if you live in composer/npm and want changelogs and a TUI. lockvet's focus is different: should I trust this diff? — across whatever language your repos are in, with security data inline, in CI.

Non-goals

  • Not a full SCA scanner — osv-scanner audits your entire dependency tree. lockvet explains a change.
  • 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

Rendered live from matteo-sung/lockvet's GitHub README — not stored, always reflects the source repo.

1 Install Method

NameDescriptionCategorySource
oci packageInstall via oci (stdio transport)mcp-serverghcr.io/matteo-sung/lockvet:0.3.18

0 Comments

Login required
Log in to post a comment or update on this repo.

No comments yet — be the first to share an update.