DEV Community

uv audit vs pip-audit, and a gate narrower than it looks

uv audit vs pip-audit, and a gate narrower than it looks

Until now, the tools that install packages have never once stopped to ask, "Are you sure it's okay to take this one?" Whether it's pip install or npm install, they've been faithful delivery drivers: they read the name and version on the invoice, fetch exactly that item, and set it on your doorstep. Whether the item was recalled days ago, or is a counterfeit someone swapped in under the same name, was never the driver's concern.

This faithfulness is usually a virtue, but it carries one uncomfortable trait. A lockfile - a file that pins the exact versions and dependency graph of what you'll install, uv.lock being the canonical example - exists for the sake of reproducibility. The version you picked six months ago installs today, and on a colleague's machine too, identically, down to the last bit. But what if that choice six months ago was a bad one? A lockfile faithfully reproduces even a dangerous past decision. Even for a package that was pulled from PyPI after a problem surfaced, if your lockfile already knows where that file lives, the install path quietly stays open.

uv, the Python package manager built by Astral, started in 2026 to hand this faithful delivery driver a new role for the first time: the role of an inspector who pauses just before installing to say, "Hold on - this item is on a list." On June 8, Astral unveiled uv audit (a dependency vulnerability scanner) and a malicious-package check that runs at install time. Both are still preview (experimental, subject to change at any time).

I didn't just read about this. I pulled the latest uv, built a throwaway project deliberately stuffed with vulnerable packages, and ran uv audit side by side with the long-standing standard tool pip-audit against the same lockfile, measuring speed and results myself. I also cracked open the binary to see where the malicious-package gate actually fires. Along the way I hit a few places where the official blog's wording didn't quite match what I saw, and a security boundary far narrower than I'd expected. Let me walk through it.

The uv version used in this article is 0.11.23 (released 2026-06-19). Every measurement was done on a single Windows machine, in an isolated throwaway project that never touched any production environment.

First, let's separate two features that look alike

When you first see the announcement, it's easy to read it as a vague "uv added a security feature." But what actually landed is two things of a different nature, and conflating them makes the whole story confusing.

  • uv audit - a scanner that checks the package names and versions in your lockfile against records of already-known vulnerabilities. It doesn't block anything. It simply reports "there are N known problems among these" and stops there.
  • UV_MALWARE_CHECK - a gate that, just before uv sync performs the actual install, aborts the install itself if a known malicious package version is in the mix.

The latter is the one that comes close to a real "security gate." But both share a common limitation. Despite the name, neither analyzes the contents of a package file the way antivirus would. What both actually do is compare your packages against a "list of bad names and versions" already registered in a public database called OSV.

One-line glossary - OSV (Open Source Vulnerabilities): a public database and API that gathers, in a single common format, which version of which package corresponds to which known vulnerability or malicious behavior. It pulls in many sources - Python security advisories, OpenSSF's malicious-package reports, and more. Malicious-package records usually carry an identifier starting with MAL-.

Here's an analogy. This check is not the airport's baggage X-ray. It doesn't open your bag and look inside; it's closer to matching passenger names against a no-fly list at the gate. Anyone on the list gets stopped. But a new threat not yet on the list, a forged ID no one has seen before, simply walks through. Keep this analogy in mind and the "things this does not block," coming later, will make natural sense.

Running uv audit myself

Running it once is faster than describing it in words. I built a throwaway project deliberately filled with old, vulnerable versions.

[project]
name = "audit-fixture"
version = "0.1.0"
requires-python = ">= 3.11"
dependencies = [
    "requests==2.19.1",
    "jinja2==2.10",
    "pyyaml==5.3",
    "flask==0.12.2",
]

