""" Maven (Java) ecosystem handler. Supports: - pom.xml (manifest) Maven does not have a standard lockfile format. Dependency resolution is handled by Maven itself at build time, so we parse declared versions from pom.xml and treat them as manifests (not lockfiles). """ from __future__ import annotations import logging import re from pathlib import Path import defusedxml.ElementTree as ET import semantic_version from vuln_scan.core.models import ( Dependency, Ecosystem, EcosystemParseResult, SecurityVulnerability, ) from vuln_scan.ecosystems.base import EcosystemHandler logger = logging.getLogger(__name__) # Maven POM namespace _MAVEN_NS = "http://maven.apache.org/POM/4.0.0" _NS = {"m": _MAVEN_NS} class JavaEcosystemHandler(EcosystemHandler): """Handler for the Maven / Java ecosystem.""" id = "java" ecosystem = Ecosystem.MAVEN # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return set() @property def manifest_names(self) -> set[str]: return {"pom.xml"} @property def manifest_globs(self) -> set[str]: return set() # ------------------------------------------------------- # Parsing — dispatch # ------------------------------------------------------- def parse_manifest( self, manifest_path: str, full_path: Path, ) -> EcosystemParseResult: content = full_path.read_bytes() return self._parse_manifest_bytes(manifest_path, content) def parse_manifest_content( self, manifest_path: str, content: str, ) -> EcosystemParseResult: filename = Path(manifest_path).name deps = self._parse_pom_xml_content(content, manifest_path) if filename == "pom.xml" else [] return EcosystemParseResult( dependencies=deps, manifest_type="manifest", parser_name="maven", ) def _parse_manifest_bytes( self, manifest_path: str, content: bytes, ) -> EcosystemParseResult: """Parse manifest from raw bytes, letting the XML parser handle encoding.""" filename = Path(manifest_path).name deps = self._parse_pom_xml_bytes(content, manifest_path) if filename == "pom.xml" else [] return EcosystemParseResult( dependencies=deps, manifest_type="manifest", parser_name="maven", ) # ------------------------------------------------------- # Normalization # ------------------------------------------------------- def _build_spec(self, range_str: str) -> semantic_version.NpmSpec: return semantic_version.NpmSpec(self._normalize_vuln_range(range_str)) def _normalize_version(self, version: str) -> str: """ Normalize a Maven version string for semver comparison. Strips common suffixes like -RELEASE, -FINAL, etc. Pads two-part versions to three parts. Coerces four-part versions to three-part semver. Raises ValueError if the version contains no numeric parts at all. """ version = version.strip() # Strip common Maven qualifiers that don't affect vulnerability matching version = re.sub(r"[.-](RELEASE|FINAL|GA)$", "", version, flags=re.IGNORECASE) # Strip leading 'v' prefix (rare in Maven but defensive) version = version.lstrip("vV") # Reject versions with no leading digit — they're not real versions if not version or not version[0].isdigit(): raise ValueError(f"Not a valid version: {version}") # Coerce to valid semver (handles 4-part and 1-part versions) version = self._coerce_version_token(version) return version def _normalize_vuln_range(self, spec: str | None) -> str: """ Normalize a GitHub Advisory vulnerability range for Maven packages. GitHub Advisory DB uses npm-style ranges (e.g. ">= 2.0.0, < 3.5.0") for all ecosystems including Maven. This method normalizes them into a format compatible with semantic_version.NpmSpec. Handles Maven's four-part versions (e.g. 2.12.7.1) and qualifier suffixes (e.g. 5.2.20.RELEASE, 9.4.1212.jre6.0.0) by coercing them into semver-compatible three-part versions. """ spec = (spec or "").strip() if not spec: return "" # Replace commas with spaces (GitHub uses ", " as separator) spec = spec.replace(",", " ") # Normalize spacing spec = " ".join(spec.split()) # Collapse operator spacing: ">= 2.0.0" → ">=2.0.0" spec = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", spec) # Coerce each version token to valid semver (handles 4+ part versions, # .RELEASE suffixes, embedded qualifiers like jre6, etc.) spec = re.sub( r"(<=|>=|<|>|==|=|~|\^)?([\d]+(?:[.\-][\w]+)*)", lambda m: (m.group(1) or "") + self._coerce_version_token(m.group(2)), spec, ) return spec # ------------------------------------------------------- # Vulnerability matching # ------------------------------------------------------- def is_vulnerable( self, dependency: Dependency, alert: SecurityVulnerability, ) -> tuple[bool, str]: vuln_range = alert.vulnerable_range if not vuln_range: return True, "low" try: normalized = self._normalize_vuln_range(vuln_range) if not normalized: return True, "low" if dependency.version: try: dep_version = semantic_version.Version.coerce( self._normalize_version(dependency.version) ) except ValueError: return True, "low" is_vuln = self._version_in_range(dep_version, normalized) # Maven has no lockfile, so confidence is always "medium" return is_vuln, "medium" if dependency.requirement: return ( self._ranges_overlap( self._normalize_vuln_range(dependency.requirement), normalized, ), "low", ) return True, "low" except Exception: return True, "low" # ------------------------------------------------------- # pom.xml parsing # ------------------------------------------------------- def _parse_pom_xml_bytes( self, content: bytes, manifest_path: str, ) -> list[Dependency]: """Parse pom.xml from raw bytes, respecting XML prolog encoding.""" root = self._safe_parse_xml(content) if root is None: return [] return self._parse_pom_xml_from_root(root, manifest_path) def _parse_pom_xml_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """Parse pom.xml from string content (used by GitHub API / memory).""" root = self._safe_parse_xml(content) if root is None: return [] return self._parse_pom_xml_from_root(root, manifest_path) def _parse_pom_xml_from_root( self, root: ET.Element, manifest_path: str, ) -> list[Dependency]: """Walk the parsed XML tree and extract dependencies.""" # Detect whether the POM uses a namespace ns = _NS if root.tag.startswith(f"{{{_MAVEN_NS}}}") else {} # Resolve properties for ${...} placeholders properties = self._extract_properties(root, ns) deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() # Parse section self._collect_dependencies(root, ns, manifest_path, properties, deps, seen) # Parse section dep_mgmt = ( root.find("m:dependencyManagement", ns) if ns else root.find("dependencyManagement") ) if dep_mgmt is not None: self._collect_dependencies(dep_mgmt, ns, manifest_path, properties, deps, seen) # Parse for plugin dependencies self._collect_plugin_dependencies(root, ns, manifest_path, properties, deps, seen) return deps def _collect_dependencies( self, parent: ET.Element, ns: dict[str, str], manifest_path: str, properties: dict[str, str], deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: """Extract dependencies from a block.""" dependencies_el = parent.find("m:dependencies", ns) if ns else parent.find("dependencies") if dependencies_el is None: return dep_tag = f"{{{_MAVEN_NS}}}dependency" if ns else "dependency" for dep_el in dependencies_el.findall(dep_tag): dep = self._parse_dependency_element(dep_el, ns, manifest_path, properties, seen) if dep is not None: deps.append(dep) def _collect_plugin_dependencies( self, root: ET.Element, ns: dict[str, str], manifest_path: str, properties: dict[str, str], deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: """Extract dependencies declared inside .""" build = root.find("m:build", ns) if ns else root.find("build") if build is None: return plugins = build.find("m:plugins", ns) if ns else build.find("plugins") if plugins is None: return plugin_tag = f"{{{_MAVEN_NS}}}plugin" if ns else "plugin" for plugin_el in plugins.findall(plugin_tag): self._collect_dependencies(plugin_el, ns, manifest_path, properties, deps, seen) def _parse_dependency_element( self, dep_el: ET.Element, ns: dict[str, str], manifest_path: str, properties: dict[str, str], seen: set[tuple[str, str]], ) -> Dependency | None: """Parse a single element into a Dependency.""" group_id = self._get_text(dep_el, "groupId", ns) artifact_id = self._get_text(dep_el, "artifactId", ns) if not group_id or not artifact_id: return None # Resolve property placeholders group_id = self._resolve_property(group_id, properties) artifact_id = self._resolve_property(artifact_id, properties) # Skip unresolved placeholders if self._has_placeholder(group_id) or self._has_placeholder(artifact_id): logger.debug( "Skipping dependency with unresolved placeholder: %s:%s", group_id, artifact_id, ) return None # GitHub Advisory DB uses "groupId:artifactId" format for Maven name = f"{group_id}:{artifact_id}" version_text = self._get_text(dep_el, "version", ns) if version_text: version_text = self._resolve_property(version_text, properties) # Skip if version is an unresolved placeholder if version_text and self._has_placeholder(version_text): logger.debug( "Skipping unresolved version placeholder for %s: %s", name, version_text, ) version_text = None version, requirement = self._normalize_maven_specifier(version_text) # Deduplicate by (name, version) dedup_key = (name, version or requirement or "") if dedup_key in seen: return None seen.add(dedup_key) return Dependency( ecosystem=Ecosystem.MAVEN, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) # ------------------------------------------------------- # Property resolution # ------------------------------------------------------- def _extract_properties( self, root: ET.Element, ns: dict[str, str], ) -> dict[str, str]: """Extract from pom.xml into a dict.""" props: dict[str, str] = {} properties_el = root.find("m:properties", ns) if ns else root.find("properties") if properties_el is None: return props for child in properties_el: # Strip namespace from tag name tag = child.tag if tag.startswith(f"{{{_MAVEN_NS}}}"): tag = tag[len(f"{{{_MAVEN_NS}}}") :] if child.text: props[tag] = child.text.strip() return props def _resolve_property(self, value: str, properties: dict[str, str]) -> str: """Replace ${property.name} placeholders with resolved values.""" def replacer(match: re.Match[str]) -> str: prop_name = match.group(1) return properties.get(prop_name, match.group(0)) return re.sub(r"\$\{([^}]+)}", replacer, value) @staticmethod def _has_placeholder(value: str) -> bool: """Check if a string still contains unresolved ${...} placeholders.""" return "${" in value # ------------------------------------------------------- # XML helpers # ------------------------------------------------------- @staticmethod def _safe_parse_xml(content: str | bytes) -> ET.Element | None: """Safely parse XML content, returning None on failure. Accepts both str and bytes. When bytes are provided, the XML parser respects the encoding declared in the XML prolog (e.g. ). Uses defusedxml to prevent entity expansion attacks and DTD processing from untrusted repository XML files. """ try: return ET.fromstring(content) except Exception: logger.warning("Failed to parse XML content", exc_info=True) return None @staticmethod def _get_text(element: ET.Element, child_tag: str, ns: dict[str, str]) -> str | None: """Get text content of a child element.""" child = element.find(f"m:{child_tag}", ns) if ns else element.find(child_tag) if child is not None and child.text: stripped = child.text.strip() return stripped if stripped else None return None # ------------------------------------------------------- # Specifier normalization # ------------------------------------------------------- @staticmethod def _normalize_maven_specifier(spec: str | None) -> tuple[str | None, str | None]: """ Normalize a Maven version specifier. Returns: (exact_version, requirement_range) One will be populated, the other None. Maven version ranges use brackets: [1.0] → exact 1.0 [1.0,2.0) → >=1.0 <2.0 (,1.0] → <=1.0 Plain versions (e.g. "1.2.3") are treated as exact versions. """ if not spec: return None, None spec = spec.strip() if not spec: return None, None # Plain version (no brackets, no ranges) → exact if re.fullmatch(r"[\d]+(?:\.[\d]+)*(?:[.\-][\w]+)*", spec): return spec, None # Maven range notation → store as requirement for range overlap check if spec.startswith(("[", "(")) or spec.endswith(("]", ")")): npm_range = JavaEcosystemHandler._maven_range_to_npm(spec) if npm_range: return None, npm_range return None, spec @staticmethod def _maven_range_to_npm(maven_range: str) -> str | None: """ Convert Maven version range notation to npm-compatible range. Examples: [1.0,2.0) → >=1.0.0 <2.0.0 (1.0,2.0] → >1.0.0 <=2.0.0 [1.0] → ==1.0.0 (exact) (,2.0) → <2.0.0 [1.0,) → >=1.0.0 """ maven_range = maven_range.strip() if not maven_range: return None # Handle comma-separated multiple ranges: [1.0,2.0),[3.0,4.0) if ( "],(" in maven_range or "),[" in maven_range or "],[" in maven_range or "),(" in maven_range ): parts = re.split(r"(?<=[\]\)])\s*,\s*(?=[\[\(])", maven_range) converted = [JavaEcosystemHandler._maven_single_range_to_npm(p) for p in parts] valid = [c for c in converted if c] return " || ".join(valid) if valid else None return JavaEcosystemHandler._maven_single_range_to_npm(maven_range) @staticmethod def _maven_single_range_to_npm(maven_range: str) -> str | None: """Convert a single Maven range expression to npm format.""" maven_range = maven_range.strip() if not maven_range or len(maven_range) < 2: return None left_bracket = maven_range[0] right_bracket = maven_range[-1] inner = maven_range[1:-1] if left_bracket not in ("[", "(") or right_bracket not in ("]", ")"): return None # Exact version: [1.0.0] if "," not in inner: version = inner.strip() if not version: return None return f"=={version}" parts = inner.split(",", 1) lower = parts[0].strip() upper = parts[1].strip() if len(parts) > 1 else "" result_parts = [] if lower: op = ">=" if left_bracket == "[" else ">" result_parts.append(f"{op}{lower}") if upper: op = "<=" if right_bracket == "]" else "<" result_parts.append(f"{op}{upper}") return " ".join(result_parts) if result_parts else None @staticmethod def _coerce_version_token(version: str) -> str: """ Coerce a version string into a valid semver three-part version. Maven commonly uses version formats that semantic_version.NpmSpec cannot parse. This method normalizes them: Four-part numeric versions — weighted encoding into patch: 2.12.7.1 → 2.12.70001 (7*10000 + 1) 2.12.6.10 → 2.12.60010 (6*10000 + 10) 2.12.6.0 → 2.12.60000 (6*10000 + 0) This preserves Maven's segment-by-segment ordering: 2.12.6.10 < 2.12.7.1 → 60010 < 70001 ✓ Qualifier suffixes (dot or hyphen separated) — strip them: 5.2.20.RELEASE → 5.2.20 1.0.0-FINAL → 1.0.0 3.0.0-GA → 3.0.0 Embedded qualifiers in multi-part versions — extract numeric parts: 9.4.1212.jre6.0.0 → 9.4.1212 Single-part versions — pad to three parts: 20190722 → 20190722.0.0 Pre-release identifiers (non-qualifier) — preserve: 2.4.0-rc1 → 2.4.0-rc1 3.0.0-M1 → 3.0.0-M1 1.0.0-alpha2 → 1.0.0-alpha2 1.0.0-SNAPSHOT → 1.0.0-SNAPSHOT """ # ------------------------------------------------ # 1. Separate pre-release suffix from base version # ------------------------------------------------ _STRIP_QUALIFIERS = {"RELEASE", "FINAL", "GA"} _PRE_RELEASE_PREFIXES = ("rc", "alpha", "beta", "M", "SNAPSHOT", "CR") # Weight multiplier for encoding fourth segment into patch. # Must exceed any realistic fourth segment value. _SEGMENT_WEIGHT = 10000 pre_release = "" base = version # Handle hyphen-separated suffix: "2.4.0-rc1", "1.0.0-RELEASE" dash_idx = version.find("-") if dash_idx > 0: base = version[:dash_idx] suffix = version[dash_idx + 1 :] pre_release = "" if suffix.upper() in _STRIP_QUALIFIERS else f"-{suffix}" parts = base.split(".") # ------------------------------------------------ # 2. Strip dot-separated qualifiers from the tail # ------------------------------------------------ numeric_parts: list[str] = [] hit_qualifier = False for part in parts: if hit_qualifier: break if re.fullmatch(r"\d+", part): numeric_parts.append(part) else: if part.upper() in _STRIP_QUALIFIERS: hit_qualifier = True elif any(part.upper().startswith(p.upper()) for p in _PRE_RELEASE_PREFIXES): pre_release = f"-{part}" hit_qualifier = True else: hit_qualifier = True if not numeric_parts: numeric_parts = ["0"] # ------------------------------------------------ # 3. Coerce to exactly 3 numeric parts # ------------------------------------------------ if len(numeric_parts) <= 3: while len(numeric_parts) < 3: numeric_parts.append("0") return ".".join(str(int(p)) for p in numeric_parts) + pre_release # ------------------------------------------------ # 4. Four+ numeric parts: weighted encoding # ------------------------------------------------ # Encode as: patch = segment3 * WEIGHT + segment4 # This preserves segment-by-segment ordering: # 2.12.6.10 → 60010, 2.12.7.1 → 70001 → 60010 < 70001 ✓ # # For 5+ segments, fold segments 4+ into one value: # 1.0.0.0.1 → patch = 0 * WEIGHT + 0 = 0, extra ignored beyond seg4 major = str(int(numeric_parts[0])) minor = str(int(numeric_parts[1])) third = int(numeric_parts[2]) fourth = int(numeric_parts[3]) patch = third * _SEGMENT_WEIGHT + fourth return f"{major}.{minor}.{patch}{pre_release}"