PyDA Course
Intermediate Half-scaffolded ⏱ 60 min ⚡ +100 XP +15 XP per step

Build a Vulnerability Scanner

securitycliautomation

Security scanners feel like magic until you see the parts: a list of known-bad versions (that’s the CVE database), a check “is my version in a bad range?” (that’s version math), and a pass for patterns that shouldn’t be in shipping code (that’s SAST). This project builds all three in pure Python, parse requirements.txt into structured dependencies, match them against a small CVE database with severity, grep code files for dangerous anti-patterns, and emit a single severity-ranked report with upgrade suggestions. It will not cover your whole supply chain; it will demystify exactly how such a scanner thinks.

This assumes Python 101 and a bit of regex, nothing from Data Analysis is required. It’s optional and ungraded; see Real-World Projects for the full, growing list.

🎯 What you’ll do

  1. Parse a pinned requirements.txt into structured dependencies.
  2. Model a small CVE database with affected version ranges and severities.
  3. Match installed versions against the ranges and collect findings.
  4. Run regex-based SAST over code files for dangerous patterns.
  5. Combine both into a severity-ranked report with fix suggestions.

Where to run this

Locally with uv is the primary path. The scanner is pure standard library, but its real value is pointing it at your project’s requirements.txt and src/, files that live on a disk you own.

Google Colab, Kaggle Notebooks, and Binder run every cell identically (stdlib only), and the example notebook ships the sample requirements.txt and fragile.py right inside, so you see the full scan against a fixed sample project. The honest caveat: a notebook scanning the course repo’s own dependencies would show you the same engine against the real thing, but its ephemeral filesystem makes “scan my project” a local-only move. Use the badges to see the engine; run uv for the real audit.

Open In Colab Open In Kaggle Binder

Setup

Create the project. The scanner uses only the standard library, json, re, pathlib, and a version-splitting helper you’ll write because “which version is newer” is a real algorithm.

uv init vulnerability-scanner
cd vulnerability-scanner
uv run python -c "import json, re; from pathlib import Path; print('ok')"

json stores the CVE database as data, re powers the SAST patterns, and pathlib walks your src/ tree. You’ll write the version-comparison logic yourself in Step 3 rather than importing a version library, because that comparison is one of the two ideas this project teaches.

✅ Checklist

  • uv init vulnerability-scanner created a folder with a pyproject.toml.
  • ✅ The import check prints ok, zero packages added.

Step 1: Parse dependencies into structured specs

Every scan starts with “what do we actually have installed?” A requirements.txt is pinned truth, but only if you turn each line into a comparison, not a string.

1.1 Write parse_version and parse_requirements

👟 Starter hint: Split a version string like 2.28.1 into a numeric tuple, Python compares tuples in the right order for free, then regex each requirement line into name, operator, and version.

# scanner.py
import json
import re
from dataclasses import dataclass
from pathlib import Path

@dataclass
class Dependency:
    name: str
    operator: str          # "==" | ">=" | ">" | "<" | "<=" | "any"
    version: tuple[int, ...]


def parse_version(v: str) -> tuple[int, ...]:
    return tuple(int(part) for part in v.split("."))

def parse_requirements(path: str = "requirements.txt") -> list[Dependency]:
    deps = []
    pattern = re.compile(r"([\w\-\.]+)\s*(==|>=|<=|>|<)\s*([0-9\.]+)")
    for line in Path(path).read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        match = pattern.match(line)
        if not match:
            continue
        deps.append(Dependency(name=match.group(1),
                               operator=match.group(2),
                               version=parse_version(match.group(3))))
    return deps

Path("requirements.txt").write_text(
    "requests==2.28.1\nflask==2.2.5\nurllib3==1.26.0\nmakedata==0.9.0\n"
)
for dep in parse_requirements():
    print(dep)

parse_version is the quiet hero: by splitting 2.28.1 into (2, 28, 1), Python’s native tuple comparison does the hard work, (2, 28, 1) < (2, 31, 0) is true, and that’s precisely how “is this version vulnerable” gets answered in Step 3. The @dataclass gives each dependency a name and a comparison contract instead of a string to manually parse later. The regex keeps whitespace and comments from becoming fake dependencies: # pinned lines are skipped, and only lines with a valid name, operator, and dotted version become Dependency objects.

