Odel
lockvet

lockvet

Local
@matteo-sungGoMITUpdated 5 days ago

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

lockvet

ci release OpenSSF Scorecard OpenSSF Best Practices Go Reference

Explain any lockfile change before you merge it.

▶ Try it in your browser — 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, or look up a package (that one is malware squatting the chalk typo).

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 (and its August 2026 return through keyv/Cacheable — replay it in your browser), 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, 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)
  • supply-chain tripwires — versions missing from their own registry index (what unpublished malware looks like), new dependencies whose name is one edit from a popular package (typosquats — checked offline against embedded popularity lists), bumps that suddenly add npm install scripts, and young releases that silently drop sigstore provenance
  • 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
  • before you even installlockvet 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 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, 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 scriptsbuild.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

$ 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

aqua (lockvet is in the standard registry):

aqua g -i matteo-sung/lockvet

mise (via its aqua backend):

mise use -g aqua:matteo-sung/lockvet

GitHub CLI extension (gh-lockvetgh lockvet vets the PR you're standing in, reusing your gh auth):

gh extension install matteo-sung/gh-lockvet

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

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:

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 instead, so verify first if you care):

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:

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

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

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

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:

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 Dependabot/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.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:

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.

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

That replay is the "news just broke" sweep from case study 7 (reproducible fixture there). On an everyday healthy repo it is just as quiet as you'd hope:

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

# .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 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

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:

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's Vet a package mode runs the same lookup in your browser, and the result is a shareable URL — #pkg=npm:chakl 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 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.6.15", "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.

Six 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
auditaudit everything the project pins right now — advisories, unlisted versions, deprecations
vet_packagevet a dependency before installing it (npm:left-pad, pypi:requests@2.32.0) — advisories, age, deprecation, typosquat suspicion
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'
      - '**/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, 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.6.15
        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.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:

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

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

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

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

# 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 (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. R packages get theirs straight from CRAN: a bump inside a package CRAN has archived — 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 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 — Hackage's yank equivalent, which solvers avoid — is flagged too. Bazel modules get theirs straight from the Bazel Central Registry: 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 — 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 studiesevent-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

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 (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, the top PyPI packages, crates.io's most-downloaded, RubyGems' most-downloaded (via ecosyste.ms), and Packagist's most 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:

    .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 looked like: version tags across the repo were force-moved to a malicious orphan commit. Replaying the attacked pin today:

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

$ 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: pinsCI/CD Catalog 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:

    .gitlab-ci.yml (Docker)
      ↑ gitlab.com/components/opentofu/full-pipeline            2.0.0      → 2.4.0  minor  (direct)
      ↑ gitlab.com/components/secret-detection/secret-detection 2 (=2.3.0) → ~latest (=2.3.0)    (direct)
    

    A pinned version that matches no tag in the component's project raises ▲ not a release — the tj-actions attack shape, for GitLab. Compare links use the project's real tags, and -changelogs renders the component's CHANGELOG.md sections for exactly the versions the bump pulls in (GitLab-hosted repos included). lockvet pkg component:gitlab.com/components/opentofu/full-pipeline vets one before you include it.

  • image: and services: refs — the containers every job runs in get the same registry verification as Dockerfile FROM pins (digest vs. tag, unknown tags, Docker Hub ages). Renovate bumps both kinds; nothing else vets either.

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

…and .circleci/config.yml

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

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

    .circleci/config.yml (Docker)
      ↑ circleci/aws-cli 4.1.0 → volatile (=5.4.2)  ?      (direct)  (54d old)
      ↑ circleci/node    5.1.0 → 7.2.1              MAJOR  (direct)  (9mo old)
    

    dev:* versions are mutable by design and expire — they stay claim-free, as do inline orb definitions and templated references. lockvet pkg orb:circleci/node vets an orb before you add it.

  • docker executor - image: refs — the containers every job runs in get the same registry verification as Dockerfile FROM pins (digest vs. tag, unknown tags, Docker Hub ages). machine: executor images are CircleCI VM image labels, not OCI refs, and are skipped.

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

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

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

.tool-versions (mise/asdf)
  ↑ erlang    27.0 (=OTP-27.0)   → 27.1.2 (=OTP-27.1.2)  minor  (direct)
  ↑ golang    1.22.5 (=go1.22.5) → 1.23.4 (=go1.23.4)    minor  (direct)
  ↑ ruby      3.3.4 (=v3_3_4)    → 3.4.2 (=v3_4_2)       minor  (direct)
  ↑ terraform 1.9.2 (=v1.9.2)    → 1.9.99  patch  (direct)
      ▲ not a release: 1.9.99 pinned version matches no tag in the tool's repository

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

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

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

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

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

Why this class of pin matters is case study 11 — the Codecov bash uploader replayed as a toolchain checksum swap, including the Gradle-wrapper varia