Running uv lock pinned it to 14 packages counting direct and indirect dependencies (including transitive dependencies - the ones I didn't list myself but another package dragged in, like urllib3 pulled in by requests or werkzeug pulled in by flask). Then I ran the audit on top of that.

$ uv audit
warning: `uv audit` is experimental and may change without warning.
Pass `--preview-features audit-command` to disable this warning.
Found 48 known vulnerabilities and no adverse project statuses in 13 packages

It's honest from the very first line. Because uv audit is preview, running it plain raises a warning that it's an experimental feature and may change at any time. Adding --preview-features audit-command only makes the warning go away; the feature behaves exactly the same. In other words, this flag isn't a "switch that turns the feature on" but a stamp that confirms "I know this is experimental."

The result: 48 known vulnerabilities across 13 packages. The human-readable text output breaks the vulnerabilities out package by package.

flask 0.12.2 has 7 known vulnerabilities:
- GHSA-562c-5r94-xh97: Flask is vulnerable to Denial of Service ...
  Fixed in: 0.12.3
  Advisory information: https://nvd.nist.gov/vuln/detail/CVE-2018-1000656
...

One-line glossary - CVE / GHSA: both are identifiers attached to a vulnerability. A CVE is a number many organizations use in common; a GHSA is a number GitHub assigns. The same incident can hold both numbers at once, and that fact becomes important later when comparing tools.

Not for humans - machine-readable output

Up to here, it's just a prettier pip-audit. The place uv audit is really aiming for is the CI (continuous integration) pipeline. That's why there are three output formats.

Format Purpose What I verified
text reading in a terminal the default
json post-processing / policy scripts schema (version=preview), summary, vulnerabilities[], adverse_statuses keys
sarif upload to GitHub Code Scanning SARIF 2.1.0, tool name uv 0.11.23, 48 rules and 48 results

One-line glossary - SARIF: a JSON specification for uploading static-analysis results to systems like GitHub in a standard format. With it, audit results show up automatically in a PR's "Security" tab. SARIF output was only added on June 18 in uv 0.11.22 - a version from a month earlier doesn't have it.

The exit code is CI-friendly too. It returns 1 if there's even one vulnerability and 0 if things are clean. So a single line of uv audit, with no extra script, can turn CI red. When I switched to a clean project with certifi as the only dependency, it returned exit code 0 as expected.

Silencing the noise, and the trap in it

Not every known vulnerability is "a risk to us right now." So --ignore lets you hide a specific advisory. What was interesting was the matching behavior. Passing a single --ignore GHSA-562c-5r94-xh97 dropped the count from 48 to 46 - two entries disappeared. That's because the alias records (CVE, PYSEC) tied to that GHSA were suppressed along with it. It means an ID is matched inclusive of its aliases.

There's an operational trap here. --ignore-until-fixed sounds reasonable - "hide it only until a fixed version ships" - but if a fixed version never ships, it stays hidden forever. It's not an expiry like "grant a pass for 30 days." So in practice it's safer to manage the ignore list separately, outside uv's configuration - who granted the pass, why, until when, and under which ticket.

# minimal form of an ignore policy that should live outside uv
id: GHSA-xxxx-yyyy-zzzz
owner: platform-security
reason: vulnerable code path is never actually invoked
expires: 2026-07-20
ticket: SEC-1234

Side by side with pip-audit: same answer, different speed

Now the real question. How is this different from pip-audit, the long-standing standard? I exported the same lockfile to requirements.txt, then ran both tools aligned to the same OSV data source.

Results first. I took the vulnerabilities each tool found, expanded them into sets down to their aliases, and compared.

Metric Value
uv audit: unique IDs (aliases included) 79
pip-audit: unique IDs (aliases included) 79
intersection 79 (symmetric difference 0)

They were completely identical. Even the distribution by type matched (PYSEC 18, CVE 30, GHSA 30, SNYK 1). The set of vulnerable packages was the same too. Which is also the expected result - both look at the same OSV. That's the first key point.

[!NOTE]
The value of uv audit is not that it "finds more vulnerabilities." If it looks at the same data, it finds the same things. The real difference is (1) speed, (2) native SARIF/JSON output, and (3) that it's integrated with the lockfile and resolver inside a single tool.

So what about speed? Here are the results, measured with the tools' caches warmed up.

Tool cold (first run) warm (median)
uv audit ~2.2s ~0.8s
pip-audit (OSV) 22.7s* ~16s
  • pip-audit's cold run includes the time to install the tool itself.

It felt like more than a 20x difference. But I have to be honest here. This is not a well-controlled benchmark. I let my pip-audit runs re-resolve dependencies every time (skipping optimizations like --no-deps), while I let uv use the lockfile as-is. The number Astral officially cites is "4โ€“10x," and in the same blog post they add their own caveat that the gap can shrink substantially once pip-audit's cache and resolution are sufficiently warm. My measurement can only say "uv is clearly faster"; the "exactly how many times" is a number my measurement conditions produced.

That's a lesson in itself. A benchmark can be inflated in any direction if you're inclined to. To trust a comparison you have to align the same data source, the same input scope, the same cache state - an "Nx faster" that doesn't is just a headline. (When I benchmarked the TypeScript 7 native compiler myself on this blog, the official "10x" turned out to be 3x on our code. A headline multiplier is always a number that came out of someone's particular codebase.)

