""" RubyGems ecosystem handler. Supports: - Gemfile (manifest) - Gemfile.lock (lockfile) - *.gemspec (manifest) """ from __future__ import annotations import logging 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 logger = logging.getLogger(__name__) class RubyEcosystemHandler(EcosystemHandler): """Handler for the RubyGems ecosystem.""" id = "ruby" ecosystem = Ecosystem.RUBYGEMS # Pattern matching valid Ruby version constraints: # "1.2.3", "~> 2.0", ">= 1.0", "<= 3.0", "!= 1.5", "= 2.0" _VERSION_CONSTRAINT_RE = re.compile(r"^(?:~>|>=|<=|!=|>|<|=)?\s*\d+(?:\.\d+)*(?:\.\w+)*$") # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"Gemfile.lock"} @property def manifest_names(self) -> set[str]: return {"Gemfile"} @property def manifest_globs(self) -> set[str]: return {"*.gemspec"} # ------------------------------------------------------- # Parsing — dispatch # ------------------------------------------------------- def parse_manifest( self, manifest_path: str, full_path: Path, ) -> EcosystemParseResult: content = full_path.read_text(encoding="utf-8", errors="ignore") 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 == "Gemfile.lock": deps = self._parse_gemfile_lock_content(content, manifest_path) elif filename == "Gemfile": deps = self._parse_gemfile_content(content, manifest_path) elif filename.endswith(".gemspec"): deps = self._parse_gemspec_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name=self.id, ) # ------------------------------------------------------- # 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() # Reject versions with no leading digit — not a real version if not version or not version[0].isdigit(): raise ValueError(f"Not a valid version: {version}") return self._coerce_version_token(version) def _normalize_vuln_range(self, spec: str | None) -> str: spec = (spec or "").strip() if not spec: return "" # Ruby's pessimistic operator ~> maps to npm's ~ operator spec = self._expand_pessimistic_constraints(spec) # Normalize comma-separated constraints to spaces spec = spec.replace(",", " ") spec = " ".join(spec.split()) # Fix operator spacing spec = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", spec) # Coerce each version token to valid 3-part semver # This handles 4-part Ruby versions like 5.2.4.2, 6.1.7.3 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) 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: logger.warning( "Failed to evaluate vulnerability for %s, assuming vulnerable", dependency.name, exc_info=True, ) return True, "low" # ------------------------------------------------------- # Gemfile.lock parser # ------------------------------------------------------- def _parse_gemfile_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """ Parse Gemfile.lock. Gems appear under the SPECS section indented with exactly 4 spaces, followed by name (version): GEM remote: https://rubygems.org/ specs: rails (7.0.4) actioncable (= 7.0.4) nokogiri (1.14.0) """ deps: list[Dependency] = [] in_specs = False for line in content.splitlines(): stripped = line.rstrip() # Detect "specs:" section if stripped.strip() == "specs:": in_specs = True continue # A non-indented line (or a new section header) ends specs if in_specs and stripped and not stripped.startswith(" "): in_specs = False continue if not in_specs: continue # Top-level gems are indented with exactly 4 spaces match = re.match(r"^ (\S+)\s+\((\S+)\)$", line) if match: name, version = match.group(1), match.group(2) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps # ------------------------------------------------------- # Gemfile parser # ------------------------------------------------------- def _parse_gemfile_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """ Parse Gemfile. Looks for lines like: gem 'rails', '~> 7.0' gem "nokogiri", ">= 1.13", "< 2.0" gem 'puma' """ deps: list[Dependency] = [] gem_pattern = re.compile( r"""^\s*gem\s+['"]([^'"]+)['"]\s*(?:,\s*(.+))?""", ) for line in content.splitlines(): s = line.strip() if not s or s.startswith("#"): continue match = gem_pattern.match(s) if not match: continue name = match.group(1) version_part = match.group(2) version, requirement = self._parse_gem_version_args(version_part) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # .gemspec parser # ------------------------------------------------------- def _parse_gemspec_content( self, content: str, manifest_path: str, ) -> list[Dependency]: deps: list[Dependency] = [] dep_pattern = re.compile( r"""\.add(?:_runtime|_development)?_dependency""" r"""\s*\(?\s*['"]([^'"]+)['"]\s*""" # group 1: gem name r"""(?:,\s*(.+?))?\s*\)?\s*$""", # group 2: version args (optional) ) for line in content.splitlines(): match = dep_pattern.search(line) if not match: continue name = match.group(1) version_part = match.group(2) if version_part: version_part = ( re.sub( r""",?\s*\w+:\s*(?:'[^']*'|"[^"]*"|\[.*?\]|:\w+|\w+)""", "", version_part, ) .strip() .rstrip(",") ) if not version_part: version_part = None version, requirement = self._parse_gem_version_args(version_part) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # Helpers # ------------------------------------------------------- def _parse_gem_version_args( self, version_part: str | None, ) -> tuple[str | None, str | None]: """ Parse the version argument(s) from a gem directive. Returns: (exact_version, requirement_range) One will be populated, the other None. Examples: "'~> 7.0'" → (None, "~> 7.0") "'>= 1.0', '< 2.0'" → (None, ">= 1.0, < 2.0") "'1.2.3'" → ("1.2.3", None) "'3.0.0.pre'" → (None, "3.0.0.pre") None → (None, None) "'~> 1.0', git: 'https://...'" → (None, "~> 1.0") "git: 'https://...'" → (None, None) Non-version quoted args (git URLs, paths, symbol-like strings) are filtered out so they don't pollute the requirement string. """ if not version_part: return None, None # Extract all quoted strings raw_parts = re.findall(r"""['"]([^'"]+)['"]""", version_part) if not raw_parts: return None, None # Keep only version-like strings parts = [p.strip() for p in raw_parts if self._VERSION_CONSTRAINT_RE.match(p.strip())] if not parts: return None, None # Single bare version: "1.2.3" if len(parts) == 1: v = parts[0] if re.fullmatch(r"\d+(\.\d+)*", v): return v, None # Combine into a single requirement string combined = ", ".join(parts) return None, combined # ------------------------------------------------------- # Specifier normalization # ------------------------------------------------------- @staticmethod def _expand_pessimistic_constraints(spec: str) -> str: """ Expand Ruby's ~> (pessimistic) operator into explicit bounded ranges. Ruby's ~> bumps the second-to-last specified segment: ~> 2.1 → >= 2.1.0, < 3.0.0 ~> 2.1.0 → >= 2.1.0, < 2.2.0 ~> 0.9.4 → >= 0.9.4, < 0.10.0 ~> 5 → >= 5.0.0, < 6.0.0 ~> 1.2.3.4 → >= 1.2.3.4, < 1.2.4.0 """ def _expand(m: re.Match[str]) -> str: version = m.group(1).strip() parts = version.split(".") int_parts = [int(p) for p in parts] if len(int_parts) == 1: upper_parts = [str(int_parts[0] + 1)] else: upper = int_parts[:-1] upper[-1] += 1 upper.append(0) upper_parts = [str(x) for x in upper] lower = ".".join(parts) upper_str = ".".join(upper_parts) return f">={lower}, <{upper_str}" return re.sub(r"~>\s*([\d]+(?:\.[\d]+)*)", _expand, spec) @staticmethod def _coerce_version_token(version: str) -> str: """ Coerce a Ruby version string into valid 3-part semver. Ruby commonly uses 4-part versions (e.g. 7.2.3.1, 5.2.4.2). NpmSpec only accepts 3-part versions, so we encode the 4th segment into patch using weighted encoding: 7.2.3.1 → 7.2.30001 (3*10000 + 1) 5.2.4.2 → 5.2.40002 (4*10000 + 2) 6.1.7.3 → 6.1.70003 (7*10000 + 3) This preserves ordering: 6.1.7.3 < 6.1.7.4 → 70003 < 70004 ✓ 5.2.4.2 < 5.2.5.0 → 40002 < 50000 ✓ Also handles: Two-part: 1.2 → 1.2.0 One-part: 3 → 3.0.0 Pre-release: 2.0.0.rc1 → 2.0.0-rc1 """ _SEGMENT_WEIGHT = 10000 _PRE_RELEASE_PREFIXES = ("rc", "alpha", "beta", "pre", "preview") pre_release = "" base = version.strip() # Handle hyphen-separated pre-release: "2.0.0-rc1" dash_idx = base.find("-") if dash_idx > 0: pre_release = base[dash_idx:] # includes the dash base = base[:dash_idx] parts = base.split(".") # Separate numeric parts from trailing qualifiers (e.g. "rc1") numeric_parts: list[str] = [] for part in parts: if re.fullmatch(r"\d+", part): numeric_parts.append(part) else: # Ruby pre-release segments like "rc1", "beta2" if any(part.lower().startswith(p) for p in _PRE_RELEASE_PREFIXES): pre_release = f"-{part}" break if not numeric_parts: numeric_parts = ["0"] # Pad to 3 parts if needed if len(numeric_parts) <= 3: while len(numeric_parts) < 3: numeric_parts.append("0") return ".".join(numeric_parts) + pre_release # 4+ parts: weighted encoding major = numeric_parts[0] minor = 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}"