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