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.
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==versionis 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.explainlinks 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
--allowexists. - Vulnerability matching uses
fixed_inper 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 whosefixed_inlists 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.

