from __future__ import annotations import ast from pathlib import Path import pytest from packaging.specifiers import SpecifierSet from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Dependency from vuln_scan.ecosystems.python import PythonEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> PythonEcosystemHandler: return PythonEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: PythonEcosystemHandler) -> None: assert "pyproject.toml" in handler.manifest_names assert "setup.py" in handler.manifest_names assert "*requirements*.txt" in handler.manifest_globs assert "poetry.lock" in handler.lockfile_names assert "Pipfile.lock" in handler.lockfile_names assert "uv.lock" in handler.lockfile_names # -------------------------------------------------------- # parse_manifest / parse_manifest_content dispatch # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: PythonEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "requirements.txt" manifest.write_text("requests==2.31.0\n", encoding="utf-8") parsed = handler.parse_manifest("requirements.txt", manifest) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "requests" assert parsed.dependencies[0].version == "2.31.0" assert parsed.manifest_type == "manifest" assert parsed.parser_name == "python" @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_name", [ pytest.param( "unknown.xyz", "x", "manifest", 0, None, id="unknown-file-returns-empty", ), pytest.param( "Pipfile.lock", '{"default": {"requests": {"version": "==2.31.0"}}}', "lockfile", 1, "requests", id="pipfile-lock-detected-as-lockfile", ), pytest.param( "pyproject.toml", '[tool.poetry.dependencies]\npython = ">=3.13"\nrequests = "==2.31.0"\n', "manifest", 1, "requests", id="pyproject-toml-dispatch", ), pytest.param( "poetry.lock", '[[package]]\nname = "requests"\nversion = "2.31.0"\n', "lockfile", 1, "requests", id="poetry-lock-dispatch", ), pytest.param( "uv.lock", '[[package]]\nname = "requests"\nversion = "2.31.0"\n', "lockfile", 1, "requests", id="uv-lock-dispatch", ), pytest.param( "setup.py", 'from setuptools import setup\nsetup(install_requires=["requests==2.31.0"])\n', "manifest", 1, "requests", id="setup-py-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: PythonEcosystemHandler, filename: str, content: str, expected_type: str, expected_count: int, expected_name: str | None, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert parsed.manifest_type == expected_type assert len(parsed.dependencies) == expected_count if expected_name is not None: assert parsed.dependencies[0].name == expected_name @pytest.mark.parametrize( "filename, content, expected_count", [ # ---- requirements.txt ---- pytest.param( "requirements.txt", "", 0, id="requirements-txt-empty", ), pytest.param( "requirements.txt", "# just a comment\n-r other.txt\n--index-url https://pypi.org\n", 0, id="requirements-txt-only-comments-and-flags", ), pytest.param( "requirements.txt", "!!!invalid requirement line\n", 0, id="requirements-txt-unparseable-line", ), # ---- Pipfile.lock ---- pytest.param( "Pipfile.lock", "not valid json {{{", 0, id="pipfile-lock-invalid-json", ), pytest.param( "Pipfile.lock", '["a", "b"]', 0, id="pipfile-lock-non-dict-top-level", ), pytest.param( "Pipfile.lock", '{"default": "not-a-dict"}', 0, id="pipfile-lock-non-dict-section", ), pytest.param( "Pipfile.lock", '{"default": {"bad-pkg": "not-a-dict"}}', 0, id="pipfile-lock-non-dict-package-entry", ), pytest.param( "Pipfile.lock", '{"default": {}}', 0, id="pipfile-lock-empty-section", ), # ---- pyproject.toml ---- pytest.param( "pyproject.toml", "{{invalid toml", 0, id="pyproject-toml-invalid-toml", ), pytest.param( "pyproject.toml", '[tool.poetry.dependencies]\npython = ">=3.13"\n', 0, id="pyproject-toml-only-python-runtime-skipped", ), pytest.param( "pyproject.toml", "[project]\nname = 'my-app'\n", 0, id="pyproject-toml-no-poetry-deps-section", ), # ---- poetry.lock / uv.lock ---- pytest.param( "poetry.lock", "{{invalid toml", 0, id="poetry-lock-invalid-toml", ), pytest.param( "poetry.lock", '[package]\nname = "requests"\nversion = "2.31.0"\n', 0, id="poetry-lock-package-not-a-list", ), pytest.param( "poetry.lock", "package = [1, 2, 3]\n", 0, id="poetry-lock-non-dict-package-entries", ), pytest.param( "poetry.lock", '[[package]]\nname = ""\nversion = "1.0.0"\n', 0, id="poetry-lock-empty-name-skipped", ), pytest.param( "poetry.lock", '[[package]]\nname = "no-version"\n', 0, id="poetry-lock-missing-version-skipped", ), pytest.param( "poetry.lock", "[metadata]\npython-versions = '>=3.13'\n", 0, id="poetry-lock-no-package-section", ), pytest.param( "uv.lock", "{{invalid toml", 0, id="uv-lock-invalid-toml", ), pytest.param( "uv.lock", 'package = "not-a-list"\n', 0, id="uv-lock-package-is-string", ), pytest.param( "uv.lock", '[[package]]\nname = "valid"\nversion = "1.0.0"\n\n[[package]]\nname = ""\nversion = "2.0.0"\n', 1, id="uv-lock-partial-bad-entries-keeps-valid", ), # ---- setup.py ---- pytest.param( "setup.py", "def not_setup():\n pass\n", 0, id="setup-py-no-setup-call", ), pytest.param( "setup.py", "this is not valid python {{{\n", 0, id="setup-py-syntax-error", ), pytest.param( "setup.py", "from setuptools import setup\nsetup(install_requires=[42, None])\n", 0, id="setup-py-non-string-requirements-skipped", ), pytest.param( "setup.py", 'from setuptools import setup\nsetup(install_requires=["!!!invalid"])\n', 0, id="setup-py-unparseable-requirement-skipped", ), ], ) def test_parse_manifest_content_negative_cases( handler: PythonEcosystemHandler, filename: str, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep, alert, expected_vuln, expected_confidence", [ pytest.param( make_dependency(version="2.20.0"), make_alert(vulnerable_range=None), True, "low", id="no-vuln-range-returns-true-low", ), pytest.param( make_dependency(version="2.20.0", is_lockfile=True), make_alert(vulnerable_range="<=2.25.0"), True, "high", id="lockfile-version-vulnerable-high-confidence", ), pytest.param( make_dependency(version="2.30.0", is_lockfile=True), make_alert(vulnerable_range="<=2.25.0"), False, "high", id="lockfile-version-not-vulnerable-high-confidence", ), pytest.param( make_dependency(version="2.20.0", is_lockfile=False), make_alert(vulnerable_range="<=2.25.0"), True, "medium", id="manifest-version-vulnerable-medium-confidence", ), pytest.param( make_dependency(version="2.30.0", is_lockfile=False), make_alert(vulnerable_range="<=2.25.0"), False, "medium", id="manifest-version-not-vulnerable-medium-confidence", ), pytest.param( make_dependency(requirement=">=2,<3"), make_alert(vulnerable_range=">=2,<2.1"), True, "low", id="requirement-overlap-true", ), pytest.param( make_dependency(requirement=">=4,<5"), make_alert(vulnerable_range=">=2.2,<2.8"), False, "low", id="requirement-overlap-false", ), pytest.param( make_dependency(requirement="^44.0.2"), make_alert(vulnerable_range=">= 0.5.0, < 48.0.1"), True, "low", id="caret-requirement-overlap-true", ), pytest.param( make_dependency(requirement="^44.0.2"), make_alert(vulnerable_range="<44.0.0"), False, "low", id="caret-requirement-overlap-false", ), pytest.param( make_dependency(requirement="*"), make_alert(vulnerable_range="<44.0.0"), True, "low", id="bare-wildcard-requirement-conservative-true-low", ), pytest.param( make_dependency(version=None, requirement=None), make_alert(vulnerable_range="<=2.0"), True, "low", id="no-version-no-requirement-conservative-true-low", ), pytest.param( make_dependency(version="not-a-version"), make_alert(vulnerable_range="<=2.0"), True, "low", id="parse-error-invalid-version-returns-true-low", ), pytest.param( make_dependency(version="2.20.0"), make_alert(vulnerable_range="not-a-valid-range!!!"), True, "low", id="parse-error-invalid-vuln-range-returns-true-low", ), pytest.param( make_dependency(version="2.20.0"), make_alert(vulnerable_range=""), True, "low", id="empty-vuln-range-falls-through-to-exception-handler", ), ], ) def test_is_vulnerable( handler: PythonEcosystemHandler, dep: Dependency, alert, expected_vuln: bool, expected_confidence: str, ) -> None: is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == expected_confidence # -------------------------------------------------------- # _normalize_poetry_requirement # -------------------------------------------------------- @pytest.mark.parametrize( "requirement, expected", [ pytest.param("^44.0.2", ">=44.0.2,<45.0.0", id="caret-full-version"), pytest.param("^0.2.3", ">=0.2.3,<0.3.0", id="caret-zero-major"), pytest.param("^0.0.3", ">=0.0.3,<0.0.4", id="caret-zero-major-minor"), pytest.param("^1", ">=1,<2.0.0", id="caret-major-only"), pytest.param("^0", ">=0,<1.0.0", id="caret-zero-major-only"), pytest.param("^0.0", ">=0.0,<0.1.0", id="caret-zero-major-zero-minor"), pytest.param("~1.2.3", ">=1.2.3,<1.3.0", id="tilde-full-version"), pytest.param("~1.2", ">=1.2,<1.3.0", id="tilde-major-minor"), pytest.param("~1", ">=1,<2.0.0", id="tilde-major-only"), pytest.param("1.2.*", ">=1.2.0,<1.3.0", id="wildcard-patch"), pytest.param("1.*", ">=1.0.0,<2.0.0", id="wildcard-minor"), pytest.param("*", "", id="bare-wildcard"), pytest.param(">=2,<3", ">=2,<3", id="pass-through-standard-specifier"), ], ) def test_normalize_poetry_requirement( handler: PythonEcosystemHandler, requirement: str, expected: str, ) -> None: result = handler._normalize_poetry_requirement(requirement) assert result == expected # every non-empty result must be a valid PEP 440 specifier set if result: SpecifierSet(result) # -------------------------------------------------------- # _ranges_overlap # -------------------------------------------------------- @pytest.mark.parametrize( "req_spec, vuln_spec, expected", [ pytest.param(">=2,<3", ">=2,<2.1", True, id="overlapping-ranges"), pytest.param(">=8,<9", ">=2,<3", False, id="disjoint-ranges"), pytest.param("~=3.0.0", "<3.1.5", True, id="compatible-release-with-upper-bound"), pytest.param(">=25.0.0", "<20.0.0", False, id="clearly-disjoint-bounds"), pytest.param("<=2.0.0", ">3.0.0", False, id="vuln-lower-exceeds-req-upper"), pytest.param(">=9.0.3", "<9.0.3", False, id="inclusive-lower-meets-exclusive-upper"), pytest.param(">9.0.3", "<=9.0.3", False, id="exclusive-lower-meets-inclusive-upper"), pytest.param(">9.0.3", "<9.0.3", False, id="both-exclusive-same-version"), pytest.param(">=9.0.3", "<=9.0.3", True, id="both-inclusive-same-version"), pytest.param( "<=3.0.0", ">3.0.0", False, id="exclusive-vuln-lower-meets-inclusive-req-upper" ), pytest.param("<=3.0.0", ">=3.0.0", True, id="both-inclusive-vuln-lower-meets-req-upper"), ], ) def test_pip_ranges_overlap( handler: PythonEcosystemHandler, req_spec: str, vuln_spec: str, expected: bool, ) -> None: assert handler._pip_ranges_overlap(SpecifierSet(req_spec), SpecifierSet(vuln_spec)) is expected def test_pip_ranges_overlap_returns_true_on_internal_error( handler: PythonEcosystemHandler, monkeypatch: pytest.MonkeyPatch, ) -> None: def _boom(spec: SpecifierSet) -> tuple[None, None]: raise RuntimeError("boom") monkeypatch.setattr(handler, "_extract_bounds", _boom) assert handler._pip_ranges_overlap(SpecifierSet(">=1"), SpecifierSet("<2")) is True # -------------------------------------------------------- # _extract_bounds / _normalize_specifier # -------------------------------------------------------- def test_extract_bounds_handles_exact_and_not_equal_specifiers( handler: PythonEcosystemHandler, ) -> None: lower, lower_inc, upper, upper_inc = handler._extract_bounds(SpecifierSet("==3.1.4,!=3.1.5")) assert str(lower) == "3.1.4" assert lower_inc is True assert str(upper) == "3.1.4" assert upper_inc is True @pytest.mark.parametrize( "spec_str, expected_lower, expected_lower_inc, expected_upper, expected_upper_inc", [ pytest.param(">=2.0,<3.0", "2.0", True, "3.0", False, id="inclusive-lower-exclusive-upper"), pytest.param(">1.0,<=2.0", "1.0", False, "2.0", True, id="exclusive-lower-inclusive-upper"), pytest.param(">1.0,<2.0", "1.0", False, "2.0", False, id="both-exclusive"), pytest.param(">=1.0,<=2.0", "1.0", True, "2.0", True, id="both-inclusive"), ], ) def test_extract_bounds_inclusivity( handler: PythonEcosystemHandler, spec_str: str, expected_lower: str, expected_lower_inc: bool, expected_upper: str, expected_upper_inc: bool, ) -> None: lower, lower_inc, upper, upper_inc = handler._extract_bounds(SpecifierSet(spec_str)) assert str(lower) == expected_lower assert lower_inc is expected_lower_inc assert str(upper) == expected_upper assert upper_inc is expected_upper_inc @pytest.mark.parametrize( "spec, expected", [ pytest.param("==2.31.0", ("2.31.0", None), id="exact-pin"), pytest.param("2.31.0", ("2.31.0", None), id="bare-version"), pytest.param(">=2,<3", (None, ">=2,<3"), id="range-specifier"), pytest.param(" ", (None, None), id="whitespace-only"), ], ) def test_normalize_specifier( handler: PythonEcosystemHandler, spec: str, expected: tuple[str | None, str | None], ) -> None: assert handler._normalize_specifier(spec) == expected # -------------------------------------------------------- # requirements.txt # -------------------------------------------------------- def test_parse_requirements_txt_content_handles_comments_flags_inline_and_invalid( handler: PythonEcosystemHandler, ) -> None: content = """ # comment --extra-index-url https://example.com/simple requests==2.31.0 # pinned flask>=2.2 invalid requirement ??? urllib3 """ deps = handler._parse_requirements_txt_content(content, "requirements.txt", False) assert [d.name for d in deps] == ["requests", "flask", "urllib3"] assert deps[0].version == "2.31.0" assert deps[1].requirement == ">=2.2" assert deps[2].version is None and deps[2].requirement is None # -------------------------------------------------------- # Pipfile.lock # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected_count, expected_names, expected_versions", [ pytest.param( "{" '"default": {"requests": {"version": "==2.31.0"}, "skip": "x"},' '"develop": {"pytest": {"version": "==8.3.0"}},' '"meta": []' "}", 2, {"requests", "pytest"}, None, id="sections-and-non-dict-values", ), pytest.param( '{"default": [], "develop": {"pytest": {"version": "==8.3.0"}}}', 1, {"pytest"}, None, id="skips-non-dict-default-section", ), pytest.param( "not valid json {{{", 0, set(), None, id="invalid-json", ), pytest.param( '["a", "b", "c"]', 0, set(), None, id="non-dict-top-level", ), pytest.param( '{"default": {"requests": {"version": ""}}}', 1, {"requests"}, [None], id="empty-version-as-none", ), ], ) def test_parse_pipfile_lock_content( handler: PythonEcosystemHandler, content: str, expected_count: int, expected_names: set[str], expected_versions: list[str | None] | None, ) -> None: deps = handler._parse_pipfile_lock_content(content, "Pipfile.lock", True) assert len(deps) == expected_count assert {d.name for d in deps} == expected_names if expected_versions is not None: assert [d.version for d in deps] == expected_versions if expected_count > 0: assert all(d.is_lockfile for d in deps) # -------------------------------------------------------- # pyproject.toml # -------------------------------------------------------- def test_parse_pyproject_toml_content_parses_poetry_dependencies_and_skips_python( handler: PythonEcosystemHandler, ) -> None: content = """ [tool.poetry.dependencies] python = ">=3.13" requests = "^2.31.0" pydantic = { version = "==2.7.0" } weird = "@git+https://example.com/repo.git" """ deps = handler._parse_pyproject_toml_content(content, "pyproject.toml", False) names = {d.name for d in deps} assert "python" not in names assert names == {"requests", "pydantic", "weird"} requests_dep = next(d for d in deps if d.name == "requests") pydantic_dep = next(d for d in deps if d.name == "pydantic") weird_dep = next(d for d in deps if d.name == "weird") assert requests_dep.requirement is not None assert pydantic_dep.version == "2.7.0" assert weird_dep.version is None and weird_dep.requirement == "x.." def test_parse_pyproject_toml_content_handles_invalid_toml( handler: PythonEcosystemHandler, ) -> None: deps = handler._parse_pyproject_toml_content( "[tool.poetry.dependencies\ninvalid toml", "pyproject.toml", False, ) assert deps == [] # -------------------------------------------------------- # poetry.lock / uv.lock # -------------------------------------------------------- def test_parse_poetry_like_lock_content_extracts_pairs( handler: PythonEcosystemHandler, ) -> None: content = """ [[package]] name = "requests" version = "2.31.0" [[package]] name = "flask" version = "3.0.2" """ deps = handler._parse_poetry_like_lock_content(content, "poetry.lock", True) assert len(deps) == 2 assert deps[0].name == "requests" assert deps[1].name == "flask" assert all(d.is_lockfile for d in deps) # -------------------------------------------------------- # setup.py # -------------------------------------------------------- def test_parse_setup_py_content_parses_install_tests_and_extras( handler: PythonEcosystemHandler, ) -> None: content = """ from setuptools import setup setup( name="demo", install_requires=["requests==2.31.0", "flask>=2.2"], tests_require=("pytest>=8",), extras_require={"dev": ["black==24.0"], "docs": "mkdocs>=1.5"}, ) """ deps = handler._parse_setup_py_content(content, "setup.py", False) assert {d.name for d in deps} == {"requests", "flask", "pytest", "black", "mkdocs"} def test_parse_setup_py_content_returns_empty_on_syntax_error( handler: PythonEcosystemHandler, ) -> None: deps = handler._parse_setup_py_content("def broken(:\n pass", "setup.py", False) assert deps == [] # -------------------------------------------------------- # AST helpers # -------------------------------------------------------- def test_ast_helpers(handler: PythonEcosystemHandler) -> None: # _is_name_or_attr assert handler._is_name_or_attr(ast.Name(id="setup"), {"setup"}) is True assert ( handler._is_name_or_attr( ast.Attribute(value=ast.Name(id="setuptools"), attr="setup"), {"setup"}, ) is True ) assert handler._is_name_or_attr(ast.Constant(value=1), {"setup"}) is False # _extract_string_literal assert handler._extract_string_literal(ast.Constant(value="x")) == "x" assert handler._extract_string_literal(ast.Constant(value=1)) is None # _extract_extras_require assert handler._extract_extras_require(ast.Constant(value="x")) == [] # _extract_string_list assert handler._extract_string_list(ast.Constant(value="pkg>=1")) == ["pkg>=1"] assert handler._extract_string_list(ast.Constant(value=1)) == [] # -------------------------------------------------------- # _parse_requirement_string # -------------------------------------------------------- @pytest.mark.parametrize( "req_string, expected_is_none", [ pytest.param(" ", True, id="empty-string"), pytest.param("not a valid requirement !!!", True, id="invalid-requirement"), ], ) def test_parse_requirement_string_returns_none( handler: PythonEcosystemHandler, req_string: str, expected_is_none: bool, ) -> None: dep = handler._parse_requirement_string(req_string, "setup.py", False) assert (dep is None) is expected_is_none