Most Python projects accumulate dozens of third-party packages before anyone thinks to audit them. Each pinned version is a potential CVE waiting to be exploited. Knowing which packages carry known vulnerabilities — and being able to act on that programmatically — is not optional in 2026.
This post shows you how to build a dependency vulnerability scanner from scratch: parse requirements.txt, query the OSV (Open Source Vulnerabilities) API, and generate an actionable report you can wire into any CI/CD pipeline.
Why Not Just Use pip-audit or Safety?
Tools like pip-audit and safety are reasonable starting points, but both have real friction in practice:
-
pip-auditresolves packages against an installed environment — useless when you want to scan a lockfile in a container build stage without installing anything first. -
safetyrequires a paid plan for anything beyond basic checks, and its CVE database can lag behind OSV. - Both produce output in formats that resist integration into custom pipelines (Slack alerts, Jira tickets, SBOM feeds).
Building your own scanner means controlling the data source, the output format, and the integration points. The OSV API is free, maintained by Google's open-source security team, and covers the PyPI ecosystem comprehensively.
Step 1 — Parse the Requirements File
We start by reading requirements.txt and extracting package names with their pinned versions:
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class Dependency:
name: str
version: Optional[str]
def parse_requirements(filepath: str) -> list[Dependency]:
deps = []
for line in Path(filepath).read_text().splitlines():
line = line.strip()
# Skip comments, blank lines, and pip options (-r, --index-url, etc.)
if not line or line.startswith("#") or line.startswith("-"):
continue
# Match name==version, name>=version, name[extra]==version, etc.
match = re.match(
r"^([A-Za-z0-9_\-\.]+)(?:\[[^\]]+\])?\s*([=><!\^~]+)\s*([0-9][^\s,;#]*)?",
line
)
if match:
name = match.group(1)
op = match.group(2)
# Only treat pinned versions (==) as authoritative for CVE matching
version = match.group(3) if op == "==" else None
deps.append(Dependency(name=name, version=version))
return deps
Unpinned ranges (requests>=2.25.0) still get collected — we query OSV without a version to surface all known vulnerabilities, then flag them as "unpinned" in the report.
Step 2 — Query the OSV API
OSV exposes a simple POST endpoint that accepts a package name, ecosystem, and optionally a version:
import httpx
import time
from typing import Any
OSV_ENDPOINT = "https://api.osv.dev/v1/query"
def query_osv(dep: Dependency) -> list[dict[str, Any]]:
payload: dict[str, Any] = {
"package": {
"name": dep.name,
"ecosystem": "PyPI",
}
}
if dep.version:
payload["version"] = dep.version
try:
resp = httpx.post(OSV_ENDPOINT, json=payload, timeout=10)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
print(f"[warn] OSV returned {e.response.status_code} for {dep.name}")
return []
except httpx.RequestError as e:
print(f"[warn] Network error querying {dep.name}: {e}")
return []
return resp.json().get("vulns", [])
def scan_dependencies(
deps: list[Dependency], delay: float = 0.1
) -> dict[str, list[dict]]:
results: dict[str, list[dict]] = {}
for dep in deps:
vulns = query_osv(dep)
if vulns:
results[dep.name] = vulns
time.sleep(delay) # stay well under OSV's public rate limit
return results
The delay defaults to 100 ms — conservative enough for 200-package requirements files without being annoying. OSV's public rate limit sits around 10 req/s; we stay well below it.
Step 3 — Parse and Rank the Findings
Raw OSV responses are verbose. We extract CVE IDs, severity, and the earliest fixed version:
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
def cvss_to_severity(score: float) -> str:
if score >= 9.0:
return "CRITICAL"
elif score >= 7.0:
return "HIGH"
elif score >= 4.0:
return "MEDIUM"
elif score > 0:
return "LOW"
return "UNKNOWN"
def extract_finding(pkg_name: str, vuln: dict, pinned: bool) -> dict:
aliases = vuln.get("aliases", [])
cve_ids = [a for a in aliases if a.startswith("CVE-")]
severity = "UNKNOWN"
for sev in vuln.get("severity", []):
if sev.get("type") in ("CVSS_V3", "CVSS_V4"):
try:
numeric = float(sev.get("score", "").split("/")[0])
severity = cvss_to_severity(numeric)
except (ValueError, IndexError):
pass
fixed_versions = []
for affected in vuln.get("affected", []):
for rng in affected.get("ranges", []):
for event in rng.get("events", []):
if "fixed" in event:
fixed_versions.append(event["fixed"])
return {
"package": pkg_name,
"pinned": pinned,
"osv_id": vuln.get("id"),
"cve_ids": cve_ids,
"severity": severity,
"summary": vuln.get("summary", ""),
"fixed_in": sorted(set(fixed_versions)),
}
def generate_report(
scan_results: dict[str, list[dict]], pinned_map: dict[str, bool]
) -> list[dict]:
findings = []
for pkg, vulns in scan_results.items():
for vuln in vulns:
findings.append(extract_finding(pkg, vuln, pinned=pinned_map.get(pkg, False)))
return sorted(findings, key=lambda f: SEVERITY_ORDER.get(f["severity"], 5))
Step 4 — CLI Entry Point with CI Gate
import json, sys, argparse
def main():
parser = argparse.ArgumentParser(
description="Scan a Python requirements file for known CVEs via OSV"
)
parser.add_argument("requirements", help="Path to requirements.txt or requirements.lock")
parser.add_argument("--output", choices=["json", "text"], default="text")
parser.add_argument(
"--fail-on",
choices=["critical", "high", "medium"],
default=None,
help="Exit with code 1 if any finding is at or above this severity"
)
args = parser.parse_args()
deps = parse_requirements(args.requirements)
pinned_map = {d.name: d.version is not None for d in deps}
print(f"[*] Scanning {len(deps)} packages ({sum(pinned_map.values())} pinned)...", file=sys.stderr)
scan_results = scan_dependencies(deps)
report = generate_report(scan_results, pinned_map)
if args.output == "json":
print(json.dumps(report, indent=2))
else:
if not report:
print("[+] No vulnerabilities found.")
for f in report:
cves = ", ".join(f["cve_ids"]) or "no CVE alias"
fixed = ", ".join(f["fixed_in"]) or "no fix available yet"
label = "(pinned)" if f["pinned"] else "(UNPINNED)"
print(f"[{f['severity']}] {f['package']} {label}")
print(f" {f['osv_id']} | {cves}")
print(f" {f['summary']}")
print(f" Fixed in: {fixed}\n")
if args.fail_on and report:
threshold = {"critical": 0, "high": 1, "medium": 2}[args.fail_on]
blocking = [f for f in report if SEVERITY_ORDER.get(f["severity"], 5) <= threshold]
if blocking:
print(f"[!] {len(blocking)} finding(s) at or above '{args.fail_on}'. Failing build.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Running it looks like:
$ python vuln_scan.py requirements.txt --fail-on high
[*] Scanning 47 packages (47 pinned)...
[CRITICAL] jinja2 (pinned)
GHSA-h5c8-rqwp-cp95 | CVE-2024-56201
Jinja2 allows an attacker to execute arbitrary code via crafted templates
Fixed in: 3.1.5
[HIGH] cryptography (pinned)
GHSA-3ww4-gg4f-jr7f | CVE-2024-26130
NULL pointer dereference in PKCS12 serialization
Fixed in: 42.0.4
[!] 2 finding(s) at or above 'high'. Failing build.
Integrating into GitHub Actions
The exit code makes CI integration a one-liner. Here is a complete job definition:
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install httpx
- name: Export JSON report
run: python vuln_scan.py requirements.txt --output json > vuln-report.json
- name: Fail on HIGH or CRITICAL
run: python vuln_scan.py requirements.txt --fail-on high
- uses: actions/upload-artifact@v4
if: always()
with:
name: vuln-report
path: vuln-report.json
The JSON artifact lets you post findings to Slack, create Jira issues, or feed a dashboard — without depending on a third-party integration. Running the scanner twice (once to export, once to gate) is intentional: it keeps the exit-code logic clean without mixing it into the report generation.
One practical note: unpinned dependencies produce noisy results because OSV returns all historical CVEs, not just ones affecting a specific version range. Lock every dependency (pip freeze > requirements.txt or use pip-compile from pip-tools). Pinning is the foundation of any dependency hygiene program — if you want a broader checklist that covers this alongside other Python application security controls, our free security hardening checklists include a dedicated Python track.
The Takeaway
The full scanner is about 130 lines of Python with a single runtime dependency (httpx). It handles the cases off-the-shelf tools miss:
- Scans lockfiles without installing packages into an environment
- Integrates cleanly into custom pipelines via JSON output and exit codes
- Uses a free, authoritative data source (OSV) with no rate-limit anxiety at typical project sizes
- Distinguishes pinned (precise CVE match) from unpinned (all-vulns, noisy) packages
Extend it with SBOM output, Slack webhooks, or a SQLite cache to avoid re-querying packages that haven't changed since the last scan. The skeleton is intentionally minimal so you can add exactly what your pipeline needs — nothing more.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)