SAST scanner · built-in table

Python Dependency Scanner

Unlike the npm scanner, this one needs no network. It reads requirements*.txt and pyproject.toml, pulls a package name and a version out of each line with one regex, and compares the version to a built-in table of 13 packages with a three-part integer compare. A line with no version at all is reported as unpinned.

SASTCritical–LowDependency Vulnerability13 packagesoffline

One line, one verdict

For every dependency line the scanner parses a name, an optional operator and an optional version. If there is no version it reports "unpinned" and stops. Otherwise it looks the normalised name up in the table and tests the version against each range for that package. The actor is the scanner reading a text file; nothing is installed and nothing is contacted.

requirements.txt lineparsedverdict Django==3.2.0django · == · 3.2.0HIGH <4.2.8CRITICAL <3.2.23two ranges match, two findings, 0.85 PyJWT==2.3.0pyjwt · == · 2.3.0CRITICAL <2.4.0CVE-2022-29217, one finding requestsrequests · no versionLOW · unpinnedconfidence 0.60, table not consulted flask>=2.3.2flask · >= · 2.3.2no finding2.3.2 is not below 2.3.2 -r base.txtstarts with "-"skippedblank lines and # comments too

1Five lines of one requirements.txt. Each is parsed by ^([a-zA-Z0-9_-]+)\s*(?:([=<>!~]+)\s*([.\w]+))?: a name, then an optional operator and version. Names are lower-cased with - and _ removed before the table lookup.

2Django==3.2.0 sits below two ranges in the table, <4.2.8 (HIGH) and <3.2.23 (CRITICAL). Each matching range is its own finding, so one line gives two, at confidence 0.85, with the file and line number.

3PyJWT==2.3.0 is below 2.4.0, the algorithm-confusion fix. CRITICAL, one finding.

4A name with no version is LOW, "Unpinned Python dependency", confidence 0.60. Any release could be installed, including a vulnerable one. The table is not consulted for this line.

5flask>=2.3.2 parses to version 2.3.2, which is not below the fix version, so nothing is reported. The operator is read but never used in the decision.

6Blank lines, comments and any line starting with - (-r, -e, --index-url) are skipped before parsing.

The version compare

Every range in the table has the form <X.Y.Z, and is_version_vulnerable supports nothing else. It splits both versions on dots, keeps three parts, pads with zero and compares the integer lists.

in the fileinstalled, as three integersvs the rangeresult django==4.2420<428[4,2,0] < [4,2,8]: vulnerablemissing part padded with 0; HIGHpillow==10.0.0rc11000rc1<1001int("0rc1") fails: silentValueError is caught, answer is "safe"flask>=2.0.0200<232operator ignored: 2.0.0 flaggedtitle reads flask==2.0.0; HIGHdjango<4.2.8428<428equal is not below: silentrange still allows 4.2.7

1The version in the file and the range threshold are each cut at the dots to three parts. A short version gets zeros appended, then Python compares the two lists element by element.

2django==4.2 has only two parts, so it becomes [4, 2, 0] against the threshold [4, 2, 8] of the range <4.2.8. The first two match and 0 is below 8, so it is vulnerable. Missing parts count as zero.

310.0.0rc1 splits into 10, 0, 0rc1 and int("0rc1") raises. The scanner catches that and answers "not vulnerable". Pre-release and any non-numeric suffix are silently safe. Four-part versions are cut to their first three.

4The operator is not part of the compare. flask>=2.0.0 is treated as the single version 2.0.0, flagged against <2.3.2, and titled flask==2.0.0, although pip would install the newest release that satisfies >=.

5django<4.2.8 reads as the version 4.2.8. A version equal to the threshold is not below it, so nothing is reported, even though the written range admits vulnerable 4.2.7.

Which files are parsed

The scanner walks every file in the repository index and decides by file name alone. Two different parsers handle the two formats, and the second is deliberately simple.

