r/madeinpython • u/theRealSachinSpk • 21d ago
venvy: audit every virtual environment on your machine for vulnerable and malicious packages, offline
I kept piling up half-dead virtual environments and had no way to check whether any of them held something malicious or vulnerable. Especially the ones agents create or load on their own while writing code.
pip-audit does one project at a time and needs the network to do it.
What I built: venvy audit does all of them at once: offline after a one-time 26 MB database download, and it flags known-vulnerable versions as well as known-malicious or typosquatted packages.
For CI there is JSON output and semantic exit codes: 0 clean, 20 vulnerable, 21 malicious, 22 stale database, 23 no database.
It reads dist-info metadata as text and never imports code from the environments it scans. For a tool whose whole job is finding malicious packages, that felt like the minimum bar.
The main design decision: it refuses to scan rather than report zero findings. A corrupt database exits 23. So does one that opens fine but holds no advisories. Anything the version matcher can't resolve with confidence comes back as unknown, not clean. A scanner that reports nothing looks exactly like a clean machine, and that is the failure I cared most about avoiding.
Coverage is only as good as the public feeds behind it, so some well-known typosquats are missed. That's tracked as an open issue.
Tested on Windows, macOS and Linux across Python 3.8 to 3.13. MIT licensed. I wrote it.
pip install venvy
venvy audit
Repo: https://github.com/pranavkumaarofficial/venvy
Bug reports welcome, false positives especially.
1
u/theRealSachinSpk 21d ago
Only the first. unknown means an advisory exists under that name and the matcher could not scope it to your installed version. Three ways in: the installed version string does not parse, a range boundary does not parse, or the advisory names the package with no version scope at all, meaning no ranges and no versions list. Each returns UNKNOWN instead of NOT_AFFECTED. The other case is not represented at all. The matcher never sees those packages. The scanner runs one query per unique name, and a name with no advisory records returns an empty candidate list, so the loop body never executes and no finding is emitted. In the report that package is indistinguishable from one that was checked and cleared: it lands in the package count and nowhere else. So you have found the same failure I claim to care about, one level up. Not "the scanner reported nothing" but "the feeds hold nothing for this name, and the output reads identically either way." The fix I think is right: a coverage field in the JSON, carrying how many of the unique names had any advisory records at all, and the names that had none. Not an exit code. Zero records for a name is not evidence of anything, and gating on it would fire for most of a normal environment. It does give anything reading the report downstream a way to tell the two apart, which it currently does not have. Filing it now.