The side that blocks installs: the malicious-package gate

uv audit only reports. Actually blocking an install is a separate feature. And here I ran into a point that diverges from the official guidance.

First, this gate attaches to uv sync (the install path), not to uv audit. To confirm how it's enabled, I dug through the strings in the uv 0.11.23 binary directly, and this came out.

Malware detected in one or more dependencies that would be installed; aborting sync.
Set `UV_MALWARE_CHECK=0` to bypass this check.
Malware checks are experimental and may change without warning.
Pass `--preview-features malware-check` to disable this warning.

Those two messages say a lot. Setting UV_MALWARE_CHECK=1 on an empty venv and running uv sync did raise the experimental-feature warning above, and the check ran. In other words, the activation switch is the environment variable UV_MALWARE_CHECK=1, and the --preview-features malware-check flag is merely a stamp that turns off the warning. It is not "you must turn on the preview flag to turn on the check." This distinction may look trivial, but it's exactly what you need to know when debugging "why isn't the check running" or "why did it suddenly start running" in CI.

The packages in my throwaway project were merely old, not known malicious packages, so the gate quietly let them through and installed all 13 normally. How it blocks when something is judged malicious, I confirmed from the abort message above and the binary implementation.

[!WARNING]
An honest limitation - I did not force a true positive by actually installing a real malicious package. Pulling known malware onto my own machine isn't safe. I confirmed that the check activates, that the gate sits on the install path, and that a blocking message exists - but I did not reproduce a positive detection itself. Where the article says it "blocks," that's a description grounded in the design and the message, not in a triggered detection.

A boundary narrower than expected

Up to this point it feels reassuring. But once you learn the things this gate does not block, you realize you have to use the phrase "security gate" carefully. The "no-fly list" analogy from earlier earns its keep here.

  1. It does not detect yanked files. The "adverse status" in the "no adverse project statuses" you saw in the uv audit output refers to the project-wide states defined by PEP 792 (archived, deprecated, quarantined). Think of it like a "Closed" or "No Entry" sign on a shop's front door. A yank, by contrast - the mark PyPI attaches to a specific distribution file to say "don't pick this one for new installs," like a "discontinued" sticker slapped on one particular production batch - is per individual file. As of June 2026, uv audit does not report these file-level yanks. The very scenario from the opening - "it was recalled, yet the lockfile keeps pointing at it" - is still a blind spot for this tool.

  2. The malicious-package gate only looks at PyPI-sourced packages. In the binary implementation, the malware check queries OSV only for packages in the lockfile that it judges to be from the PyPI registry. So the following currently fall outside the check:

    • dependencies pinned by direct URL (PEP 508 direct URL)
    • Git dependencies
    • local paths and wheel files
    • private registries
    • public PyPI packages relayed through a private mirror

    The last item is especially dangerous. An organization whose internal mirror simply relays public PyPI is prone to assume "these are PyPI packages, so of course they get checked" - but if the source looks like a mirror, they're skipped.

  3. Con

Comments

No comments yet. Start the discussion.