🎯 Expected output: Four Dependency(name=..., operator='==', version=(2, 28, 1)) lines, one per real requirement, comments and blanks ignored.

🩹 If it’s off: If a dependency’s version comes back as an empty tuple, parse_version never ran (a None group from the regex reached the dataclass). If lines like flask --hash=... crash, the regex match fails and continue gates it, but a strict match() on ([\w\-\.]+) also drops legitimate packages with _ in the name; widen the class to [\w\.\-]. If requirements parse from the wrong folder, Path("requirements.txt") is relative to the working directory.

1.2 Verify parsing

✅ Checklist

  • ✅ Four dependencies parse from the sample file; comments are ignored.
  • ✅ A flask>=3.0.0 line yields operator='>=' and version=(3, 0, 0).
  • ✅ Version tuples compare correctly: (2, 28, 1) < (2, 31, 0) is True.

🤔 Socratic Question(s)

  • A version like 1.10.0 would compare lower than 1.9.0 if you stored it as one number (int("1.10.0"), literally impossible). Which part of this design makes 1.10.0 > 1.9.0 come out right, and where would a package version like "2026.9.2rc1" break it?
  • The regex ignores lines it can’t match silently. When is silently skipping a malformed requirement worse than erroring, and what would you log to make the skip visible?

Step 2: Model a CVE database

A scanner is only as smart as its database. This step encodes a few CVEs as data, each with an affected range and a severity, and loads them from JSON so the scanner “knows” them the same way a real tool knows its feed.

2.1 Load the CVE database

👟 Starter hint: Keep the CVEs as a small JSON file and load it once with json.load, the operator/version pairs reuse exactly the comparison contract Step 1 built.

# scanner.py (continued)
CVE_DB_PATH = Path("cve_db.json")
CVE_DB_PATH.write_text(json.dumps([
    {"id": "CVE-2026-0001", "package": "requests", "operator": "<",
     "version": "2.31.0", "severity": "high",
     "summary": "SSL verification bypass on redirect"},
    {"id": "CVE-2025-1234", "package": "flask", "operator": "<=",
     "version": "2.2.5", "severity": "critical",
     "summary": "RCE reachable in debug mode"},
    {"id": "CVE-2026-1000", "package": "makedata", "operator": "<",
     "version": "1.0.0", "severity": "medium",
     "summary": "Slowloris-style memory leak"},
], indent=2))

def load_cves(path: str | None = None) -> list[dict]:
    with open(path or CVE_DB_PATH) as f:
        return json.load(f)

print([c["id"] for c in load_cves()])

The critical modeling decision is that a CVE carries an operator plus a version, ("<", "2.31.0") means “any version below 2.31.0 is affected”, so matching it to a dependency in Step 3 is just applying the same tuple comparison you already wrote for parse_version. Keeping severity as data (not code) means triaging by it later (Step 5) is a sort, not an if-else forest. Because the database lives in JSON rather than a Python file, updating it is a data edit, not a code edit.

🎯 Expected output: ['CVE-2026-0001', 'CVE-2025-1234', 'CVE-2026-1000'].

🩹 If it’s off: If the list is empty, load_cves opened an empty file or json.load swallowed a path mismatch. If a rank shows integers (1, 2), the file stored numerics but the schema expects severity as an exact string like "high". If a new CVE “doesn’t apply no matter the version”, its operator/version fields are misspelled.

2.2 Verify the database

✅ Checklist

  • load_cves() returns all three CVEs with id, package, operator, version, severity, summary.
  • severity values are exactly critical / high / medium (the sort in Step 5 depends on it).
  • ✅ Editing cve_db.json changes the scanner’s knowledge without touching code.

🤔 Socratic Question(s)

  • Each CVE here targets one package. Real CVEs use version ranges (>=1.0, <1.5). What happens to your single-operator model when a fix ships a 1.5.0 that reintroduces the bug, and what schema change would express two operators?
  • The CVSS score that decides severity in real life is computed from attack vector and exploitability. If you stored that as a number instead of critical/high/medium, what could your report do that a string severity can’t?

