PyPI added a Security tab to every package. I built a one-command CI gate that reads it.

On 6 November 2026, PyPI redesigned every project page with a Security tab: each release's provenance state and per-file attestations (published via Trusted Publisher, SLSA Build Provenance), plus four provenance warnings - Incomplete, Inconsistent, Change, and Loss - and late-file warnings for files uploaded more than 14 days after the release. The signals exist. Nobody reads 40 project pages by hand before pip install, and there was no machine-readable gate for them in CI. That is the gap I built depsignal to close.

depsignal demo

The incident that made this concrete

LiteLLM 1.107.0, 21 October 2026. The project's own release pipeline was compromised through stolen GitHub CLI OAuth tokens. Attacker-built wheels from a lookalike repo (Berri-AI/litellm vs the real BerriAI/litellm) were published to PyPI - seven wheels, live 11:39-13:23 UTC (about 1h44m), carrying a credential stealer (cook.pth / cook.py / sitecustomize.py, runs on every Python start) plus an hourly remote-code loader. PyPI removed/quarantined 1.107.0; the clean pin is 1.106.2. It was the second LiteLLM compromise of 2026 (March: 1.82.7/1.82.8).

The sharpest detail: the malicious wheels were built by the official pipeline, and build provenance recorded the malicious fork as the source. Provenance alone does not mean safe. A Change of Provenance vs the previous release is exactly the detectable signal - and it is the one depsignal fails on.

What it does

depsignal is a zero-dependency TypeScript CLI (Node 20+). It reads what PyPI itself publishes for every release - build provenance/attestations, known vulnerabilities, yanked status, late-file warnings, release age and license metadata - and turns it into a gate: PASS, WARN or FAIL. No install, no virtualenv, no key, no account. It downloads nothing: it only reads the public PyPI JSON API and Simple API.

git clone https://github.com/techdevfun/depsignal.git cd depsignal npm install npm run build node dist/cli.js audit requirements.txt 

Four commands:

depsignal audit <requirements.txt | pyproject.toml> [--json] [--allow <pkg> ...] [--save-baseline <file>] [--limit <n>] depsignal check --baseline <file> [--input <req>] depsignal diff <baseline-a> <baseline-b> depsignal explain <pkg>[@<version>] 
  • audit: one verdict per dependency. Exit 0 = no FAIL, 1 = at least one FAIL, 2 = usage or network error. name==version is audited exactly; ranges and unpinned names resolve to the latest stable release and are labelled as such.
  • check: FAIL only on NEW failings vs a saved baseline - the CI gate for existing projects. Fixed findings are listed, not failed.
  • diff: fixed / new / unchanged findings between two baselines.
  • explain: drill-down for one package, every signal with its source URL (PyPI JSON + Simple API links, plus the Security tab link).

The demo, with real output

Part 1 of the GIF is a live audit against PyPI (no auth). django==4.2 fails: 44 findings, including GHSA-r3xc-prgr-mg9p / CVE-2023-31047, fixed in 4.2.1. pillow and requests resolve to their latest releases and pass. --limit 3 truncates the human output only - --json lists all 44 findings, and the table header still shows the full count.

Part 2 replays litellm==1.107.0. That version no longer exists on PyPI (HTTP 404), so a live probe cannot reproduce it. The replay runs against a local fixture server, and the labelling is honest about what the fixture is: the 1.106.2 half is the verbatim recorded API payload; the 1.107.0 half is reconstructed from published reports - file count, upload times and the provenance-presence change are the faithful signals; hashes and exact filenames are representative, not verbatim. The verdict is the point: "Provenance state changed none -> full between 1.106.2 and 1.107.0: attestations appeared from a previously unattested release. Provenance alone cannot prove the new publisher is the same as the old one." Exit 1.

The full transcript is in demo/transcript.txt in the repo.

Verdict rules

# Signal Source Rule
S1 Known vulnerability JSON API top-level vulnerabilities (OSV) FAIL if an unwithdrawn vuln affects the pinned version (pinned below the fixed_in of its own release line, or no fixed_in at all). Duplicate GHSA/PYSEC entries for the same advisory are collapsed.
S2 Provenance Simple API v1.4 provenance per file Full / Partial / None. None or Partial = WARN. Change of coverage state vs the previous stable release = FAIL.
S3 Yanked JSON API info.yanked + per-file yanked Pinned version yanked = FAIL. Latest release yanked = WARN.
S4 Late file Simple API upload-time per file File uploaded more than 14 days after the release's first file (pre-July-2026 releases only) = WARN.
S5 Staleness Simple API upload times of the latest stable release Latest stable release more than 2 years old = WARN, with the honest note that stability is not abandonment.
S6 License JSON API info.license / license_expression No license metadata = WARN.
S7 Archived / quarantined Simple API project-status Not claimed as detected (see Honest limits).

What the provenance field actually is

