from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Dependency, Ecosystem, SecurityVulnerability from vuln_scan.ecosystems.php import PhpEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> PhpEcosystemHandler: return PhpEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: PhpEcosystemHandler) -> None: assert handler.manifest_names == {"composer.json"} assert handler.lockfile_names == {"composer.lock"} assert handler.manifest_globs == set() # -------------------------------------------------------- # parse_manifest / parse_manifest_content dispatch # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: PhpEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "composer.json" manifest.write_text( '{"require": {"monolog/monolog": "^3.0"}}', encoding="utf-8", ) parsed = handler.parse_manifest("composer.json", manifest) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "monolog/monolog" assert parsed.manifest_type == "manifest" assert parsed.parser_name == "composer" @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( "composer.json", '{"require": {"monolog/monolog": "^3.0"}}', "manifest", 1, "monolog/monolog", id="composer-json-dispatch", ), pytest.param( "composer.lock", '{"packages": [{"name": "monolog/monolog", "version": "3.5.0"}]}', "lockfile", 1, "monolog/monolog", id="composer-lock-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: PhpEcosystemHandler, 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", [ # ---- composer.json ---- pytest.param( "composer.json", "not valid json {{{", 0, id="composer-json-invalid-json", ), pytest.param( "composer.json", '["not", "a", "dict"]', 0, id="composer-json-non-dict-top-level", ), pytest.param( "composer.json", '{"require": "not-a-dict"}', 0, id="composer-json-non-dict-require", ), pytest.param( "composer.json", '{"require": {"bad-pkg": 42}}', 0, id="composer-json-non-string-version-spec", ), pytest.param( "composer.json", '{"require": {}}', 0, id="composer-json-empty-require", ), pytest.param( "composer.json", "{}", 0, id="composer-json-no-require-sections", ), pytest.param( "composer.json", '{"require": {"php": ">=8.1", "ext-json": "*", "lib-pcre": ">=7.0", "composer-plugin-api": "^2.0"}}', 0, id="composer-json-platform-requirements-filtered", ), # ---- composer.lock ---- pytest.param( "composer.lock", "not valid json {{{", 0, id="composer-lock-invalid-json", ), pytest.param( "composer.lock", '["not", "a", "dict"]', 0, id="composer-lock-non-dict-top-level", ), pytest.param( "composer.lock", '{"packages": "not-a-list"}', 0, id="composer-lock-non-list-packages", ), pytest.param( "composer.lock", '{"packages": ["not-a-dict"]}', 0, id="composer-lock-non-dict-package-entry", ), pytest.param( "composer.lock", '{"packages": [{"name": "", "version": "1.0.0"}]}', 0, id="composer-lock-empty-name", ), pytest.param( "composer.lock", '{"packages": [{"name": "pkg"}]}', 0, id="composer-lock-missing-version", ), pytest.param( "composer.lock", '{"packages": [{"name": "pkg", "version": ""}]}', 0, id="composer-lock-empty-version", ), pytest.param( "composer.lock", '{"packages": []}', 0, id="composer-lock-empty-packages", ), pytest.param( "composer.lock", "{}", 0, id="composer-lock-no-packages-section", ), ], ) def test_parse_manifest_content_negative_cases( handler: PhpEcosystemHandler, filename: str, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # composer.json # -------------------------------------------------------- def test_parse_composer_json_require_and_require_dev(handler: PhpEcosystemHandler) -> None: content = """{ "require": { "php": ">=8.1", "monolog/monolog": "^3.0", "ext-json": "*" }, "require-dev": { "phpunit/phpunit": "^10.0" } }""" deps = handler._parse_composer_json_content(content, "composer.json") assert len(deps) == 2 assert {d.name for d in deps} == {"monolog/monolog", "phpunit/phpunit"} assert all(d.ecosystem == Ecosystem.COMPOSER for d in deps) assert all(not d.is_lockfile for d in deps) # -------------------------------------------------------- # composer.lock # -------------------------------------------------------- def test_parse_composer_lock_packages_and_packages_dev(handler: PhpEcosystemHandler) -> None: content = """{ "packages": [ {"name": "monolog/monolog", "version": "v3.5.0"}, {"name": "symfony/console", "version": "6.4.1"} ], "packages-dev": [ {"name": "phpunit/phpunit", "version": "v10.5.0"} ] }""" deps = handler._parse_composer_lock_content(content, "composer.lock") assert len(deps) == 3 assert {d.name for d in deps} == {"monolog/monolog", "symfony/console", "phpunit/phpunit"} assert all(d.is_lockfile for d in deps) def test_parse_composer_lock_strips_version_prefix(handler: PhpEcosystemHandler) -> None: content = '{"packages": [{"name": "monolog/monolog", "version": "v3.5.0"}]}' deps = handler._parse_composer_lock_content(content, "composer.lock") assert deps[0].version == "3.5.0" def test_parse_composer_lock_deduplicates(handler: PhpEcosystemHandler) -> None: content = """{ "packages": [ {"name": "monolog/monolog", "version": "v3.5.0"} ], "packages-dev": [ {"name": "monolog/monolog", "version": "v3.5.0"} ] }""" deps = handler._parse_composer_lock_content(content, "composer.lock") assert len(deps) == 1 # -------------------------------------------------------- # _is_platform_requirement # -------------------------------------------------------- @pytest.mark.parametrize( "name, expected", [ pytest.param("php", True, id="php"), pytest.param("PHP", True, id="php-uppercase"), pytest.param("ext-json", True, id="ext-json"), pytest.param("ext-mbstring", True, id="ext-mbstring"), pytest.param("lib-pcre", True, id="lib-pcre"), pytest.param("composer-plugin-api", True, id="composer-plugin-api"), pytest.param("monolog/monolog", False, id="normal-package"), pytest.param("phpunit/phpunit", False, id="phpunit"), ], ) def test_is_platform_requirement( name: str, expected: bool, ) -> None: assert PhpEcosystemHandler._is_platform_requirement(name) is expected # -------------------------------------------------------- # _strip_version_prefix # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("v3.5.0", "3.5.0", id="lowercase-v"), pytest.param("V3.5.0", "3.5.0", id="uppercase-v"), pytest.param("3.5.0", "3.5.0", id="no-prefix"), pytest.param("", "", id="empty-string"), ], ) def test_strip_version_prefix( version: str, expected: str, ) -> None: assert PhpEcosystemHandler._strip_version_prefix(version) == expected # -------------------------------------------------------- # _normalize_composer_specifier # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected_version, expected_requirement", [ pytest.param("3.5.0", "3.5.0", None, id="exact-version"), pytest.param("v3.5.0", "3.5.0", None, id="exact-version-with-v"), pytest.param("^3.0", None, "^3.0", id="caret-range"), pytest.param("~3.0", None, "~3.0", id="tilde-range"), pytest.param(">=3.0,<4.0", None, ">=3.0,<4.0", id="compound-range"), pytest.param("*", None, "*", id="wildcard"), pytest.param("", None, None, id="empty-string"), pytest.param(" ", None, None, id="whitespace-only"), pytest.param("dev-main", None, None, id="dev-main"), pytest.param("dev-master", None, None, id="dev-master"), pytest.param("dev-feature/foo", None, None, id="dev-branch"), pytest.param("@dev", None, None, id="stability-flag-only"), pytest.param("^3.0@beta", None, "^3.0", id="range-with-stability-stripped"), ], ) def test_normalize_composer_specifier( spec: str, expected_version: str | None, expected_requirement: str | None, ) -> None: version, requirement = PhpEcosystemHandler._normalize_composer_specifier(spec) assert version == expected_version assert requirement == expected_requirement # -------------------------------------------------------- # _safe_json_load # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected", [ pytest.param('{"key": "value"}', {"key": "value"}, id="valid-dict"), pytest.param("invalid json {{{", None, id="invalid-json"), pytest.param('["a", "b"]', None, id="non-dict-json"), ], ) def test_safe_json_load( content: str, expected: dict[str, object] | None, ) -> None: assert PhpEcosystemHandler._safe_json_load(content) == expected # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("v3.5.0", "3.5.0", id="strip-lowercase-v"), pytest.param("V3.5.0", "3.5.0", id="strip-uppercase-v"), pytest.param("3.5.0", "3.5.0", id="no-prefix"), pytest.param("3.5.0@beta", "3.5.0", id="strip-stability-flag"), pytest.param("2.0@stable", "2.0.0", id="strip-stability-and-pad-two-part"), pytest.param("3.5", "3.5.0", id="two-part-padded"), pytest.param(" v1.0.0 ", "1.0.0", id="whitespace-stripped"), ], ) def test_normalize_version( handler: PhpEcosystemHandler, version: str, expected: str, ) -> None: assert handler._normalize_version(version) == expected # -------------------------------------------------------- # _normalize_vuln_range # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("", "", id="empty-string"), pytest.param(" ", "", id="whitespace-only"), pytest.param(None, "", id="none-input"), pytest.param(">=2.0.0, <3.5.0", ">=2.0.0 <3.5.0", id="comma-to-space"), pytest.param(">= 2.0.0 < 3.5.0", ">=2.0.0 <3.5.0", id="operator-space-collapsed"), pytest.param(">=2.0.0@beta", ">=2.0.0", id="stability-flag-stripped"), pytest.param("^3.0", "^3.0", id="caret-unchanged"), ], ) def test_normalize_vuln_range( handler: PhpEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- def test_build_spec_returns_npm_spec(handler: PhpEcosystemHandler) -> None: import semantic_version result = handler._build_spec(">=2.0.0 <3.0.0") assert isinstance(result, semantic_version.NpmSpec) # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep, alert, expected_vuln, expected_confidence", [ pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ), make_alert(ecosystem=Ecosystem.COMPOSER, vulnerable_range=None), True, "low", id="no-vuln-range-returns-true-low", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ), make_alert(ecosystem=Ecosystem.COMPOSER, vulnerable_range=""), True, "low", id="empty-vuln-range-returns-true-low", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "high", id="lockfile-version-vulnerable-high-confidence", ), pytest.param( make_dependency( version="3.5.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), False, "high", id="lockfile-version-not-vulnerable-high-confidence", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=False, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "medium", id="manifest-version-vulnerable-medium-confidence", ), pytest.param( make_dependency( version=None, requirement=">=5.0.0", ecosystem=Ecosystem.COMPOSER, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <2.5.0", ), False, "low", id="requirement-no-overlap", ), pytest.param( make_dependency( version=None, requirement=">=2.0.0", ecosystem=Ecosystem.COMPOSER, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="requirement-overlap-true", ), pytest.param( make_dependency( version=None, requirement=None, ecosystem=Ecosystem.COMPOSER, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="no-version-no-requirement-conservative", ), pytest.param( make_dependency( version="not-a-version", ecosystem=Ecosystem.COMPOSER, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="invalid-version-returns-true-low", ), pytest.param( make_dependency( version="v3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ), make_alert( ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0", ), True, "high", id="version-with-v-prefix-stripped", ), ], ) def test_is_vulnerable( handler: PhpEcosystemHandler, dep: Dependency, alert: SecurityVulnerability, 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 def test_is_vulnerable_normalize_returns_empty(handler: PhpEcosystemHandler) -> None: """Covers: `if not normalized: return True, 'low'`.""" dep = make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ) alert = make_alert(ecosystem=Ecosystem.COMPOSER, vulnerable_range="some-range") with patch.object(handler, "_normalize_vuln_range", return_value=""): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" def test_is_vulnerable_unexpected_exception_returns_true_low( handler: PhpEcosystemHandler, ) -> None: """Covers: outer `except Exception: return True, 'low'` catch-all.""" dep = make_dependency( version="3.0.0", ecosystem=Ecosystem.COMPOSER, is_lockfile=True, ) alert = make_alert(ecosystem=Ecosystem.COMPOSER, vulnerable_range=">=2.0.0, <3.5.0") with patch.object( handler, "_normalize_vuln_range", side_effect=RuntimeError("unexpected"), ): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" # -------------------------------------------------------- # _version_in_range / _ranges_overlap — error paths # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: PhpEcosystemHandler) -> None: """Covers: _version_in_range catches exception and returns True.""" import semantic_version version = semantic_version.Version.coerce("1.0.0") with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._version_in_range(version, ">=1.0.0") is True def test_ranges_overlap_exception_returns_true(handler: PhpEcosystemHandler) -> None: """Covers: _ranges_overlap catches exception and returns True.""" with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap(">=1.0.0", ">=2.0.0 <3.0.0") is True