Step 3: Match dependencies against the database

With dependencies parsed and CVEs loaded, the scan itself is one comparison function: “is this installed version in this CVE’s affected range?” This step applies it to every dependency/CVE pair.

3.1 Write the matching logic

👟 Starter hint: Write one in_range(dep_version, cve) helper using the operator string as a dict of comparison lambdas, then loop every dep × every CVE.

# scanner.py (continued)
COMPARE = {
    "<": lambda a, b: a < b,
    "<=": lambda a, b: a <= b,
    ">": lambda a, b: a > b,
    ">=": lambda a, b: a >= b,
    "==": lambda a, b: a == b,
}

def in_range(dep: Dependency, cve: dict) -> bool:
    if dep.name != cve["package"]:
        return False
    cve_version = parse_version(cve["version"])
    return COMPARE[cve["operator"]](dep.version, cve_version)

def scan_dependencies(deps: list[Dependency], cves: list[dict]) -> list[dict]:
    findings = []
    for dep in deps:
        for cve in cves:
            if in_range(dep, cve):
                findings.append({
                    "type": "dependency",
                    "package": dep.name,
                    "installed": ".".join(str(p) for p in dep.version),
                    "cve": cve["id"],
                    "severity": cve["severity"],
                    "summary": cve["summary"],
                })
    return findings

for f in scan_dependencies(parse_requirements(), load_cves()):
    print(f["severity"], f["package"], f["installed"], f["cve"])

COMPARE as a dict of lambdas is the switch-statement Python doesn’t have: the operator string is the code branch, so a CVE arriving with "<=" works without editing the matcher. The guard dep.name != cve["package"] short-circuits package mismatches before any version math, which is what keeps the double loop (deps × CVEs) cheap at real scale. The finding dict is the contract every later stage consumes, it carries the severity for the Step 5 sort and the summary for human readability.

🎯 Expected output: Three findings sorted by the data, not by luck: requests 2.28.1 CVE-2026-0001, flask 2.2.5 CVE-2025-1234, makedata 0.9.0 CVE-2026-1000, with the flask one reporting the critical severity.

🩹 If it’s off: If requests never matches despite being < 2.31.0, in_range compares dep.version against a string that wasn’t parse_version-ed. If every dependency matches every CVE, the package guard is missing. If a KeyError fires on COMPARE[...], a CVE has an operator the five-map doesn’t cover, add it to COMPARE or validate the database at load.

3.2 Verify dependency scanning

✅ Checklist

  • ✅ Three findings exactly match the sample’s vulnerable pins; urllib3 produces none.
  • CVE-2026-0001 (< 2.31.0) does not fire for a hypothetical requests==2.31.0.
  • ✅ The severity ranking data (critical/high/medium) is present on every finding.

🤔 Socratic Question(s)

  • Dependencies pinned with >= instead of == declare a minimum, not an exact install. What can a scanner truly claim about a flask>=2.0.0 line versus a flask==2.2.5 line, and which is the honest subject of a version check?
  • The matcher assumes you have the exact installed version. Where do lockfiles (uv.lock, package-lock.json) fit in, what does scanning a lockfile buy you that scanning requirements.txt can’t?

Step 4: Scan source for anti-patterns with SAST

Dependency versions are one failure mode; code is the other. Static application security testing (SAST) scans source for patterns that shouldn’t ship, eval, shell strings, hardcoded secrets, without running the program. This step runs a tiny SAST pass over every .py file in a folder.

4.1 Write the pattern list and scanner

👟 Starter hint: Keep patterns as (regex, label, severity) tuples, walk .py files with Path.rglob, and search each line, flagging line numbers so the report is actionable.

# scanner.py (continued)
ANTI_PATTERNS = [
    (re.compile(r"\beval\s*\("), "eval() on untrusted data", "high"),
    (re.compile(r"\bshell\s*=\s*True"), "subprocess with shell=True", "high"),
    (re.compile(r"password\s*=\s*['\"][^'\"]+['\"]"), "Hardcoded password", "critical"),
    (re.compile(r"\bassert\s+"), "assert used for runtime checks", "low"),
    (re.compile(r"\bTODO\b|\bFIXME\b"), "Unresolved marker", "low"),
]

