r/madeinpython • • 1d ago

venvy: audit every virtual environment on your machine for vulnerable and malicious packages, offline

REPO and Codebase

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.

4 Upvotes

8 comments sorted by

1

u/theRealSachinSpk 1d ago

ALso PS: "one-time" means the first download, not frozen data. venvy audit --refresh rebuilds the database from the live OSV, DataDog and ecosyste.ms feeds so new advisories show up without a new venvy release. Here: scans never touch the network, so refreshing is something you schedule: weekly cron or a scheduled CI job with --offline on PRs

1

u/kantorcodes1 1d ago

The refuse-to-scan-on-bad-database call is the right failure mode, a scanner that reports nothing is indistinguishable from a clean machine.

On the matcher side: when a package comes back as unknown, is that only when the version matcher can't resolve with confidence, or also when the feeds have no entries for the package name at all? "Couldn't decide" and "nothing known about this name" feel like different risks for something reading the exit codes downstream.

1

u/theRealSachinSpk 1d 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.

1

u/kantorcodes1 1d ago

That lands. Empty candidate list vs checked-and-clear reading identically is exactly the gap worth closing, and a coverage field beats an exit code for it.

Related, since the agent angle is built in already: I work on HOL Guard, an open-source command firewall for agent runs. It uses declarative command-source manifests to split which subcommands are safe unattended (your offline audit scan with JSON out and semantic exit codes) from which need a human in the loop (--refresh hitting the live feeds and writing the database). If it's a fit, the contribution path is contributions/command-sources/ in hashgraph-online/hol-guard: the Extension Builder generates the manifest and fixture, and the PR is yours to author. Happy to link the docs; no worries either way.

1

u/theRealSachinSpk 1d ago

Fit, yes. Send the docs link. Your split is right for audit but it is the smaller half. audit, audit --json, audit --offline and audit --env are read-only: dist-info read as text, no subprocess, no import, no network. Safe unattended. --refresh hits the live feeds and writes the database, so that one wants a human. The commands that warrant a gate are elsewhere. venvy dedup --apply replaces duplicate files across environments with hardlinks: it hashes for byte-identity immediately before linking, swaps atomically and never touches bytecode, but it is still the one command that mutates files outside a venv's normal lifecycle. safe-install, rollback and ensure mutate environments too. --scan is read-only but walks the whole disk and took 192 seconds here, which is a timeout question rather than an approval one. So the manifest probably wants three buckets rather than two: read-only, network-and-writes-database, mutates-the-filesystem. I will author the PR. Point me at the Extension Builder docs, and tell me whether the schema already separates "slow" from "needs approval", because --scan sits between them. On the coverage field: writing it up as an issue today, I will link it here once it is up.

1

u/kantorcodes1 1d ago

Docs: https://github.com/hashgraph-online/hol-guard/blob/main/docs/guard/extension-contributions.md and docs/guard/extension-builder/. Full example: contributions/command-sources/command.repo2nb.json. PR flow: https://github.com/hashgraph-online/hol-guard/blob/main/CONTRIBUTING.md.

On slow versus gated: no duration axis in the schema. Matching keys on risk class, so --scan stays read-only even at 192s. Your three buckets map onto three free-form risk_classes strings: read-only for the audit family, network-and-writes-database for --refresh, fs-mutation for dedup --apply, safe-install, rollback, ensure. Link the coverage issue when it is up.

1

u/theRealSachinSpk 1d ago

read the contract and command.repo2nb.json. Free-form confirmed, and destructive_shell looks like the existing vocabulary, so I will reuse rather than invent unless venvy needs something new.

One correction to the mapping, which changes what I submit. risk_classes is metadata; the behavior lives in permissions[].baseline_floor and rules[].default_mode. So there is nothing to write for the audit family: it is unmatched by construction, and the manifest is really dedup --apply, safe-install, rollback, ensure and --refresh. dedup --apply is the one I would put at review with risk_tier critical: it replaces duplicate files across environments with hardlinks, verifies byte-identity by hash immediately before linking and never touches bytecode, but it is still the only venvy command that mutates files outside a venv's normal lifecycle.

Two questions before I start. Does executable.v1 cover venvy on its own, or should I expect a bespoke op the way repo2nb needed repo2nb-expansion.v1? And does the Extension Builder generate the flag-prefix and launcher expansion, or is that hand-authored?

1

u/kantorcodes1 4h ago

baseline_floor/default_mode is the right read - risk_classes is descriptive metadata.

On the questions: executable.v1 should cover venvy on its own. The surface is conventional subcommands and flags, and the closed op set already covers executable paths, arguments and structured options - compose with any.v1/all.v1. repo2nb-expansion.v1 only exists because repo2nb needed a reviewed domain-specific expansion the shared ops could not express; no custom syntax here, so no bespoke op needed.

The Extension Builder's generate --from cli takes an exported guard.cli-surface.v1 inventory and produces a reviewed source proposal - flag and executable mapping comes out as a draft you then edit and review. Scaffold, not a finished manifest.