From b750a4627645487d90e608c8bd2cb0587c6b0af3 Mon Sep 17 00:00:00 2001 From: Mark2Mac Date: Thu, 23 Jul 2026 19:32:10 +0200 Subject: [PATCH 1/2] fix(supply-chain): treat only == / <= as version pins in requirements.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_extract_packages_from_requirements` kept the captured version for any operator, so a floor like `pillow>=10.0.0` was recorded as the exact release `10.0.0` and the OSV/CVE lookup attributed that version's vulnerabilities to an unpinned dependency — a false CRITICAL on a requirements file that pins nothing. Only `==` and `<=` bound the dependency to a concrete, CVE-checkable release; `>=`, `>`, `!=`, `~=` are floors/ranges. This mirrors the guard already present in `_extract_packages_from_setup_py` (`m.group(2) in ("==", "<=")`), so the two extractors now agree. Added a regression test asserting `>=`, `~=`, `!=` and bare names yield version=None while `==` / `<=` keep the version. Fixes #294 Signed-off-by: Mark2Mac --- .../analyzers/static_patterns_supply_chain.py | 7 ++++++- tests/unit/test_patterns_new.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 5bbba8e2..f0a4207c 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -427,7 +427,12 @@ def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | N m = re.match(r"^([a-zA-Z][a-zA-Z0-9._-]*)(?:\[.*?\])?\s*(?:([=<>!~]=?)\s*([\d.*]+))?", line) if m: name = m.group(1) - version = m.group(3) if m.group(2) else None + # Only "==" / "<=" bound the version to a concrete, CVE-checkable release. + # ">=", ">", "!=", "~=" are floors/ranges, not pins: treating them as an exact + # version makes a floor like "pillow>=10.0.0" report as "pillow==10.0.0" and + # attributes that release's CVEs to an unpinned dependency. Mirrors the guard + # already used by _extract_packages_from_setup_py. + version = m.group(3) if m.group(2) in ("==", "<=") else None results.append((name, version, i)) return results diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 5c0525d3..ef147986 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -1262,6 +1262,26 @@ def test_extract_packages_requirements(self) -> None: assert "numpy" in names assert "flask" in names + def test_extract_packages_requirements_specifier_is_not_a_pin(self) -> None: + # Only "==" / "<=" resolve to a concrete version; floors and ranges must not be + # reported as pins (regression: ">=" was treated as "==", so "pillow>=10.0.0" was + # scanned as the exact release "10.0.0" and flagged with that version's CVEs). + content = ( + "requests==2.31.0\n" # exact pin -> version kept + "pillow>=10.0.0\n" # floor -> version None + "click<=8.1.0\n" # upper cap -> version kept + "urllib3~=1.26.0\n" # compatible -> version None + "jinja2!=3.0.0\n" # exclusion -> version None + "flask\n" # unpinned -> version None + ) + versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_requirements(content)} + assert versions["requests"] == "2.31.0" + assert versions["click"] == "8.1.0" + assert versions["pillow"] is None + assert versions["urllib3"] is None + assert versions["jinja2"] is None + assert versions["flask"] is None + def test_extract_packages_package_json(self) -> None: content = ( '{\n "dependencies": {\n "express": "^4.18.0",\n "lodash": "4.17.21"\n }\n}' From 15788af2b2e548a8064fa5a43bcb8c4a779dd018 Mon Sep 17 00:00:00 2001 From: Mark2Mac Date: Thu, 30 Jul 2026 23:11:07 +0200 Subject: [PATCH 2/2] fix(supply-chain): only exact pins resolve to a version, ranges do not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #302: the previous guard still admitted non-exact constraints. `<=8.1.0` matches every earlier release and `==1.*` is a wildcard, so both were handed to the vulnerability lookup as a version the dependency may never install. A vulnerability lookup answers "is THIS release affected?", which is only meaningful when the manifest admits exactly one release. That predicate is now explicit and shared instead of being re-derived at each call site: - `_pinned_version` (PEP 440): only `==` with a fully concrete version. Floors, caps, exclusions, compatible releases and wildcard equality yield None. - `_pinned_npm_version` (semver): only a bare `x.y.z`. npm defaults to caret ranges, so `"^1.8.3"` was being stripped into the concrete release `1.8.3`. Applied to all three extractors — requirements.txt, pyproject.toml and package.json — because the objection in the review holds verbatim for the two that were not touched by the original patch. Note for the maintainer: dropping these specifiers moves more dependencies to version=None, which #318 shows is currently reported as CRITICAL carrying the package's worst-ever advisory. The two fixes are complementary; happy to send the severity side as a separate PR. Regressions cover both cases named in the review (`<=` and `==1.*`) plus the npm caret/tilde/wildcard/range forms. Signed-off-by: Mark2Mac --- .../analyzers/static_patterns_supply_chain.py | 38 ++++++--- tests/unit/test_patterns_new.py | 77 ++++++++++++++++--- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index f0a4207c..b6a380ea 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py +++ b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py @@ -417,6 +417,32 @@ def _is_typosquat(pkg_name: str, popular: set[str], max_distance: int = 2) -> st } +def _pinned_version(operator: str | None, version: str | None) -> str | None: + """Return *version* only when the specifier pins one concrete release. + + A vulnerability lookup answers "is THIS release affected?". That question is only + meaningful when the manifest admits exactly one release. Under PEP 440 that is ``==`` + with a fully concrete version: floors (``>=``, ``>``), caps (``<=``, ``<``), exclusions + (``!=``), compatible releases (``~=``) and wildcard equality (``==1.*``) all admit more + than one, so the installed version is unknown and must not be passed off as a pin. + """ + if operator != "==" or not version or "*" in version: + return None + return version + + +def _pinned_npm_version(spec: str) -> str | None: + """Return the pinned version of an npm dependency spec, or None for any range. + + npm defaults to caret ranges, so ``"^1.8.3"`` is *not* a pin: stripping the operator + turns a range into a concrete release that the project may never install. + """ + candidate = spec.strip() + if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", candidate): + return candidate + return None + + def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | None, int]]: """Extract (package_name, version_or_None, line_number) from requirements.txt format.""" results: list[tuple[str, str | None, int]] = [] @@ -427,12 +453,7 @@ def _extract_packages_from_requirements(content: str) -> list[tuple[str, str | N m = re.match(r"^([a-zA-Z][a-zA-Z0-9._-]*)(?:\[.*?\])?\s*(?:([=<>!~]=?)\s*([\d.*]+))?", line) if m: name = m.group(1) - # Only "==" / "<=" bound the version to a concrete, CVE-checkable release. - # ">=", ">", "!=", "~=" are floors/ranges, not pins: treating them as an exact - # version makes a floor like "pillow>=10.0.0" report as "pillow==10.0.0" and - # attributes that release's CVEs to an unpinned dependency. Mirrors the guard - # already used by _extract_packages_from_setup_py. - version = m.group(3) if m.group(2) in ("==", "<=") else None + version = _pinned_version(m.group(2), m.group(3)) results.append((name, version, i)) return results @@ -453,8 +474,7 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N m = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped) if m: name = m.group(1) - ver_str = m.group(2).lstrip("^~>=<") - version = ver_str if re.match(r"^\d", ver_str) else None + version = _pinned_npm_version(m.group(2)) results.append((name, version, i)) return results @@ -500,7 +520,7 @@ def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None if not m: continue name = m.group(1) - version = m.group(3) if m.group(2) in ("==", "<=") else None + version = _pinned_version(m.group(2), m.group(3)) idx = content.find(spec) line_num = get_line_number(content, idx) if idx >= 0 else 1 results.append((name, version, line_num)) diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index ef147986..e52bfc0f 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -1262,26 +1262,83 @@ def test_extract_packages_requirements(self) -> None: assert "numpy" in names assert "flask" in names + def test_pinned_version_only_accepts_exact_concrete_pins(self) -> None: + # A vulnerability lookup asks "is THIS release affected?", which is only meaningful + # when the manifest admits exactly one release. Everything else must yield None. + assert sc_mod._pinned_version("==", "2.31.0") == "2.31.0" + assert sc_mod._pinned_version("==", "1.*") is None # wildcard equality + assert sc_mod._pinned_version("<=", "8.1.0") is None # cap: admits every earlier + assert sc_mod._pinned_version("<", "8.1.0") is None + assert sc_mod._pinned_version(">=", "10.0.0") is None # floor + assert sc_mod._pinned_version(">", "10.0.0") is None + assert sc_mod._pinned_version("~=", "1.26.0") is None # compatible release + assert sc_mod._pinned_version("!=", "3.0.0") is None # exclusion + assert sc_mod._pinned_version(None, None) is None # bare dependency + + def test_pinned_npm_version_rejects_ranges(self) -> None: + # npm defaults to caret ranges: stripping the operator turns a range into a concrete + # release the project may never install (regression: "^1.8.3" -> "1.8.3"). + assert sc_mod._pinned_npm_version("4.17.21") == "4.17.21" + assert sc_mod._pinned_npm_version("1.2.3-rc.1") == "1.2.3-rc.1" + assert sc_mod._pinned_npm_version("^1.8.3") is None + assert sc_mod._pinned_npm_version("~4.18.0") is None + assert sc_mod._pinned_npm_version(">=1.2.3") is None + assert sc_mod._pinned_npm_version("1.x") is None + assert sc_mod._pinned_npm_version("*") is None + assert sc_mod._pinned_npm_version(">=1.2.3 <2.0.0") is None + assert sc_mod._pinned_npm_version("") is None + def test_extract_packages_requirements_specifier_is_not_a_pin(self) -> None: - # Only "==" / "<=" resolve to a concrete version; floors and ranges must not be - # reported as pins (regression: ">=" was treated as "==", so "pillow>=10.0.0" was - # scanned as the exact release "10.0.0" and flagged with that version's CVEs). + # Regression: any specifier was treated as "==", so the floor "pillow>=10.0.0" was + # scanned as the exact release 10.0.0 and flagged with that release's CVEs. content = ( - "requests==2.31.0\n" # exact pin -> version kept - "pillow>=10.0.0\n" # floor -> version None - "click<=8.1.0\n" # upper cap -> version kept - "urllib3~=1.26.0\n" # compatible -> version None - "jinja2!=3.0.0\n" # exclusion -> version None - "flask\n" # unpinned -> version None + "requests==2.31.0\n" # exact pin -> kept + "pillow>=10.0.0\n" # floor -> None + "click<=8.1.0\n" # cap -> None + "urllib3~=1.26.0\n" # compatible -> None + "jinja2!=3.0.0\n" # exclusion -> None + "boto3==1.*\n" # wildcard -> None + "flask\n" # unpinned -> None ) versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_requirements(content)} assert versions["requests"] == "2.31.0" - assert versions["click"] == "8.1.0" assert versions["pillow"] is None + assert versions["click"] is None assert versions["urllib3"] is None assert versions["jinja2"] is None + assert versions["boto3"] is None assert versions["flask"] is None + def test_extract_packages_pyproject_specifier_is_not_a_pin(self) -> None: + content = ( + "[build-system]\n" + 'requires = ["setuptools>=61", "wheel==0.42.0"]\n' + "[project]\n" + 'dependencies = ["httpx<=0.27.0", "rich==13.*"]\n' + ) + versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_pyproject(content)} + assert versions["wheel"] == "0.42.0" + assert versions["setuptools"] is None + assert versions["httpx"] is None + assert versions["rich"] is None + + def test_extract_packages_package_json_caret_is_not_a_pin(self) -> None: + content = ( + "{\n" + ' "dependencies": {\n' + ' "shell-quote": "^1.8.3",\n' + ' "lodash": "4.17.21",\n' + ' "semver": "~7.5.0",\n' + ' "glob": "*"\n' + " }\n" + "}" + ) + versions = {p[0]: p[1] for p in sc_mod._extract_packages_from_package_json(content)} + assert versions["lodash"] == "4.17.21" + assert versions["shell-quote"] is None + assert versions["semver"] is None + assert versions["glob"] is None + def test_extract_packages_package_json(self) -> None: content = ( '{\n "dependencies": {\n "express": "^4.18.0",\n "lodash": "4.17.21"\n }\n}'