""" NuGet (.NET) ecosystem handler. Supports: - *.csproj (manifest — PackageReference) - packages.config (manifest — legacy) - Directory.Packages.props (manifest — central package management) - packages.lock.json (lockfile — resolved versions) """ from __future__ import annotations import json import logging import re from collections.abc import Iterator 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__) class DotNetEcosystemHandler(EcosystemHandler): """Handler for the NuGet / .NET ecosystem.""" id = "dotnet" ecosystem = Ecosystem.NUGET # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"packages.lock.json"} @property def manifest_names(self) -> set[str]: return {"packages.config", "Directory.Packages.props"} @property def manifest_globs(self) -> set[str]: return {"*.csproj"} # ------------------------------------------------------- # Parsing — dispatch # ------------------------------------------------------- def parse_manifest( self, manifest_path: str, full_path: Path, ) -> EcosystemParseResult: filename = Path(manifest_path).name if filename == "packages.lock.json": content = full_path.read_text(encoding="utf-8", errors="ignore") return self.parse_manifest_content(manifest_path, content) # XML manifests — read as bytes to respect XML prolog encoding content_bytes = full_path.read_bytes() return self._parse_manifest_bytes(manifest_path, content_bytes) def parse_manifest_content( self, manifest_path: str, content: str, ) -> EcosystemParseResult: filename = Path(manifest_path).name is_lockfile = self.is_lockfile(filename) if filename == "packages.lock.json": deps = self._parse_packages_lock_json_content(content, manifest_path) elif filename == "packages.config": deps = self._parse_packages_config_content(content, manifest_path) elif filename == "Directory.Packages.props": deps = self._parse_directory_packages_props_content(content, manifest_path) elif filename.endswith(".csproj"): deps = self._parse_csproj_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name="nuget", ) def _parse_manifest_bytes( self, manifest_path: str, content: bytes, ) -> EcosystemParseResult: """Parse XML manifest from raw bytes, respecting XML prolog encoding.""" filename = Path(manifest_path).name if filename == "packages.config": deps = self._parse_packages_config_bytes(content, manifest_path) elif filename == "Directory.Packages.props": deps = self._parse_directory_packages_props_bytes(content, manifest_path) elif filename.endswith(".csproj"): deps = self._parse_csproj_bytes(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="manifest", parser_name="nuget", ) # ------------------------------------------------------- # 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 NuGet version string for semver comparison. - Strips leading 'v' / 'V' prefix (rare but defensive) - Coerces 4-part .NET versions (1.2.3.4) via weighted encoding - Pads 2-part versions to 3 parts - Preserves pre-release tags (-preview, -rc.1, etc.) Raises ValueError if the version contains no numeric parts. """ version = version.strip() version = version.lstrip("vV") 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: """ Normalize a GitHub Advisory vulnerability range for NuGet packages. GitHub uses npm-style ranges for all ecosystems: ">= 2.0.0, < 3.5.0" -> ">=2.0.0 <3.5.0" Also handles: "1.2.3" -> "==1.2.3" (bare exact version) "= 1.2.3" -> "==1.2.3" (single-equals shorthand) "== 1.2.3" -> "==1.2.3" (spaced double-equals) Coerces .NET 4-part versions (e.g. 4.7.0.2) to semver-compatible 3-part versions via weighted encoding. """ spec = (spec or "").strip() if not spec: return "" # Replace commas with spaces 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) # Normalize single "=" to "==": "=1.2.3" -> "==1.2.3" if spec.startswith("=") and not spec.startswith("=="): spec = f"={spec}" # Bare exact version (no operator): "1.2.3" -> "==1.2.3" if re.fullmatch(r"\d+(\.\d+)*", spec): spec = f"=={spec}" # Coerce each version token to valid semver 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" # ------------------------------------------------------- # packages.lock.json parsing # ------------------------------------------------------- def _parse_packages_lock_json_content( self, content: str, manifest_path: str, ) -> list[Dependency]: """ Parse packages.lock.json lockfile. Structure: { "version": 1, "dependencies": { ".NETCoreApp,Version=v8.0": { "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3", "contentHash": "..." } } } } We extract the "resolved" version from each package entry across all target frameworks, deduplicating by (name, version). """ data = self._safe_json_load(content) if data is None: return [] dependencies_section = data.get("dependencies") if not isinstance(dependencies_section, dict): return [] deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for _framework, packages in dependencies_section.items(): if not isinstance(packages, dict): continue for name, pkg_info in packages.items(): if not isinstance(pkg_info, dict): continue resolved = pkg_info.get("resolved") if not isinstance(resolved, str) or not resolved.strip(): continue name = name.strip() if not name: continue version = resolved.strip() key = (name.lower(), version) if key in seen: continue seen.add(key) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps # ------------------------------------------------------- # *.csproj parsing # ------------------------------------------------------- def _parse_csproj_bytes( self, content: bytes, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_csproj_from_root(root, manifest_path) def _parse_csproj_content( self, content: str, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_csproj_from_root(root, manifest_path) def _parse_csproj_from_root( self, root: ET.Element, manifest_path: str, ) -> list[Dependency]: """ Extract PackageReference entries from .csproj. Handles both SDK-style (no namespace) and legacy projects with xmlns="http://schemas.microsoft.com/developer/msbuild/2003". """ deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for item_group in self._iter_tag(root, "ItemGroup"): for pkg_ref in self._iter_tag(item_group, "PackageReference"): name = pkg_ref.get("Include") or pkg_ref.get("include") or "" name = name.strip() if not name: continue version_str = self._get_package_version(pkg_ref) version, requirement = self._normalize_nuget_specifier(version_str) dedup_key = (name.lower(), version or requirement or "") if dedup_key in seen: continue seen.add(dedup_key) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # packages.config parsing # ------------------------------------------------------- def _parse_packages_config_bytes( self, content: bytes, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_packages_config_from_root(root, manifest_path) def _parse_packages_config_content( self, content: str, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_packages_config_from_root(root, manifest_path) def _parse_packages_config_from_root( self, root: ET.Element, manifest_path: str, ) -> list[Dependency]: """ Extract dependencies from packages.config. packages.config does not typically use namespaces, but we match namespace-agnostically for robustness. """ deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for pkg in self._iter_tag(root, "package"): name = pkg.get("id") or pkg.get("Id") or "" name = name.strip() if not name: continue version = pkg.get("version") or pkg.get("Version") or "" version = version.strip() if not version: continue key = (name.lower(), version) if key in seen: continue seen.add(key) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # Directory.Packages.props parsing # ------------------------------------------------------- def _parse_directory_packages_props_bytes( self, content: bytes, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_directory_packages_props_from_root(root, manifest_path) def _parse_directory_packages_props_content( self, content: str, manifest_path: str, ) -> list[Dependency]: root = self._safe_parse_xml(content) if root is None: return [] return self._parse_directory_packages_props_from_root(root, manifest_path) def _parse_directory_packages_props_from_root( self, root: ET.Element, manifest_path: str, ) -> list[Dependency]: """ Extract PackageVersion entries from Directory.Packages.props. Matches namespace-agnostically for robustness. """ deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for item_group in self._iter_tag(root, "ItemGroup"): for pkg_ver in self._iter_tag(item_group, "PackageVersion"): name = pkg_ver.get("Include") or pkg_ver.get("include") or "" name = name.strip() if not name: continue version_str = self._get_package_version(pkg_ver) version, requirement = self._normalize_nuget_specifier(version_str) dedup_key = (name.lower(), version or requirement or "") if dedup_key in seen: continue seen.add(dedup_key) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) return deps # ------------------------------------------------------- # Helpers # ------------------------------------------------------- @staticmethod def _safe_json_load(content: str) -> dict[str, object] | None: try: data = json.loads(content) except (ValueError, TypeError): return None return data if isinstance(data, dict) else None @staticmethod def _safe_parse_xml(content: str | bytes) -> ET.Element | None: """ Safely parse XML content, returning None on failure. 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_package_version(element: ET.Element) -> str | None: """ Get version from a PackageReference or PackageVersion element. Version can be specified as an attribute or child element: 1.0.0 Searches namespace-agnostically for the child element. """ # Try attribute first (most common) version = element.get("Version") or element.get("version") if version: return version.strip() or None # Try child element (namespace-agnostic) version_el = DotNetEcosystemHandler._find_child(element, "Version") if version_el is not None and version_el.text: stripped = version_el.text.strip() return stripped or None return None @staticmethod def _normalize_nuget_specifier(spec: str | None) -> tuple[str | None, str | None]: """ Normalize a NuGet version specifier. Returns: (exact_version, requirement_range) One will be populated, the other None. NuGet version formats: "13.0.1" → exact version "[1.0,2.0)" → range (bracket notation, same as Maven) "(,1.0]" → range "1.0.*" → floating (treated as requirement) """ if not spec: return None, None spec = spec.strip() if not spec: return None, None # Bracket/paren notation → convert to npm range if spec.startswith(("[", "(")) or spec.endswith(("]", ")")): npm_range = DotNetEcosystemHandler._nuget_range_to_npm(spec) if npm_range: return None, npm_range return None, None # Floating version: 1.0.* → requirement if "*" in spec: return None, spec # Plain version (no brackets, no wildcards) → exact if re.fullmatch(r"[\d]+(?:\.[\d]+)*(?:[.\-][\w]+)*", spec): return spec, None # Anything else → treat as requirement return None, spec @staticmethod def _nuget_range_to_npm(nuget_range: str) -> str | None: """ Convert NuGet version range notation to npm-compatible range. NuGet uses the same bracket/paren notation as Maven: [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 """ nuget_range = nuget_range.strip() if not nuget_range or len(nuget_range) < 2: return None left_bracket = nuget_range[0] right_bracket = nuget_range[-1] inner = nuget_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 3-part version. .NET commonly uses 4-part versions (e.g. 4.7.0.2, 6.0.1.3). NpmSpec only accepts 3-part versions, so we encode the 4th segment into patch using weighted encoding: 4.7.0.2 → 4.7.2 (0*10000 + 2) 6.0.1.3 → 6.0.10003 (1*10000 + 3) 1.2.3.4 → 1.2.30004 (3*10000 + 4) This preserves ordering: 1.2.3.4 < 1.2.3.5 → 30004 < 30005 ✓ 1.2.3.9 < 1.2.4.0 → 30009 < 40000 ✓ Also handles: Two-part: 1.2 → 1.2.0 One-part: 3 → 3.0.0 Pre-release: 1.0.0-preview.1 → 1.0.0-preview.1 """ _SEGMENT_WEIGHT = 10000 _PRE_RELEASE_PREFIXES = ("rc", "alpha", "beta", "preview") pre_release = "" base = version.strip() # Handle hyphen-separated pre-release: "1.0.0-preview.1" 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 numeric_parts: list[str] = [] for part in parts: if re.fullmatch(r"\d+", part): numeric_parts.append(part) else: 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}" @staticmethod def _strip_namespace(tag: str) -> str: """Strip the XML namespace prefix from a tag name. ElementTree represents namespaced tags as '{uri}localname'. This returns just the local name. """ if tag.startswith("{"): return tag.split("}", 1)[1] return tag @staticmethod def _iter_tag(root: ET.Element, local_name: str) -> Iterator[ET.Element]: """ Iterate over all descendant elements matching a local tag name, regardless of XML namespace. Equivalent to root.iter(local_name) but namespace-agnostic. This handles both: - SDK-style .csproj (no namespace) - Legacy .csproj (xmlns="http://schemas.microsoft.com/developer/msbuild/2003") """ for el in root.iter(): if DotNetEcosystemHandler._strip_namespace(el.tag) == local_name: yield el @staticmethod def _find_child(parent: ET.Element, local_name: str) -> ET.Element | None: """ Find a direct child element by local tag name, ignoring namespace. """ for child in parent: if DotNetEcosystemHandler._strip_namespace(child.tag) == local_name: return child return None