def scan_source(path: str = "src") -> list[dict]:
    findings = []
    for file in Path(path).rglob("*.py"):
        for lineno, line in enumerate(Path(file).read_text().splitlines(), 1):
            for pattern, label, severity in ANTI_PATTERNS:
                if pattern.search(line):
                    findings.append({
                        "type": "sast",
                        "file": str(file),
                        "line": lineno,
                        "severity": severity,
                        "summary": label,
                    })
    return findings

src = Path("src")
src.mkdir(exist_ok=True)
(src / "fragile.py").write_text(
    "import subprocess\n"
    "data = eval(input('code: '))\n"
    "password = 'hunter2'\n"
    "def run(cmd):\n"
    "    return subprocess.run(cmd, shell=True)\n"
    "assert password != ''\n"
    "# TODO: remove before ship\n"
)

for f in scan_source():
    print(f["line"], f["severity"], f["summary"])

The pattern-as-data design (regex, label, severity) means adding a check is adding one tuple, not rewriting the scanner, exactly how real tools let you drop in custom rules. rglob("*.py") finds files in nested folders, and iterating splitlines() with enumerate(..., 1) gives human line numbers. The finding carries the file and line, which is what turns a list of problems into a diff-able review. assert and TODO are deliberately low severity: they’re mostly hygiene, included so you can see severity have range.

🎯 Expected output: Five findings with line numbers 2–7, the eval (high) at line 2, a hardcoded password (critical) at line 3, shell=True (high) at line 5, and the assert and TODO (low) on their lines.

