"""
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