File name decides requirements.txtparsed requirements-dev.txtparsed services/api/requirements.txtparsed pyproject.tomlparsed requirements/base.txtnot parsed setup.py · Pipfilenot parsed poetry.lock · uv.locknot parsed the file name must be requirements*.txt so a requirements/ folder is invisible pyproject.toml [project] dependencies = [block starts on this exact line "django>=3.2.0",read: two findings 'pyyaml==5.0',single quotes: skipped "requests",read, never flagged unpinned ] [tool.ruff]a line starting with "[" ends it also starts a block: a [project.dependencies] header one-line dependencies = ["x"] is not recognised

1Every file in the repository index is tested by name. Nothing is read from a lock file or an environment.

2Requirements files are any file named requirements.txt or starting with requirements and ending in .txt, in any directory. requirements-dev.txt counts.

3Any file whose path ends in pyproject.toml is parsed by a separate, line-based reader.

4Not read at all: files in a requirements/ directory under other names such as base.txt, setup.py, Pipfile, and lock files. Dependencies declared only there are never compared.

5The pyproject reader turns a block on at dependencies = [ or [project.dependencies], reads lines that start with a double quote, and turns it off at the next line starting with [. It never reports unpinned; only a pinned version that matches the table counts.

Why it matters

Why it matters

Python has the same dependency problem as npm. PyJWT before 2.4.0 (CVE-2022-29217) allows an algorithm-confusion attack that forges tokens; PyYAML before 6.0.1 allows arbitrary code execution through yaml.load(); unpatched Django carries denial-of-service CVEs (CVE-2023-46695, CVE-2023-41164). Unpatched dependencies are the most common attack vector across all languages.

The Log4Shell pattern, in Python

Log4Shell was Java-specific, but the same class of flaw, remote code execution through deserialization, exists in Python packages such as PyYAML and Pickle.

Vulnerable vs fixed code

# requirements.txt
Django==3.2.0      # two CVE ranges match
PyJWT==2.3.0      # CVE-2022-29217, token forgery
PyYAML==5.4       # code execution via yaml.load()
requests           # unpinned: any version installs

The table and its limits

Thirteen packages, each with a single range of the form <X.Y.Z, except Django which has two. Package names are matched after lower-casing and removing - and _.

PackageVulnerable rangeSeverityNote
django<4.2.8HighCVE-2023-46695, DoS via file uploads
django<3.2.23CriticalCVE-2023-41164, DoS via URI validation
flask<2.3.2HighCVE-2023-30861, cookie handling
fastapi<0.109.1MediumCVE-2024-24762, DoS via multipart parsing
requests<2.31.0MediumCVE-2023-32681, proxy credential leak
cryptography<41.0.6HighCVE-2023-49083, NULL pointer dereference
pyjwt<2.4.0CriticalCVE-2022-29217, algorithm confusion
sqlalchemy<1.4.49MediumSQL injection via textual SQL expressions
jinja2<3.1.3MediumCVE-2024-22195, XSS via xmlattr
werkzeug<3.0.1HighCVE-2023-46136, DoS via multipart data
pillow<10.0.1HighCVE-2023-44271, DoS via crafted images
urllib3<2.0.7MediumCVE-2023-45803, cookie leak on redirect
paramiko<3.4.0CriticalCVE-2023-48795, Terrapin SSH truncation
pyyaml<6.0.1Criticalarbitrary code execution via yaml.load()
What it deliberately stays quiet about

The table is frozen in the source and was not updated against newer advisories; a package outside it is never reported, only its missing pin. Ranges with >=, ~= or != are compared as plain versions. The scanner's own docstring lists requirements/*.txt, but the name test accepts only files whose name starts with requirements. Findings carry the file path and line number; the unpinned finding is LOW at 0.60, so the severity range is Critical to Low. Some lines that are not plain pins also reach the unpinned branch: requests[security]==2.0 (extras) and git+https://… are reported as unpinned requests and git. For broader Python coverage see OSV dependencies, and for npm see npm dependencies.