from __future__ import annotations from pathlib import Path from unittest.mock import patch import defusedxml.ElementTree as ET import pytest import semantic_version from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Dependency, Ecosystem, SecurityVulnerability from vuln_scan.ecosystems.java import JavaEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> JavaEcosystemHandler: return JavaEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: JavaEcosystemHandler) -> None: assert handler.manifest_names == {"pom.xml"} assert handler.lockfile_names == set() assert handler.manifest_globs == set() @pytest.mark.parametrize( "manifest_path, expected", [ pytest.param("pom.xml", True, id="pom-xml"), pytest.param("submodule/pom.xml", True, id="nested-pom-xml"), pytest.param("a/b/c/pom.xml", True, id="deeply-nested-pom-xml"), pytest.param("build.gradle", False, id="gradle-file"), pytest.param("package.json", False, id="npm-file"), pytest.param("anything", False, id="random-file"), ], ) def test_supports_manifest( handler: JavaEcosystemHandler, manifest_path: str, expected: bool, ) -> None: """supports_manifest() accepts full paths and extracts the basename.""" assert handler.supports_manifest(manifest_path) is expected @pytest.mark.parametrize( "filename, expected_is_manifest, expected_is_lockfile", [ pytest.param("pom.xml", True, False, id="pom-xml"), pytest.param("build.gradle", False, False, id="gradle-file"), pytest.param("package.json", False, False, id="npm-file"), pytest.param("anything", False, False, id="random-file"), ], ) def test_is_manifest_and_is_lockfile( handler: JavaEcosystemHandler, filename: str, expected_is_manifest: bool, expected_is_lockfile: bool, ) -> None: """is_manifest() and is_lockfile() expect basenames only.""" assert handler.is_manifest(filename) is expected_is_manifest assert handler.is_lockfile(filename) is expected_is_lockfile # -------------------------------------------------------- # _parse_manifest_bytes # -------------------------------------------------------- @pytest.mark.parametrize( "filename, content, expected_count", [ pytest.param( "pom.xml", b""" com.example lib 1.0.0 """, 1, id="pom-xml-bytes", ), pytest.param( "unknown.xyz", b"", 0, id="unknown-file-returns-empty", ), pytest.param( "pom.xml", b"not valid xml <<<", 0, id="invalid-xml-bytes", ), ], ) def test_parse_manifest_bytes( handler: JavaEcosystemHandler, filename: str, content: bytes, expected_count: int, ) -> None: parsed = handler._parse_manifest_bytes(filename, content) assert parsed.manifest_type == "manifest" assert parsed.parser_name == "maven" assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # _parse_pom_xml_bytes # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected_count, expected_name", [ pytest.param( b""" com.google.guava guava 31.1 """, 1, "com.google.guava:guava", id="valid-pom-bytes", ), pytest.param( b"not xml <<<", 0, None, id="invalid-xml-bytes", ), pytest.param( b"", 0, None, id="empty-bytes", ), pytest.param( b"", 0, None, id="empty-project-bytes", ), ], ) def test_parse_pom_xml_bytes( handler: JavaEcosystemHandler, content: bytes, expected_count: int, expected_name: str | None, ) -> None: deps = handler._parse_pom_xml_bytes(content, "pom.xml") assert len(deps) == expected_count if expected_name is not None: assert deps[0].name == expected_name def test_parse_pom_xml_bytes_with_namespace(handler: JavaEcosystemHandler) -> None: content = b""" org.apache.commons commons-lang3 3.12.0 """ deps = handler._parse_pom_xml_bytes(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.apache.commons:commons-lang3" # -------------------------------------------------------- # parse_manifest — XML encoding handling # -------------------------------------------------------- def test_parse_manifest_respects_xml_prolog_encoding( handler: JavaEcosystemHandler, tmp_path: Path, ) -> None: """POM with ISO-8859-1 encoding should be parsed correctly.""" pom_content = ( '\n' "\n" " \n" " \n" " com.example\n" " lib\n" " 1.0.0\n" " \n" " \n" "" ) pom = tmp_path / "pom.xml" pom.write_bytes(pom_content.encode("iso-8859-1")) parsed = handler.parse_manifest("pom.xml", pom) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "com.example:lib" assert parsed.dependencies[0].version == "1.0.0" def test_parse_manifest_handles_utf16_encoding( handler: JavaEcosystemHandler, tmp_path: Path, ) -> None: """POM with UTF-16 encoding should be parsed correctly.""" pom_content = ( '\n' "\n" " \n" " \n" " com.example\n" " lib\n" " 2.0.0\n" " \n" " \n" "" ) pom = tmp_path / "pom.xml" pom.write_bytes(pom_content.encode("utf-16")) parsed = handler.parse_manifest("pom.xml", pom) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].version == "2.0.0" def test_parse_manifest_invalid_bytes_returns_empty( handler: JavaEcosystemHandler, tmp_path: Path, ) -> None: """Corrupted bytes should not crash, should return empty.""" pom = tmp_path / "pom.xml" pom.write_bytes(b"\xff\xfe not valid xml <<<") parsed = handler.parse_manifest("pom.xml", pom) assert len(parsed.dependencies) == 0 def test_parse_manifest_non_pom_file_returns_empty( handler: JavaEcosystemHandler, tmp_path: Path, ) -> None: """Non-pom.xml file via parse_manifest returns empty deps.""" other = tmp_path / "build.gradle" other.write_bytes(b"apply plugin: 'java'") parsed = handler.parse_manifest("build.gradle", other) assert len(parsed.dependencies) == 0 assert parsed.manifest_type == "manifest" # -------------------------------------------------------- # parse_manifest / parse_manifest_content dispatch # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: JavaEcosystemHandler, tmp_path: Path, ) -> None: pom = tmp_path / "pom.xml" pom.write_text( """ com.google.guava guava 31.1 """, encoding="utf-8", ) parsed = handler.parse_manifest("pom.xml", pom) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "com.google.guava:guava" assert parsed.dependencies[0].version == "31.1" assert parsed.manifest_type == "manifest" assert parsed.parser_name == "maven" @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_name", [ pytest.param( "unknown.xyz", "", "manifest", 0, None, id="unknown-file-returns-empty", ), pytest.param( "pom.xml", """ com.google.guava guava 31.1 """, "manifest", 1, "com.google.guava:guava", id="pom-xml-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: JavaEcosystemHandler, 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( "content, expected_count", [ pytest.param( "not valid xml <<<", 0, id="invalid-xml", ), pytest.param( "", 0, id="empty-project", ), pytest.param( "", 0, id="empty-dependencies", ), pytest.param( """ no-group 1.0.0 """, 0, id="missing-groupId", ), pytest.param( """ com.example 1.0.0 """, 0, id="missing-artifactId", ), pytest.param( """ com.example lib """, 1, id="missing-version-still-included", ), pytest.param( """ lib 1.0.0 """, 0, id="empty-groupId", ), pytest.param( """ com.example 1.0.0 """, 0, id="empty-artifactId", ), ], ) def test_parse_manifest_content_negative_cases( handler: JavaEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("pom.xml", content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # pom.xml — basic parsing # -------------------------------------------------------- def test_parse_pom_basic_dependencies(handler: JavaEcosystemHandler) -> None: content = """ org.springframework spring-core 5.3.20 com.fasterxml.jackson.core jackson-databind 2.13.4 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 2 assert {d.name for d in deps} == { "org.springframework:spring-core", "com.fasterxml.jackson.core:jackson-databind", } assert all(d.ecosystem == Ecosystem.MAVEN for d in deps) assert all(not d.is_lockfile for d in deps) def test_parse_pom_with_namespace(handler: JavaEcosystemHandler) -> None: content = """ org.apache.commons commons-lang3 3.12.0 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.apache.commons:commons-lang3" assert deps[0].version == "3.12.0" def test_parse_pom_with_full_namespace_and_schema_location( handler: JavaEcosystemHandler, ) -> None: """Real-world POM with full namespace + xsi attributes.""" content = """ 4.0.0 junit junit 4.13.2 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "junit:junit" assert deps[0].version == "4.13.2" # -------------------------------------------------------- # pom.xml — dependencyManagement # -------------------------------------------------------- def test_parse_pom_dependency_management(handler: JavaEcosystemHandler) -> None: content = """ org.slf4j slf4j-api 2.0.7 com.google.guava guava 31.1 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 2 assert {d.name for d in deps} == { "org.slf4j:slf4j-api", "com.google.guava:guava", } def test_parse_pom_dependency_management_with_namespace( handler: JavaEcosystemHandler, ) -> None: content = """ org.slf4j slf4j-api 2.0.7 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.slf4j:slf4j-api" # -------------------------------------------------------- # pom.xml — plugin dependencies # -------------------------------------------------------- def test_parse_pom_plugin_dependencies(handler: JavaEcosystemHandler) -> None: content = """ org.apache.maven.plugins maven-compiler-plugin 3.11.0 org.projectlombok lombok 1.18.28 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.projectlombok:lombok" assert deps[0].version == "1.18.28" def test_parse_pom_plugin_dependencies_with_namespace( handler: JavaEcosystemHandler, ) -> None: content = """ org.apache.maven.plugins maven-compiler-plugin org.projectlombok lombok 1.18.28 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.projectlombok:lombok" def test_parse_pom_no_build_section(handler: JavaEcosystemHandler) -> None: content = """ com.example lib 1.0.0 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 def test_parse_pom_build_without_plugins(handler: JavaEcosystemHandler) -> None: content = """ src """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 0 def test_parse_pom_plugin_without_dependencies(handler: JavaEcosystemHandler) -> None: content = """ org.apache.maven.plugins maven-compiler-plugin """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 0 # -------------------------------------------------------- # pom.xml — property resolution # -------------------------------------------------------- def test_parse_pom_resolves_properties(handler: JavaEcosystemHandler) -> None: content = """ 5.3.20 org.springframework spring-core ${spring.version} """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].version == "5.3.20" def test_parse_pom_resolves_properties_with_namespace( handler: JavaEcosystemHandler, ) -> None: content = """ 2.13.4 com.fasterxml.jackson.core jackson-databind ${jackson.version} """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].version == "2.13.4" def test_parse_pom_skips_unresolved_version_placeholder( handler: JavaEcosystemHandler, ) -> None: content = """ org.springframework spring-core ${unknown.property} """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].version is None assert deps[0].requirement is None def test_parse_pom_skips_unresolved_groupId_placeholder( handler: JavaEcosystemHandler, ) -> None: content = """ ${parent.groupId} some-artifact 1.0.0 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 0 def test_parse_pom_skips_unresolved_artifactId_placeholder( handler: JavaEcosystemHandler, ) -> None: content = """ com.example ${project.artifactId} 1.0.0 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 0 # -------------------------------------------------------- # pom.xml — deduplication # -------------------------------------------------------- def test_parse_pom_deduplicates(handler: JavaEcosystemHandler) -> None: content = """ com.google.guava guava 31.1 com.google.guava guava 31.1 """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 def test_parse_pom_no_version(handler: JavaEcosystemHandler) -> None: """Dependency without a version (managed by parent POM).""" content = """ org.springframework spring-core """ deps = handler._parse_pom_xml_content(content, "pom.xml") assert len(deps) == 1 assert deps[0].name == "org.springframework:spring-core" assert deps[0].version is None assert deps[0].requirement is None # -------------------------------------------------------- # _safe_parse_xml # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected_tag", [ pytest.param("", "root", id="valid-xml"), pytest.param("not xml <<<", None, id="invalid-xml"), pytest.param("", None, id="empty-string"), ], ) def test_safe_parse_xml( content: str, expected_tag: str | None, ) -> None: result = JavaEcosystemHandler._safe_parse_xml(content) if expected_tag is None: assert result is None else: assert result is not None assert result.tag == expected_tag # -------------------------------------------------------- # _safe_parse_xml — XXE / entity expansion protection # -------------------------------------------------------- def test_safe_parse_xml_rejects_entity_expansion() -> None: """Verify that defusedxml blocks XML entity expansion (Billion Laughs).""" malicious_xml = """ ]> &c;""" result = JavaEcosystemHandler._safe_parse_xml(malicious_xml) assert result is None def test_safe_parse_xml_rejects_external_entity() -> None: """Verify that defusedxml blocks external entity references.""" malicious_xml = """ ]> &xxe;""" result = JavaEcosystemHandler._safe_parse_xml(malicious_xml) assert result is None # -------------------------------------------------------- # _safe_parse_xml — bytes input # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected_tag", [ pytest.param(b"", "root", id="valid-bytes"), pytest.param(b"not xml <<<", None, id="invalid-bytes"), pytest.param(b"", None, id="empty-bytes"), pytest.param( ''.encode("iso-8859-1"), "root", id="iso-8859-1-bytes", ), ], ) def test_safe_parse_xml_bytes( content: bytes, expected_tag: str | None, ) -> None: result = JavaEcosystemHandler._safe_parse_xml(content) if expected_tag is None: assert result is None else: assert result is not None assert result.tag == expected_tag # -------------------------------------------------------- # _get_text # -------------------------------------------------------- @pytest.mark.parametrize( "xml_str, ns, child_tag, expected", [ pytest.param( 'com.example', {"m": "http://maven.apache.org/POM/4.0.0"}, "groupId", "com.example", id="with-namespace", ), pytest.param( "com.example", {}, "groupId", "com.example", id="without-namespace", ), pytest.param( "", {}, "groupId", None, id="missing-child", ), pytest.param( "", {}, "groupId", None, id="empty-text", ), pytest.param( " ", {}, "groupId", None, id="whitespace-only", ), ], ) def test_get_text( xml_str: str, ns: dict[str, str], child_tag: str, expected: str | None, ) -> None: el = ET.fromstring(xml_str) assert JavaEcosystemHandler._get_text(el, child_tag, ns) == expected # -------------------------------------------------------- # _extract_properties # -------------------------------------------------------- @pytest.mark.parametrize( "xml_str, ns, expected", [ pytest.param( """ 5.3.20 17 """, {}, {"spring.version": "5.3.20", "java.version": "17"}, id="no-namespace", ), pytest.param( """ 5.3.20 """, {"m": "http://maven.apache.org/POM/4.0.0"}, {"spring.version": "5.3.20"}, id="with-namespace", ), pytest.param( "", {}, {}, id="no-properties-section", ), pytest.param( """ """, {}, {}, id="child-no-text", ), ], ) def test_extract_properties( handler: JavaEcosystemHandler, xml_str: str, ns: dict[str, str], expected: dict[str, str], ) -> None: root = JavaEcosystemHandler._safe_parse_xml(xml_str) assert handler._extract_properties(root, ns) == expected # -------------------------------------------------------- # _resolve_property # -------------------------------------------------------- @pytest.mark.parametrize( "value, properties, expected", [ pytest.param( "${spring.version}", {"spring.version": "5.3.20", "java.version": "17"}, "5.3.20", id="single-placeholder-resolved", ), pytest.param( "no-placeholder", {"spring.version": "5.3.20"}, "no-placeholder", id="no-placeholder-unchanged", ), pytest.param( "${unknown}", {"spring.version": "5.3.20"}, "${unknown}", id="unresolved-placeholder-kept", ), pytest.param( "${major}.${minor}.0", {"major": "5", "minor": "3"}, "5.3.0", id="multiple-placeholders", ), pytest.param( "${any}", {}, "${any}", id="empty-properties", ), ], ) def test_resolve_property( handler: JavaEcosystemHandler, value: str, properties: dict[str, str], expected: str, ) -> None: assert handler._resolve_property(value, properties) == expected # -------------------------------------------------------- # _has_placeholder # -------------------------------------------------------- @pytest.mark.parametrize( "value, expected", [ pytest.param("${foo}", True, id="simple-placeholder"), pytest.param("1.0.0", False, id="no-placeholder"), pytest.param("pre-${x}-post", True, id="embedded-placeholder"), pytest.param("", False, id="empty-string"), ], ) def test_has_placeholder( value: str, expected: bool, ) -> None: assert JavaEcosystemHandler._has_placeholder(value) is expected # -------------------------------------------------------- # _normalize_maven_specifier # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected_version, expected_requirement", [ pytest.param("1.0.0", "1.0.0", None, id="exact-version"), pytest.param("2.13.4.2", "2.13.4.2", None, id="four-part-version"), pytest.param("5.3.20", "5.3.20", None, id="three-part-version"), pytest.param("1.0-SNAPSHOT", "1.0-SNAPSHOT", None, id="snapshot-version"), pytest.param("3.0.0-M1", "3.0.0-M1", None, id="milestone-version"), pytest.param("1.0.0-alpha2", "1.0.0-alpha2", None, id="alpha-version"), pytest.param("[1.0,2.0)", None, ">=1.0 <2.0", id="maven-range-inclusive-exclusive"), pytest.param("(1.0,2.0]", None, ">1.0 <=2.0", id="maven-range-exclusive-inclusive"), pytest.param("[1.0,2.0]", None, ">=1.0 <=2.0", id="maven-range-inclusive-inclusive"), pytest.param("(1.0,2.0)", None, ">1.0 <2.0", id="maven-range-exclusive-exclusive"), pytest.param("[1.0]", None, "==1.0", id="maven-exact-range"), pytest.param("(,2.0)", None, "<2.0", id="maven-range-upper-only"), pytest.param("(,2.0]", None, "<=2.0", id="maven-range-upper-inclusive"), pytest.param("[1.0,)", None, ">=1.0", id="maven-range-lower-only"), pytest.param("(1.0,)", None, ">1.0", id="maven-range-lower-exclusive"), pytest.param("", None, None, id="empty-string"), pytest.param(None, None, None, id="none-input"), pytest.param(" ", None, None, id="whitespace-only"), pytest.param( "latest", None, "latest", id="non-version-non-range-returns-raw-spec", ), pytest.param( "RELEASE", None, "RELEASE", id="release-keyword-returns-raw-spec", ), ], ) def test_normalize_maven_specifier( spec: str | None, expected_version: str | None, expected_requirement: str | None, ) -> None: version, requirement = JavaEcosystemHandler._normalize_maven_specifier(spec) assert version == expected_version assert requirement == expected_requirement # -------------------------------------------------------- # _maven_range_to_npm # -------------------------------------------------------- @pytest.mark.parametrize( "maven_range, expected", [ pytest.param("[1.0,2.0)", ">=1.0 <2.0", id="inclusive-exclusive"), pytest.param("(1.0,2.0]", ">1.0 <=2.0", id="exclusive-inclusive"), pytest.param("[1.0,2.0]", ">=1.0 <=2.0", id="inclusive-inclusive"), pytest.param("(1.0,2.0)", ">1.0 <2.0", id="exclusive-exclusive"), pytest.param("[1.0]", "==1.0", id="exact"), pytest.param("(,2.0)", "<2.0", id="upper-bound-only"), pytest.param("(,2.0]", "<=2.0", id="upper-bound-inclusive"), pytest.param("[1.0,)", ">=1.0", id="lower-bound-only"), pytest.param("(1.0,)", ">1.0", id="lower-bound-exclusive"), pytest.param("", None, id="empty-string"), pytest.param("x", None, id="too-short"), pytest.param("[1.0,2.0),[3.0,4.0)", ">=1.0 <2.0 || >=3.0 <4.0", id="multi-range-comma"), pytest.param( "[1.0,2.0],[3.0,4.0]", ">=1.0 <=2.0 || >=3.0 <=4.0", id="multi-range-both-inclusive" ), ], ) def test_maven_range_to_npm( maven_range: str, expected: str | None, ) -> None: assert JavaEcosystemHandler._maven_range_to_npm(maven_range) == expected # -------------------------------------------------------- # _maven_single_range_to_npm — edge cases # -------------------------------------------------------- @pytest.mark.parametrize( "maven_range, expected", [ pytest.param("", None, id="empty"), pytest.param("x", None, id="single-char"), pytest.param("ab", None, id="no-brackets"), pytest.param("[,]", None, id="both-empty-lower-upper"), pytest.param("[]", None, id="empty-no-comma-empty-version"), ], ) def test_maven_single_range_to_npm_edge_cases( maven_range: str, expected: str | None, ) -> None: assert JavaEcosystemHandler._maven_single_range_to_npm(maven_range) == expected # -------------------------------------------------------- # _coerce_version_token # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ # Basic numeric coercion — weighted encoding pytest.param("2.12.7.1", "2.12.70001", id="four-part-numeric"), pytest.param("2.12.6.0", "2.12.60000", id="four-part-trailing-zero"), pytest.param("2.12.6.10", "2.12.60010", id="four-part-double-digit"), pytest.param("1.2.3", "1.2.3", id="three-part-unchanged"), pytest.param("1.2", "1.2.0", id="two-part-padded"), pytest.param("20190722", "20190722.0.0", id="single-part-padded"), pytest.param("1.0.0.0.1", "1.0.0", id="five-part-only-first-four-used"), # Qualifier stripping pytest.param("5.2.20.RELEASE", "5.2.20", id="dot-release-stripped"), pytest.param("5.2.23.RELEASE", "5.2.23", id="dot-release-stripped-2"), pytest.param("1.0.0.FINAL", "1.0.0", id="dot-final-stripped"), pytest.param("2.0.0.GA", "2.0.0", id="dot-ga-stripped"), pytest.param("1.0.0-RELEASE", "1.0.0", id="hyphen-release-stripped"), pytest.param("1.0.0-FINAL", "1.0.0", id="hyphen-final-stripped"), pytest.param("1.0.0-GA", "1.0.0", id="hyphen-ga-stripped"), # Embedded qualifiers — stop at non-numeric pytest.param("9.4.1212.jre6.0.0", "9.4.1212", id="embedded-jre-qualifier"), # Pre-release preserved pytest.param("2.4.0-rc1", "2.4.0-rc1", id="hyphen-rc-preserved"), pytest.param("3.0.0-M1", "3.0.0-M1", id="hyphen-milestone-preserved"), pytest.param("1.0.0-alpha2", "1.0.0-alpha2", id="hyphen-alpha-preserved"), pytest.param("1.0.0-SNAPSHOT", "1.0.0-SNAPSHOT", id="hyphen-snapshot-preserved"), pytest.param("1.0.0-beta3", "1.0.0-beta3", id="hyphen-beta-preserved"), pytest.param("1.0.0-CR1", "1.0.0-CR1", id="hyphen-cr-preserved"), pytest.param("2.12.7.1-beta", "2.12.70001-beta", id="four-part-with-pre-release"), # Dot-separated pre-release pytest.param("1.0.0.RC1", "1.0.0-RC1", id="dot-rc-converted-to-hyphen"), pytest.param("2.0.0.alpha1", "2.0.0-alpha1", id="dot-alpha-converted-to-hyphen"), pytest.param("3.1.0.beta2", "3.1.0-beta2", id="dot-beta-converted-to-hyphen"), pytest.param("4.0.0.M2", "4.0.0-M2", id="dot-milestone-converted-to-hyphen"), pytest.param("1.0.0.SNAPSHOT", "1.0.0-SNAPSHOT", id="dot-snapshot-converted-to-hyphen"), pytest.param("5.0.0.CR1", "5.0.0-CR1", id="dot-cr-converted-to-hyphen"), # No numeric parts at all pytest.param("notaversion", "0.0.0", id="all-alpha-no-numeric"), pytest.param("abc.def", "0.0.0", id="all-alpha-dotted"), # Leading zeros stripped (NpmSpec rejects semver components with leading zeros) pytest.param("2.7.00", "2.7.0", id="trailing-part-leading-zero-stripped"), pytest.param("1.00.3", "1.0.3", id="middle-part-leading-zero-stripped"), pytest.param("01.2.3", "1.2.3", id="major-leading-zero-stripped"), ], ) def test_coerce_version_token( version: str, expected: str, ) -> None: assert JavaEcosystemHandler._coerce_version_token(version) == expected # -------------------------------------------------------- # _coerce_version_token — ordering preservation # -------------------------------------------------------- @pytest.mark.parametrize( "lower, higher", [ pytest.param("2.12.6.10", "2.12.7.1", id="third-segment-determines-order"), pytest.param("1.0.9.99", "1.0.10.1", id="numeric-segment-not-lexicographic"), pytest.param("3.4.5.100", "3.4.6.1", id="large-fourth-segment-vs-next-third"), pytest.param("2.12.6.0", "2.12.7.1", id="zero-extra-vs-nonzero-higher"), pytest.param("2.12.6.1", "2.12.6.2", id="same-first-three-fourth-differs"), ], ) def test_coerce_version_token_preserves_ordering( lower: str, higher: str, ) -> None: """ Verify that the pre-release encoding strategy preserves Maven's segment-by-segment ordering. Maven compares 2.12.6.10 < 2.12.7.1 (because 6 < 7 at segment 3). After coercion: 2.12.6-10 < 2.12.7-1 in semver (patch 6 < 7). ✓ """ coerced_lower = JavaEcosystemHandler._coerce_version_token(lower) coerced_higher = JavaEcosystemHandler._coerce_version_token(higher) v_lower = semantic_version.Version.coerce(coerced_lower) v_higher = semantic_version.Version.coerce(coerced_higher) assert ( v_lower < v_higher ), f"Ordering broken: {lower}→{coerced_lower} should be < {higher}→{coerced_higher}" # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("5.3.20", "5.3.20", id="no-change"), pytest.param("3.5", "3.5.0", id="two-part-padded"), pytest.param("v1.0.0", "1.0.0", id="strip-v-prefix"), pytest.param("V2.0.0", "2.0.0", id="strip-uppercase-v-prefix"), pytest.param("1.0.0-RELEASE", "1.0.0", id="strip-hyphen-release"), pytest.param("2.0.0-FINAL", "2.0.0", id="strip-hyphen-final"), pytest.param("3.0.0-GA", "3.0.0", id="strip-hyphen-ga"), pytest.param("1.0.0.RELEASE", "1.0.0", id="strip-dot-release"), pytest.param(" 2.0.0 ", "2.0.0", id="whitespace-stripped"), pytest.param("5.2.8.RELEASE", "5.2.8", id="dot-release-real-world"), pytest.param("2.12.7.1", "2.12.70001", id="four-part-coerced"), ], ) def test_normalize_version( handler: JavaEcosystemHandler, version: str, expected: str, ) -> None: assert handler._normalize_version(version) == expected @pytest.mark.parametrize( "version", [ pytest.param("not-a-version", id="alpha-only"), pytest.param("vvv", id="only-v-prefix-stripped-to-empty"), pytest.param("", id="empty-string"), pytest.param(" ", id="whitespace-only"), ], ) def test_normalize_version_raises_on_invalid( handler: JavaEcosystemHandler, version: str, ) -> None: with pytest.raises(ValueError): handler._normalize_version(version) # -------------------------------------------------------- # _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("^3.0", "^3.0.0", id="caret-coerced"), pytest.param("< 2.12.7.1", "<2.12.70001", id="four-part-in-range-coerced"), pytest.param("<= 2.12.6.0", "<=2.12.60000", id="four-part-upper-coerced"), pytest.param( "<5.2.20.RELEASE", "<5.2.20", id="dot-release-in-range-stripped", ), pytest.param( ">=9.4.1212.jre6.0.0 <42.2.13", ">=9.4.1212 <42.2.13", id="embedded-qualifier-in-range-stripped", ), # Leading zeros in advisory range must be stripped so NpmSpec can parse them pytest.param( ">= 2.7.00, < 2.9.10.8", ">=2.7.0 <2.9.100008", id="leading-zero-in-lower-bound-stripped", ), ], ) def test_normalize_vuln_range( handler: JavaEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- @pytest.mark.parametrize( "range_str", [ pytest.param(">=2.0.0 <3.0.0", id="simple-range"), pytest.param("<2.12.7.1", id="four-part-version"), pytest.param("<5.2.20.RELEASE", id="dot-release-suffix"), ], ) def test_build_spec_returns_npm_spec( handler: JavaEcosystemHandler, range_str: str, ) -> None: result = handler._build_spec(range_str) assert isinstance(result, semantic_version.NpmSpec) # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep, alert, expected_vuln, expected_confidence", [ pytest.param( make_dependency( name="com.example:lib", version="3.0.0", ecosystem=Ecosystem.MAVEN, ), make_alert(ecosystem=Ecosystem.MAVEN, vulnerable_range=None), True, "low", id="no-vuln-range-returns-true-low", ), pytest.param( make_dependency( name="com.example:lib", version="3.0.0", ecosystem=Ecosystem.MAVEN, ), make_alert(ecosystem=Ecosystem.MAVEN, vulnerable_range=""), True, "low", id="empty-vuln-range-returns-true-low", ), pytest.param( make_dependency( name="com.example:lib", version="3.0.0", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <3.5.0", ), True, "medium", id="version-vulnerable-medium-confidence", ), pytest.param( make_dependency( name="com.example:lib", version="3.5.0", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <3.5.0", ), False, "medium", id="version-not-vulnerable-medium-confidence", ), pytest.param( make_dependency( name="com.example:lib", version=None, requirement=">=5.0.0", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <2.5.0", ), False, "low", id="requirement-no-overlap", ), pytest.param( make_dependency( name="com.example:lib", version=None, requirement=">=2.0.0", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="requirement-overlap-true", ), pytest.param( make_dependency( name="com.example:lib", version=None, requirement=None, ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="no-version-no-requirement-conservative", ), pytest.param( make_dependency( name="com.example:lib", version="not-a-version", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="invalid-version-returns-true-low", ), pytest.param( make_dependency( name="com.example:lib", version="1.0.0-RELEASE", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=">=0.9.0, <1.5.0", ), True, "medium", id="release-suffix-stripped-and-matched", ), pytest.param( make_dependency( name="com.example:lib", version="5.2.8.RELEASE", ecosystem=Ecosystem.MAVEN, ), make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range="<5.2.20.RELEASE", ), True, "medium", id="dot-release-version-and-range", ), ], ) def test_is_vulnerable( handler: JavaEcosystemHandler, 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: JavaEcosystemHandler) -> None: """Covers: `if not normalized: return True, 'low'`.""" dep = make_dependency( name="com.example:lib", version="3.0.0", ecosystem=Ecosystem.MAVEN, ) alert = make_alert(ecosystem=Ecosystem.MAVEN, 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: JavaEcosystemHandler, ) -> None: """Covers: outer `except Exception: return True, 'low'` catch-all.""" dep = make_dependency( name="com.example:lib", version="3.0.0", ecosystem=Ecosystem.MAVEN, ) alert = make_alert(ecosystem=Ecosystem.MAVEN, 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" # -------------------------------------------------------- # is_vulnerable — four-part + qualifier regressions # -------------------------------------------------------- @pytest.mark.parametrize( "dep_version, vuln_range, expected_vuln", [ # Four-part numeric ranges pytest.param("2.11.4", "<=2.12.6.0", True, id="below-four-part-upper"), pytest.param("2.13.0", "<=2.12.6.0", False, id="above-four-part-upper"), pytest.param("2.11.4", "<2.12.7.1", True, id="below-four-part-exclusive"), pytest.param("2.11.4", ">=2.4.0-rc1 <2.12.7.1", True, id="in-range-rc-lower"), pytest.param("2.13.0", ">=2.4.0-rc1 <2.12.7.1", False, id="above-range-rc-lower"), # .RELEASE suffix in vuln ranges pytest.param("5.2.8", "<5.2.20.RELEASE", True, id="below-dot-release-upper"), pytest.param("5.3.0", "<5.2.20.RELEASE", False, id="above-dot-release-upper"), pytest.param("5.2.8", "<5.2.23.RELEASE", True, id="below-dot-release-upper-2"), pytest.param("5.2.8", "<5.2.24.RELEASE", True, id="below-dot-release-upper-3"), # Embedded qualifier in vuln ranges pytest.param( "9.4.1212", ">=9.4.1212.jre6.0.0 <42.2.13", True, id="at-embedded-qualifier-lower", ), pytest.param( "42.2.12", ">=9.4.1212.jre6.0.0 <42.2.13", True, id="in-range-embedded-qualifier", ), pytest.param( "42.3.0", ">=9.4.1212.jre6.0.0 <42.2.13", False, id="above-range-embedded-qualifier", ), # .RELEASE installed version against .RELEASE range pytest.param( "5.2.8.RELEASE", "<5.3.39", True, id="dot-release-installed-vs-normal-range", ), # Regression: leading zeros in advisory lower bound caused NpmSpec to throw, # which triggered the fail-open and false-positived a non-vulnerable version. # 2.13.4.2 (minor=13) is above the upper bound 2.9.10.8 (minor=9) → not vulnerable. pytest.param( "2.13.4.2", ">= 2.7.00, < 2.9.10.8", False, id="above-range-with-leading-zero-in-lower-bound-not-vulnerable", ), ], ) def test_is_vulnerable_complex_maven_versions( handler: JavaEcosystemHandler, dep_version: str, vuln_range: str, expected_vuln: bool, ) -> None: dep = make_dependency( name="org.example:lib", version=dep_version, ecosystem=Ecosystem.MAVEN, ) alert = make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=vuln_range, ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == "medium" # -------------------------------------------------------- # is_vulnerable — four-part ordering correct # -------------------------------------------------------- @pytest.mark.parametrize( "dep_version, vuln_range, expected_vuln", [ pytest.param( "2.12.6.10", "<2.12.7.1", True, id="four-part-below-upper-bound", ), pytest.param( "1.0.9.99", "<1.0.10.1", True, id="large-fourth-segment-still-below", ), pytest.param( "2.12.7.2", "<2.12.7.1", False, id="four-part-above-upper-bound", ), pytest.param( "2.12.6.1", "<=2.12.6.2", True, id="four-part-at-lower-within-inclusive-upper", ), ], ) def test_is_vulnerable_four_part_ordering_correct( handler: JavaEcosystemHandler, dep_version: str, vuln_range: str, expected_vuln: bool, ) -> None: dep = make_dependency( name="com.example:lib", version=dep_version, ecosystem=Ecosystem.MAVEN, ) alert = make_alert( ecosystem=Ecosystem.MAVEN, vulnerable_range=vuln_range, ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == "medium" # -------------------------------------------------------- # _version_in_range / _ranges_overlap — error paths # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: JavaEcosystemHandler) -> None: """Covers: _version_in_range catches exception and returns True.""" 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: JavaEcosystemHandler) -> 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