diff --git a/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py b/src/skillspector/nodes/analyzers/static_patterns_supply_chain.py index 5bbba8e2..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,7 +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) - version = m.group(3) if m.group(2) else None + version = _pinned_version(m.group(2), m.group(3)) results.append((name, version, i)) return results @@ -448,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 @@ -495,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 5c0525d3..e52bfc0f 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -1262,6 +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: + # 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 -> 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["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}'