""" Go ecosystem handler. Supports: - go.mod (manifest — pinned versions via Minimal Version Selection) - go.sum (lockfile — exact versions with checksums) """ 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__) # Go pseudo-version pattern: # v0.0.0-20230420150708-abcd1234ef56 # v1.2.3-pre.0.20230420150708-abcd1234ef56 _PSEUDO_VERSION_RE = re.compile( r"^v?\d+\.\d+\.\d+" # base semver r"(?:-[a-zA-Z0-9.]*\.)?" # optional pre-release prefix r"(\d{14})" # 14-digit timestamp r"-([0-9a-f]{12})$" # 12-char commit hash ) _REQUIRE_BLOCK_START_RE = re.compile(r"^require\s*\(") _REPLACE_BLOCK_START_RE = re.compile(r"^replace\s*\(") _REQUIRE_INLINE_RE = re.compile(r"^require\s+(.+)") _REPLACE_INLINE_RE = re.compile(r"^replace\s+(.+)") class GoEcosystemHandler(EcosystemHandler): """Handler for the Go modules ecosystem.""" id = "go" ecosystem = Ecosystem.GO # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"go.sum"} @property def manifest_names(self) -> set[str]: return {"go.mod"} @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", 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 == "go.sum": deps = self._parse_go_sum_content(content, manifest_path) elif filename == "go.mod": deps = self._parse_go_mod_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name="go", ) # ------------------------------------------------------- # 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 Go module version for semver comparison. - Strips leading 'v' / 'V' prefix - Strips +incompatible suffix - Pads two-part versions to three parts (1.2 -> 1.2.0) - Preserves pseudo-versions as-is since their pre-release segment (timestamp + commit hash) is already valid semver and sorts correctly via lexicographic comparison """ v = (version or "").strip() v = v.lstrip("vV") # Strip +incompatible suffix used by Go for v2+ modules v = re.sub(r"\+incompatible$", "", v) # Pseudo-versions like "0.0.0-20230420150708-abcd1234ef56" # are already valid semver with a pre-release tag — the # 14-digit timestamp ensures correct lexicographic ordering. # Return as-is; no transformation needed. m = _PSEUDO_VERSION_RE.match(f"v{v}") if m: return v # Pad 2-part versions: 1.2 -> 1.2.0 if re.fullmatch(r"\d+\.\d+", v): v += ".0" return v def _normalize_vuln_range(self, spec: str | None) -> str: """ Normalize GitHub Advisory vulnerability range for Go packages. GitHub uses npm-style ranges for all ecosystems: ">= 1.0.0, < 1.5.1" -> ">=1.0.0 <1.5.1" "< 1.7.0" -> "<1.7.0" Go versions may have a 'v' prefix that must be stripped, and +incompatible suffixes that must be removed. """ spec = (spec or "").strip() if not spec: return "" # Replace commas with spaces spec = spec.replace(",", " ") # Normalize spacing spec = " ".join(spec.split()) # Collapse operator spacing: ">= 1.0.0" -> ">=1.0.0" spec = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", spec) # Strip 'v' prefix and +incompatible from version tokens spec = re.sub( r"(<=|>=|<|>|==|=|~|\^)?v?(\d[\w.\-+]*)", lambda m: (m.group(1) or "") + self._strip_go_qualifiers(m.group(2)), spec, ) return spec @staticmethod def _strip_go_qualifiers(version: str) -> str: """Strip +incompatible suffix from a version token.""" return re.sub(r"\+incompatible$", "", version) # ------------------------------------------------------- # 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" # Exact version (go.sum lockfile or go.mod pinned version) 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 # Requirement range overlap (unlikely for Go, but defensive) if dependency.requirement: req = self._normalize_vuln_range(dependency.requirement) if not req: return True, "low" overlap = self._ranges_overlap(req, normalized) return overlap, "low" return True, "low" except Exception: logger.warning( "Failed to evaluate vulnerability for %s, assuming vulnerable", dependency.name, exc_info=True, ) return True, "low" # ------------------------------------------------------- # go.sum parsing # ------------------------------------------------------- def _parse_go_sum_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """ Parse go.sum lockfile. Format per line: [/go.mod] Examples: github.com/foo/bar v1.2.3 h1:abc123= github.com/foo/bar v1.2.3/go.mod h1:abc123= We deduplicate by (module, version) since go.sum contains two entries per module (one for the module, one for go.mod). """ deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for line in content.splitlines(): line = line.strip() if not line or line.startswith("//"): continue parts = line.split() if len(parts) < 3: continue module = parts[0] version_raw = parts[1] # Skip /go.mod entries — they duplicate the module entry if version_raw.endswith("/go.mod"): version_raw = version_raw[: -len("/go.mod")] if not module or not version_raw: continue version = self._normalize_version(version_raw) if (module, version) in seen: continue seen.add((module, version)) deps.append( Dependency( ecosystem=self.ecosystem, name=module, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps # ------------------------------------------------------- # go.mod parsing # ------------------------------------------------------- def _parse_go_mod_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """ Parse go.mod manifest. Handles both inline and block require directives: Inline: require github.com/foo/bar v1.2.3 Block: require ( github.com/foo/bar v1.2.3 github.com/baz/qux v0.5.0 // indirect ) Go uses Minimal Version Selection, so all versions in go.mod are effectively pinned — no range specifiers. Replace directives are also parsed since the replacement module/version is what's actually used. """ deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() lines = content.splitlines() in_require_block = False in_replace_block = False for raw_line in lines: line = raw_line.strip() # Strip inline comments if "//" in line: line = line[: line.index("//")].strip() if not line: continue # ---- Block start / end ---- if _REQUIRE_BLOCK_START_RE.match(line): in_require_block = True continue if _REPLACE_BLOCK_START_RE.match(line): in_replace_block = True continue if line == ")": in_require_block = False in_replace_block = False continue # ---- Inline require ---- m = _REQUIRE_INLINE_RE.match(line) if m and "(" not in line: self._add_require_dep(m.group(1), manifest_path, deps, seen) continue # ---- Inside require block ---- if in_require_block: self._add_require_dep(line, manifest_path, deps, seen) continue # ---- Inline replace ---- m = _REPLACE_INLINE_RE.match(line) if m and "(" not in line: self._add_replace_dep(m.group(1), manifest_path, deps, seen) continue # ---- Inside replace block ---- if in_replace_block: self._add_replace_dep(line, manifest_path, deps, seen) continue return deps def _add_require_dep( self, line: str, manifest_path: str, deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: """Parse a single require line: 'module version'.""" parts = line.split() if len(parts) < 2: return module = parts[0] version_raw = parts[1] if not module or not version_raw: # pragma: no cover return version = self._normalize_version(version_raw) if (module, version) in seen: return seen.add((module, version)) deps.append( Dependency( ecosystem=self.ecosystem, name=module, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=False, ) ) def _add_replace_dep( self, line: str, manifest_path: str, deps: list[Dependency], seen: set[tuple[str, str]], ) -> None: """ Parse a single replace line: 'old => new version' or 'old version => new version'. We care about the *replacement* (right side of =>), since that's the module actually compiled into the binary. """ if "=>" not in line: return _, _, right = line.partition("=>") right = right.strip() parts = right.split() if len(parts) < 2: return module = parts[0] version_raw = parts[1] if not module or not version_raw: # pragma: no cover return version = self._normalize_version(version_raw) if (module, version) in seen: return seen.add((module, version)) deps.append( Dependency( ecosystem=self.ecosystem, name=module, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=False, ) )