🩹 If it’s off: If nothing prints, rglob("*.py") found no files, check the src folder path. If the hardcoded-password rule fires on a variable named password = getenv(...), the regex ['\"][^'\"]+ is matching a function call too, require a quote literal. If findings double-count one line, several patterns matched the same line (legit) but you want one representative finding per line, dedupe by (file, line).

4.2 Verify SAST

✅ Checklist

  • scan_source("src") returns five findings with file, line, severity, summary.
  • ✅ The hardcoded-password rule reports critical.
  • ✅ Adding a new (regex, label, severity) tuple to ANTI_PATTERNS immediately produces findings on matching lines.

🤔 Socratic Question(s)

  • Regex SAST sees shell=True in a comment and a docstring too, because it never runs the code. What class of false positive does that produce, and what would a real parser-based tool (an AST) have to do instead to tell comment from code?
  • eval is flagged high, never critical, but an eval on attacker input is easily critical. What information does a line-scan lack that would let you raise that severity responsibly?

Step 5: Build the severity-ranked report with fixes

The last step makes the scanner useful: merge dependency and SAST findings, rank them by severity, attach an upgrade suggestion where one exists, print a human summary, and write the whole thing to report.json.

5.1 Write build_report and the main entry point

👟 Starter hint: Sort by a severity rank map (critical <-1 → low), append recommendation from a fixed-version map, print counts + top findings, and dump the merged list to JSON.

# scanner.py (continued)
import sys

SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
FIXED_VERSIONS = {"requests": "2.32.0", "flask": "3.0.0", "makedata": "1.0.1"}
FIX_HINT = "upgrade to >= {}"

def build_report(deps: list[Dependency], cves: list[dict], source_dir: str = "src") -> list[dict]:
    findings = scan_dependencies(deps, cves) + scan_source(source_dir)
    for f in findings:
        if f["type"] == "dependency" and f["package"] in FIXED_VERSIONS:
            f["recommendation"] = FIX_HINT.format(FIXED_VERSIONS[f["package"]])
    findings.sort(key=lambda f: SEVERITY_RANK.get(f["severity"], 9))
    return findings

def main() -> None:
    report = build_report(parse_requirements(), load_cves())
    Path("report.json").write_text(json.dumps(report, indent=2))
    print(f"report.json: {len(report)} findings")
    for f in report:
        rec = f.get("recommendation", "")
        print(f"  [{f['severity']:>8}] {f['summary']:<40} {f['package'] if f['type']=='dependency' else f['file']}  {rec}")

if __name__ == "__main__":
    main()

build_report merges both scanners and hands the list to a single severity sort, SEVERITY_RANK.get(severity, 9) falls back to a large number so an unexpected severity sorts last instead of crashing. The recommendation is attached as data only where a known fixed version exists, keeping the “how do I fix this?” column honest rather than guessing. sys appears only to gate main behind __name__, so import scanner in a test never runs the scan. The JSON at the end is the machine-readable contract a CI pipeline (the natural consumer of a scanner) would read.

🎯 Expected output: report.json contains 8 findings; the printed list starts with the two critical items (flask’s RCE and the hardcoded password), then highs, then lows, with upgrade recommendations on the three dependency findings.

🩹 If it’s off: If the report starts with lows, SEVERITY_RANK lookups are failing and every severity sorted to the 9 bucket. If recommendation never appears, FIXED_VERSIONS has a package key not in the findings (casings differ, normalizing names at parse fixes it). If report.json writes but a CI parser chokes on it, a finding is missing one of the fields the parser expects, keep the dict contract identical across both scanner types.

5.2 Verify the finished scanner

✅ Checklist

  • uv run python scanner.py writes report.json with 8 findings, criticals first.
  • ✅ The three dependency findings each carry a concrete upgrade recommendation.
  • ✅ SAST findings carry file/line; dependency findings carry package/installed.
  • ✅ Running the scanner on its own src adds exactly the findings you expect, a scanner that flags itself is working.

🤔 Socratic Question(s)

  • The report sorts by severity but keeps a vulnerability’s reachability out of its ranking. Which matters more when triaging, severity alone, or severity × “is this even in the hot path”? What column would you add to encode that?
  • A scanner that reports everything trains teams to ignore all of it. What (in this report’s data) would you present differently for a team that gets 200 findings a month versus one that gets 2, and why does triage UI decide whether a scanner lives or dies?

⚠️ Common pitfalls

  • Version tuples that don’t parse. A string version like "1.10.0rc1" kills int(part) and the whole scan crashes. Fix: strip suffixes (split("-")[0], drop a trailing rcN) before converting, and let malformed pins log rather than abort.
  • Case drift on package names. Requests vs requests vs requests[socks] all fail the exact-match guard and silently miss CVEs. Fix: normalize names (and drop extras like [socks]) once at parse time.
  • Relying on requirements.txt for the installed truth. Pins express intent, not necessarily the running version. Fix: if the environment has a lockfile or pip freeze output, scan that instead, it’s the real inventory.
  • Regex SAST crying wolf on comments. shell=True in a comment is a policy file, not a hole. Fix: report it but let a human weigh it, and prefer AST-style checks (name trees, string literals) for anything you’d act on automatically.
  • Unranked severities sorting last-by-accident. A CVE with a typo’d "critial" sinks to the bottom instead of the top. Fix: SEVERITY_RANK.get(severity, 9) is the safe fallback, plus a warning when an unknown severity is seen.

What you just built

A working, standard-library vulnerability scanner: structured dependency parsing, a JSON CVE database with range matching, regex-based SAST over source, and a single severity-ranked report with upgrade recommendations written to JSON. The transferable skill is turning “security” into data operations: version-range comparison, pattern matching, and severity sorting are the exact same moves behind dependency bots, lint rules, and every “check my project” tool, you’ve now built three of them from scratch.

Run a fuller version without any local setup

examples/vulnerability-scanner/ in the course repo is a fuller version of the code above, with lockfile support and a richer sample project to scan. Clone it, or open the whole repo in a GitHub Codespace, and run it from there.

Where to go from here

  • Expand parse_requirements to read [project] tables out of pyproject.toml, so the scanner covers uv/pip projects, not just legacy requirements.txt.
  • Add an --ast mode that walks the AST instead of lines and flags eval/exec only when they maybe reach untrusted input, fewer false positives, same coverage.
  • Wire a CVE severity score in (via the numeric CVSS idea from Step 2’s question) and print a project-wide risk total as the summary headline.
  • Make main return a non-zero exit code when any critical/high finding exists, so a CI job that runs the scanner actually fails a build.

Share your project with the class

Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.

Welcome to writing Python outside the browser. 🎓

Finish every step, then mark this project complete to claim its XP.