Most security problems in a Python CLI are not in its own code. They are published advisories against one of the dozens of packages it depends on, transitively — an XML parser that expands entities, a template engine with a sandbox escape, an HTTP library that leaks headers on redirect. The fix usually exists by the time anyone notices; the problem is noticing. pip-audit, maintained by the Python Packaging Authority, compares a set of packages against the Python Packaging Advisory Database and OSV and reports every known vulnerability with the versions that fix it. This guide runs it against exactly what your CLI ships — the lock file — wires it into CI on pull requests and on a schedule, and adds the piece that makes it sustainable: a triage allowlist where every exception has a reason and an expiry date. It belongs to the supply-chain security topic.
Prerequisites
- A CLI project managed with uv (
uv.lock) — Poetry works the same way withpoetry export. uvxto runpip-auditwithout adding it to the project; Python 3.11+ fortomllibin the triage script.- A CI system; the examples use GitHub Actions, as in testing a CLI across Python versions with GitHub Actions.
Audit what you ship, not what is installed
pip-audit with no arguments audits the current environment — whatever happens to be installed in the virtual environment you run it from, including developer tools and stale packages. That is rarely the question you care about. Export the lock file to a requirements file and audit that instead:
uv export --frozen --no-emit-project --format requirements.txt -o requirements.txt
uvx pip-audit -r requirements.txt --disable-pip
--frozen refuses to touch the lock file, so the audit sees exactly what CI and your release build install. --no-emit-project leaves out your own package, which is not on the advisory databases. --disable-pip tells pip-audit not to resolve anything — the exported file is already fully pinned and hashed, so it simply looks up each pinned version. Add --no-dev to the export to audit only runtime dependencies, which is what users get; keep development dependencies in a separate, lower-priority audit if you want to know about them too.
The output lists each vulnerable package, the advisory ID and the fixed versions:
Found 4 known vulnerabilities in 1 package
Name Version ID Fix Versions
------ ------- --------------- ------------
jinja2 3.1.2 PYSEC-2026-1473 3.1.3
jinja2 3.1.2 PYSEC-2026-1474 3.1.4
The exit code is 1 when anything is found and 0 otherwise, which is all a CI gate needs.
Running it in CI
Two triggers cover the important cases. On pull requests that change dependencies, an audit stops a vulnerable version from being introduced. On a schedule, an audit catches new advisories against versions you already ship — the more common case, since advisories are published long after releases.
# .github/workflows/audit.yml
name: audit
on:
pull_request:
paths: ["pyproject.toml", "uv.lock", "audit-allowlist.toml"]
schedule:
- cron: "23 5 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
pip-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv export --frozen --no-dev --no-emit-project --format requirements.txt -o requirements.txt
- run: uvx pip-audit -r requirements.txt --disable-pip -f json -o audit.json || true
- run: uv run --no-project python scripts/triage_audit.py audit.json audit-allowlist.toml
pip-audit writes JSON and is allowed to "fail" here because the decision moves to the triage script in the last step. (Pin the actions to commit SHAs in your real workflow, as the topic overview explains.) A failing scheduled run should notify someone — GitHub emails the workflow's last editor by default, which is easy to miss; routing failures to a team channel is better.
Triage with expiring exceptions
Not every advisory affects your CLI. A vulnerability in a library's server component does not matter to a client that never starts a server. pip-audit --ignore-vuln ID exists for this, but a list of ignored IDs in a workflow file quietly becomes permanent. An allowlist file that requires a reason and an expiry date keeps exceptions honest:
# audit-allowlist.toml
[[ignore]]
id = "GHSA-h5c8-rqwp-cp95"
package = "jinja2"
reason = "Only affects xmlattr filter with user-controlled keys; we never use xmlattr."
expires = 2026-12-31
The triage script reads pip-audit's JSON, removes allowed findings — matching the advisory's ID or any of its aliases, since databases use different identifiers for the same issue — and fails on anything left, or on any exception that has expired:
# scripts/triage_audit.py
from __future__ import annotations
import json
import sys
import tomllib
from dataclasses import dataclass
from datetime import date
from pathlib import Path
@dataclass(frozen=True)
class Finding:
package: str
version: str
id: str
aliases: tuple[str, ...]
fixes: tuple[str, ...]
def findings(report: dict) -> list[Finding]:
seen: dict[tuple[str, str], Finding] = {}
for dep in report.get("dependencies", []):
for v in dep.get("vulns", []):
f = Finding(dep["name"], dep["version"], v["id"], tuple(v.get("aliases", [])),
tuple(v.get("fix_versions", [])))
seen[(f.package, f.id)] = f # pip-audit can list duplicates
return list(seen.values())
def evaluate(report: dict, allowlist: dict, today: date) -> tuple[list[Finding], list[str]]:
"""Return (unhandled findings, problems with the allowlist)."""
problems, allowed = [], set()
for entry in allowlist.get("ignore", []):
if not entry.get("reason"):
problems.append(f"{entry.get('id')}: an exception needs a reason")
elif entry.get("expires") is None or entry["expires"] < today:
problems.append(f"{entry['id']}: exception expired on {entry.get('expires')}")
else:
allowed.add(entry["id"])
open_findings = [f for f in findings(report)
if f.id not in allowed and not allowed.intersection(f.aliases)]
return open_findings, problems
def main(argv: list[str]) -> int:
report = json.loads(Path(argv[0]).read_text())
allow_path = Path(argv[1])
allowlist = tomllib.loads(allow_path.read_text()) if allow_path.exists() else {}
open_findings, problems = evaluate(report, allowlist, date.today())
for f in open_findings:
fix = ", ".join(f.fixes) or "no fix yet"
print(f"VULNERABLE {f.package} {f.version}: {f.id} ({', '.join(f.aliases)}) -> {fix}")
for p in problems:
print(f"ALLOWLIST {p}")
if not open_findings and not problems:
print("audit clean")
return 1 if open_findings or problems else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
The expiry date is the important part. It forces a periodic re-check of every exception — perhaps the code path is now used, perhaps a fix has shipped and the exception can simply be deleted by upgrading.
Fixing what you find
For most findings, the fix is an upgrade of the vulnerable package in the lock file:
uv lock --upgrade-package jinja2
uv run pytest
uv lock --upgrade-package moves only that package (and whatever its new version requires), keeping the change small and reviewable. If the fixed version is excluded by a constraint in your own pyproject.toml, relax the constraint deliberately. If the vulnerable package is a transitive dependency whose parent pins an old version, either upgrade the parent or add a direct lower bound for the fixed version and let the resolver find a compatible set. pip-audit --fix can upgrade an environment automatically, but it does not edit uv.lock, so for locked projects the explicit uv lock command is the right tool.
When no fix exists yet, the choices are to add an allowlist entry with a short expiry while you watch the advisory, to stop using the vulnerable feature, or to replace the dependency. Write down which and why — the allowlist's reason field is the natural place.
UX considerations
The "users" of an audit are your maintainers, and a noisy or confusing audit gets ignored:
- Fail with an actionable line per finding: package, installed version, advisory ID with aliases, and fixed versions. The triage script prints exactly that.
- Separate runtime from development dependencies. A vulnerability in a test-only tool is worth knowing about but rarely urgent; do not block releases on it.
- Keep the allowlist in the repository, reviewed like code, so exceptions are visible in pull requests.
- Run the same command locally. A
just auditormake audittarget that matches CI lets contributors reproduce a failure without guessing. - Mention advisories in release notes when you ship a fix, so users know to upgrade.
Testing the behaviour
The triage logic is pure and easy to test with small report fixtures:
# tests/test_triage_audit.py
from datetime import date
from scripts.triage_audit import evaluate
REPORT = {"dependencies": [
{"name": "jinja2", "version": "3.1.2", "vulns": [
{"id": "PYSEC-1", "aliases": ["GHSA-aaaa"], "fix_versions": ["3.1.3"]},
{"id": "PYSEC-1", "aliases": ["GHSA-aaaa"], "fix_versions": ["3.1.3"]},
{"id": "PYSEC-2", "aliases": [], "fix_versions": []},
]},
{"name": "click", "version": "8.5.0", "vulns": []},
]}
TODAY = date(2026, 10, 2)
def entry(id, expires=date(2026, 12, 31), reason="not reachable"):
return {"ignore": [{"id": id, "reason": reason, "expires": expires}]}
def test_duplicates_collapse():
found, problems = evaluate(REPORT, {}, TODAY)
assert sorted(f.id for f in found) == ["PYSEC-1", "PYSEC-2"] and problems == []
def test_alias_matches_allowlist():
found, _ = evaluate(REPORT, entry("GHSA-aaaa"), TODAY)
assert [f.id for f in found] == ["PYSEC-2"]
def test_expired_exception_is_a_problem():
found, problems = evaluate(REPORT, entry("PYSEC-1", expires=date(2026, 9, 1)), TODAY)
assert "PYSEC-1" in [f.id for f in found]
assert "expired" in problems[0]
def test_exception_without_reason_is_rejected():
_, problems = evaluate(REPORT, entry("PYSEC-2", reason=""), TODAY)
assert "needs a reason" in problems[0]
Add a __init__.py to scripts/ (or put the module in your package) so tests can import it. For an end-to-end check, commit a small audit.json fixture captured from a real run, so changes to pip-audit's output format show up as a failing test rather than a silently passing audit.
Conclusion
A dependency audit is cheap insurance: export the lock file, run pip-audit with --disable-pip, and gate both pull requests and a daily schedule on the result. What keeps it useful over months is triage discipline — exceptions with reasons and expiry dates, alias-aware matching, and targeted uv lock --upgrade-package fixes. Combined with hash pinning and an SBOM, it gives you a clear, defensible answer to "are we shipping anything known to be vulnerable?".
Frequently asked questions
How is pip-audit different from safety or GitHub's Dependabot alerts?
All three check dependencies against advisory data. pip-audit is free, maintained by PyPA, uses the open PyPA and OSV databases, and runs anywhere, which makes it a good CI gate. Dependabot alerts are convenient on GitHub and can open fix pull requests; running both is common and they complement each other.
Does pip-audit scan my own code for vulnerabilities?
No — it only checks declared dependencies against known advisories. For your own code, use a static analyser such as Bandit or Ruff's S (flake8-bandit) rules, covered in configuring Ruff for a CLI project.
Why does the same vulnerability appear under different IDs?
Advisory databases assign their own identifiers — PYSEC-…, GHSA-…, CVE-… — for the same issue. pip-audit reports one ID and lists the others as aliases, which is why the triage script matches allowlist entries against both.
Should the audit block releases?
Block on runtime dependencies with known fixes. For advisories without a fix, or in development-only tools, record an allowlist entry and release anyway — blocking a release over something you cannot fix only delays other fixes.