Verified live 15 November 2026 (requests 2.34.2, pillow 13.0.0, litellm 1.106.2, django 4.2): Simple API v1.4 file entries carry provenance as a URL string or null - pointing at Sigstore attestation bundles. The API exposes no repository identity: the publisher identity lives inside the Sigstore certificate, which this CLI does not decode. So change detection compares attestation PRESENCE between releases (attested vs unattested), and the finding says so. A change in either direction fails: attestations appearing from a new publisher is the LiteLLM signal, and attestations disappearing is a loss. This also flags a project adopting attestations for the first time; use --allow for that case.

How it compares (checked 15 November 2026)

Tool Form What it does well What it cannot do (depsignal's wedge)
pip-audit (PyPA) Python CLI Vulnerabilities via OSV for an installed env or requirements file Vulns only. No provenance/attestation, no yanked, no staleness, no late-file.
safety (Safety CLI) Python CLI Vulns + license from a commercial DB Vulns + license only; no registry provenance signals; commercial DB terms.
osv-scanner (Google) CLI Vulns across ecosystems from OSV Vulns only; no PyPI-specific signals (provenance/yanked/late-file).
trustcheck 2.2.3 Python CLI Per-package trust and provenance verification; extracts publisher-identity hints from provenance bundles Per-package pre-install check, no whole-list CI verdict, no baseline/diff over a requirements file.
pipsentinel 0.2.6 Python CLI Pre-install checks including PyPI provenance (OIDC attestation) Per-package pre-install check, no whole-list CI verdict, no baseline/diff.
vetgate (own factory, #6) CLI Pre-execution gate for ONE artifact: downloads the published tarball, inspects contents Different moment and signals: single artifact before install, content scan. depsignal audits the WHOLE dependency list from registry-published signals and downloads nothing.
extverify (own factory, #15) CLI Verify-before-install for agent extensions (publisher identity + marketplace trust) Different surface: extensions, not PyPI dependencies.
PyPI Security tab itself Web page Shows the signals, one project at a time Not machine-readable as a gate; no CI, no baseline, no diff. depsignal is the CLI that reads the same signals.

Where rivals beat us (read this before trusting depsignal alone): pip-audit and osv-scanner cover more ecosystems and match vulnerabilities more deeply, including transitive dependencies via lockfiles; safety has a curated database with remediation guidance; trustcheck already extracts publisher-identity hints from provenance bundles, which depsignal does not decode yet (it is on the roadmap). depsignal v0.1.0 is PyPI-only, direct dependencies only (no transitive resolution), and cannot see archived/quarantined status if PyPI does not expose it in the APIs it reads. depsignal is the registry-signal layer - provenance, yanked, late-file - and complements, not replaces, a vulnerability scanner.

Honest limits

  • PyPI only, direct dependencies only. No transitive resolution, no lockfiles, no other registries. A vulnerable transitive dependency is invisible to v0.1.0 - run pip-audit or osv-scanner alongside.
  • Archived / quarantined status is not claimed as detected. The Simple API exposes project-status (observed as {"status": "active"} on every project checked) and neither the JSON API nor the Simple API exposed an archived/quarantined flag for any project probed as of 15 November 2026. explain links the Security tab so a human can check.
  • Provenance change is a presence signal, not an identity proof. depsignal cannot tell you WHICH repository published a release, only whether attestations exist and whether that changed. A legitimate publisher migration and a pipeline takeover look the same to this signal - that is why it fails loudly and why --allow exists.
  • Vulnerability matching uses fixed_in per release line, not full OSV ranges. It is correct for the common case (django 4.2 < 4.2.1) and unit-tested on real cases, but a vuln whose fixed_in lists only another major line is treated as not affecting the pinned version. When in doubt, cross-check with pip-audit.
  • Staleness is a WARN, not a verdict on quality. Old stable packages are often finished, not abandoned.
  • Unpinned dependencies resolve to latest at audit time. The output labels the resolved version; pin for reproducible gates.

CI flow

- run: npm ci - run: npm run build - run: depsignal audit requirements.txt --save-baseline baseline.json 

For an existing project, commit the baseline and gate on new failures only:

depsignal check --baseline baseline.json --input requirements.txt 

Exit 1 only when a NEW failing appears. depsignal diff baseline-old.json baseline-new.json shows what was fixed and what is new.

Tests and verification

29/29 tests passing (15 unit + 14 e2e), live smoke 2/2 against real PyPI, CI green on Node 20 and 22. The test suite covers the failure modes, not just the happy path: PEP 440 comparison incl. pre/post/dev/epochs, vuln-range matching on the real django 4.2 case, GHSA/PYSEC dedupe, provenance classification + change detection, yanked, late-file, staleness, license, baseline diff, --allow, --limit, unknown package exit 2.

Feedback welcome in the repo issues. The roadmap (decode the Sigstore certificate to report the publishing repository and workflow, transitive dependencies via lockfile input, archived/quarantined detection the moment PyPI exposes it, OSV range-based matching, npm publish of depsignal-cli) is in the README.