""" Composer (PHP) ecosystem handler. Supports: - composer.json (manifest) - composer.lock (lockfile) """ from __future__ import annotations import json import re from pathlib import Path import semantic_version from vuln_scan.core.models import ( Dependency, Ecosystem, EcosystemParseResult, SecurityVulnerability, ) from vuln_scan.ecosystems.base import EcosystemHandler class PhpEcosystemHandler(EcosystemHandler): """Handler for the Composer / PHP ecosystem.""" id = "composer" ecosystem = Ecosystem.COMPOSER # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"composer.lock"} @property def manifest_names(self) -> set[str]: return {"composer.json"} @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_text(encoding="utf-8") return self.parse_manifest_content(manifest_path, content) def parse_manifest_content( self, manifest_path: str, content: str, ) -> EcosystemParseResult: filename = Path(manifest_path).name is_lockfile = self.is_lockfile(filename) if filename == "composer.lock": deps = self._parse_composer_lock_content(content, manifest_path) elif filename == "composer.json": deps = self._parse_composer_json_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name="composer", ) # ------------------------------------------------------- # 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: version = version.strip() version = re.sub(r"@[a-zA-Z]+", "", version) version = version.lstrip("vV") if re.fullmatch(r"\d+\.\d+", version): version += ".0" return version def _normalize_vuln_range(self, spec: str) -> str: spec = (spec or "").strip() if not spec: return "" # Replace commas with spaces spec = spec.replace(",", " ") # Remove stability flags spec = re.sub(r"@[a-zA-Z]+", "", spec) # Normalize spacing spec = " ".join(spec.split()) # Fix operator spacing spec = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", 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) confidence = "high" if dependency.is_lockfile else "medium" return is_vuln, confidence if dependency.requirement: return ( self._ranges_overlap( self._normalize_vuln_range(dependency.requirement), normalized, ), "low", ) return True, "low" except Exception: return True, "low" # ------------------------------------------------------- # composer.json # ------------------------------------------------------- def _parse_composer_json_content( self, content: str, manifest_path: str, ) -> list[Dependency]: data = self._safe_json_load(content) if data is None: return [] deps: list[Dependency] = [] for section in ("require", "require-dev"): section_data = data.get(section) if not isinstance(section_data, dict): continue for name, version_spec in section_data.items(): if self._is_platform_requirement(name): continue if not isinstance(version_spec, str): continue version, requirement = self._normalize_composer_specifier(version_spec) deps.append( Dependency( ecosystem=Ecosystem.COMPOSER, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # composer.lock # ------------------------------------------------------- def _parse_composer_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: data = self._safe_json_load(content) if data is None: return [] deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for section in ("packages", "packages-dev"): packages = data.get(section) if isinstance(packages, list): self._collect_lock_packages(packages, manifest_path, deps, seen) return deps def _collect_lock_packages( self, packages: list[object], manifest_path: str, deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: """Extract dependencies from a composer.lock packages array.""" for pkg in packages: if not isinstance(pkg, dict): continue name = pkg.get("name") version = pkg.get("version") if not isinstance(name, str) or not name: continue if not isinstance(version, str) or not version: continue clean_version = self._strip_version_prefix(version) if (name, clean_version) in seen: continue seen.add((name, clean_version)) deps.append( Dependency( ecosystem=Ecosystem.COMPOSER, name=name, version=clean_version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) # ------------------------------------------------------- # Helpers # ------------------------------------------------------- @staticmethod def _safe_json_load(content: str) -> dict[str, object] | None: try: data = json.loads(content) except ValueError: return None return data if isinstance(data, dict) else None @staticmethod def _is_platform_requirement(name: str) -> bool: """ Filter out PHP platform requirements. These are not installable packages: php, ext-*, lib-*, composer-plugin-api """ lower = name.lower() return ( lower == "php" or lower.startswith("ext-") or lower.startswith("lib-") or lower == "composer-plugin-api" ) @staticmethod def _strip_version_prefix(version: str) -> str: """Strip leading 'v' or 'V' prefix from version strings.""" if version and version[0] in ("v", "V"): return version[1:] return version @staticmethod def _normalize_composer_specifier(spec: str) -> tuple[str | None, str | None]: """ Normalize a Composer version specifier. Returns: (exact_version, requirement_range) One will be populated, the other None. """ spec = spec.strip() if not spec: return None, None # Exact version: 1.2.3 or v1.2.3 cleaned = spec.lstrip("vV") if re.fullmatch(r"\d+(\.\d+)*", cleaned): return cleaned, None # Stability flags: @dev, @stable, etc. # Strip them for range evaluation spec_no_stability = re.sub(r"@(dev|alpha|beta|RC|stable)", "", spec).strip() if not spec_no_stability: return None, None # dev-* branches — can't resolve to a version if spec_no_stability.startswith("dev-"): return None, None # Range specifiers: ^, ~, >=, >, <, <=, ||, *, etc. return None, spec_no_stability