from __future__ import annotations from pathlib import Path from textwrap import dedent from unittest.mock import patch import pytest import semantic_version from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Ecosystem from vuln_scan.ecosystems.dotnet import DotNetEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> DotNetEcosystemHandler: return DotNetEcosystemHandler() # -------------------------------------------------------- # Handler identity # -------------------------------------------------------- def test_handler_id_and_ecosystem(handler: DotNetEcosystemHandler) -> None: assert handler.id == "dotnet" assert handler.ecosystem == Ecosystem.NUGET # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: DotNetEcosystemHandler) -> None: assert handler.manifest_names == {"packages.config", "Directory.Packages.props"} assert handler.lockfile_names == {"packages.lock.json"} assert handler.manifest_globs == {"*.csproj"} @pytest.mark.parametrize( "path, expected", [ ("MyApp.csproj", True), ("src/MyApp.csproj", True), ("packages.config", True), ("Directory.Packages.props", True), ("packages.lock.json", True), ("nope.txt", False), ("package.json", False), ], ) def test_supports_manifest(handler: DotNetEcosystemHandler, path: str, expected: bool) -> None: assert handler.supports_manifest(path) is expected def test_is_lockfile(handler: DotNetEcosystemHandler) -> None: assert handler.is_lockfile("packages.lock.json") is True assert handler.is_lockfile("MyApp.csproj") is False assert handler.is_lockfile("packages.config") is False def test_is_manifest(handler: DotNetEcosystemHandler) -> None: assert handler.is_manifest("MyApp.csproj") is True assert handler.is_manifest("packages.config") is True assert handler.is_manifest("Directory.Packages.props") is True assert handler.is_manifest("packages.lock.json") is False assert handler.is_manifest("nope.txt") is False # -------------------------------------------------------- # parse_manifest — on-disk dispatch (bytes & JSON paths) # -------------------------------------------------------- def test_parse_manifest_reads_csproj_file( handler: DotNetEcosystemHandler, tmp_path: Path, ) -> None: csproj = tmp_path / "MyApp.csproj" csproj.write_text( dedent("""\ """), encoding="utf-8", ) parsed = handler.parse_manifest("MyApp.csproj", csproj) assert parsed.manifest_type == "manifest" assert parsed.parser_name == "nuget" assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.ecosystem == Ecosystem.NUGET assert dep.name == "Newtonsoft.Json" assert dep.version == "13.0.1" assert dep.is_lockfile is False def test_parse_manifest_reads_lockfile( handler: DotNetEcosystemHandler, tmp_path: Path, ) -> None: lockfile = tmp_path / "packages.lock.json" lockfile.write_text( dedent("""\ { "version": 1, "dependencies": { ".NETCoreApp,Version=v8.0": { "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" } } } } """), encoding="utf-8", ) parsed = handler.parse_manifest("packages.lock.json", lockfile) assert parsed.manifest_type == "lockfile" assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].is_lockfile is True def test_parse_manifest_reads_packages_config( handler: DotNetEcosystemHandler, tmp_path: Path, ) -> None: pkgconfig = tmp_path / "packages.config" pkgconfig.write_text( dedent("""\ """), encoding="utf-8", ) parsed = handler.parse_manifest("packages.config", pkgconfig) assert parsed.manifest_type == "manifest" assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Newtonsoft.Json" def test_parse_manifest_reads_directory_packages_props_file( handler: DotNetEcosystemHandler, tmp_path: Path, ) -> None: props = tmp_path / "Directory.Packages.props" props.write_text( dedent("""\ """), encoding="utf-8", ) parsed = handler.parse_manifest("Directory.Packages.props", props) assert parsed.manifest_type == "manifest" assert parsed.parser_name == "nuget" assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Serilog" def test_parse_manifest_non_supported_file_returns_empty( handler: DotNetEcosystemHandler, tmp_path: Path, ) -> None: other = tmp_path / "build.gradle" other.write_bytes(b"apply plugin: 'java'") parsed = handler.parse_manifest("build.gradle", other) assert len(parsed.dependencies) == 0 @pytest.mark.parametrize( "filename, content", [ pytest.param("MyApp.csproj", b"not valid xml <<<", id="csproj-invalid-xml"), pytest.param( "packages.config", b"\xff\xfe not valid xml <<<", id="packages-config-invalid-xml" ), pytest.param( "Directory.Packages.props", b"<<>>", id="directory-packages-props-invalid-xml" ), ], ) def test_parse_manifest_invalid_xml_bytes_returns_empty( handler: DotNetEcosystemHandler, tmp_path: Path, filename: str, content: bytes, ) -> None: """Invalid XML via the on-disk bytes path returns empty for all XML manifest types.""" path = tmp_path / filename path.write_bytes(content) parsed = handler.parse_manifest(filename, path) assert len(parsed.dependencies) == 0 assert parsed.manifest_type == "manifest" # -------------------------------------------------------- # parse_manifest_content — string dispatch # -------------------------------------------------------- @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_first_name", [ pytest.param("unknown.xyz", "", "manifest", 0, None, id="unknown-file"), pytest.param( "MyApp.csproj", dedent("""\ """), "manifest", 1, "Newtonsoft.Json", id="csproj-dispatch", ), pytest.param( "packages.config", dedent("""\ """), "manifest", 1, "NUnit", id="packages-config-dispatch", ), pytest.param( "Directory.Packages.props", dedent("""\ """), "manifest", 1, "Serilog", id="directory-packages-props-dispatch", ), pytest.param( "packages.lock.json", dedent("""\ { "version": 1, "dependencies": { ".NETCoreApp,Version=v8.0": { "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" } } } } """), "lockfile", 1, "Newtonsoft.Json", id="packages-lock-json-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: DotNetEcosystemHandler, filename: str, content: str, expected_type: str, expected_count: int, expected_first_name: str | None, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert parsed.manifest_type == expected_type assert parsed.parser_name == "nuget" assert len(parsed.dependencies) == expected_count if expected_first_name is not None: assert parsed.dependencies[0].name == expected_first_name # -------------------------------------------------------- # packages.lock.json parsing # -------------------------------------------------------- def test_parse_lock_json_multiple_frameworks(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ { "version": 1, "dependencies": { ".NETCoreApp,Version=v6.0": { "PkgA": { "type": "Direct", "resolved": "1.0.0" } }, ".NETCoreApp,Version=v8.0": { "PkgB": { "type": "Transitive", "resolved": "2.0.0" } } } } """) parsed = handler.parse_manifest_content("packages.lock.json", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["PkgA", "PkgB"] assert all(d.is_lockfile for d in parsed.dependencies) def test_parse_lock_json_deduplicates_across_frameworks(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ { "version": 1, "dependencies": { ".NETCoreApp,Version=v6.0": { "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" } }, ".NETCoreApp,Version=v8.0": { "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" } } } } """) parsed = handler.parse_manifest_content("packages.lock.json", content) assert len(parsed.dependencies) == 1 def test_parse_lock_json_case_insensitive_dedup(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ { "version": 1, "dependencies": { ".NETCoreApp,Version=v8.0": { "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" }, "newtonsoft.json": { "type": "Transitive", "resolved": "13.0.3" } } } } """) parsed = handler.parse_manifest_content("packages.lock.json", content) assert len(parsed.dependencies) == 1 @pytest.mark.parametrize( "content, expected_count", [ pytest.param("not json {{{", 0, id="invalid-json"), pytest.param("[1, 2, 3]", 0, id="json-array-not-dict"), pytest.param('{"version": 1}', 0, id="no-dependencies-key"), pytest.param('{"version": 1, "dependencies": "not-dict"}', 0, id="dependencies-not-dict"), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": "not-dict"}}', 0, id="framework-not-dict", ), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": {"Pkg": "not-dict"}}}', 0, id="package-entry-not-dict", ), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": {"Pkg": {"resolved": ""}}}}', 0, id="empty-resolved-version", ), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": {"Pkg": {"resolved": 123}}}}', 0, id="resolved-not-string", ), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": {"": {"resolved": "1.0.0"}}}}', 0, id="empty-package-name", ), pytest.param( '{"version": 1, "dependencies": {".NETCoreApp": {" ": {"resolved": "1.0.0"}}}}', 0, id="whitespace-only-package-name", ), ], ) def test_parse_lock_json_negative_cases( handler: DotNetEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("packages.lock.json", content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # *.csproj parsing # -------------------------------------------------------- def test_parse_csproj_multiple_packages(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["Newtonsoft.Json", "Serilog"] assert all(not d.is_lockfile for d in parsed.dependencies) def test_parse_csproj_version_as_child_element(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ 13.0.1 """) parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].version == "13.0.1" def test_parse_csproj_deduplicates(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == 1 @pytest.mark.parametrize( "content, expected_count, expected_name, expected_version, expected_requirement", [ pytest.param( '', 1, "Pkg", None, None, id="no-version-managed-externally", ), pytest.param( '', 1, "Pkg", None, ">=1.0 <2.0", id="range-version", ), pytest.param( '', 1, "Pkg", None, "13.0.*", id="floating-version", ), pytest.param("not xml <<<", 0, None, None, None, id="invalid-xml"), pytest.param("", 0, None, None, None, id="empty-project"), pytest.param( '', 0, None, None, None, id="empty-include-skipped", ), ], ) def test_parse_csproj_edge_cases( handler: DotNetEcosystemHandler, content: str, expected_count: int, expected_name: str | None, expected_version: str | None, expected_requirement: str | None, ) -> None: parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == expected_count if expected_count > 0: dep = parsed.dependencies[0] assert dep.name == expected_name assert dep.version == expected_version assert dep.requirement == expected_requirement # -------------------------------------------------------- # packages.config parsing # -------------------------------------------------------- def test_parse_packages_config_multiple(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("packages.config", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["NUnit", "Newtonsoft.Json"] assert all(not d.is_lockfile for d in parsed.dependencies) def test_parse_packages_config_deduplicates(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("packages.config", content) assert len(parsed.dependencies) == 1 def test_parse_packages_config_capital_id_attribute(handler: DotNetEcosystemHandler) -> None: """packages.config may use 'Id'/'Version' instead of 'id'/'version'.""" content = dedent("""\ """) parsed = handler.parse_manifest_content("packages.config", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Newtonsoft.Json" assert parsed.dependencies[0].version == "13.0.1" @pytest.mark.parametrize( "content, expected_count", [ pytest.param("not xml <<<", 0, id="invalid-xml"), pytest.param("", 0, id="empty-packages"), pytest.param( '', 0, id="empty-id", ), pytest.param( '', 0, id="empty-version", ), pytest.param( '', 0, id="missing-version", ), ], ) def test_parse_packages_config_negative_cases( handler: DotNetEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("packages.config", content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # Directory.Packages.props parsing # -------------------------------------------------------- def test_parse_directory_packages_props_multiple(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("Directory.Packages.props", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["Newtonsoft.Json", "Serilog"] def test_parse_directory_packages_props_deduplicates(handler: DotNetEcosystemHandler) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("Directory.Packages.props", content) assert len(parsed.dependencies) == 1 def test_parse_directory_packages_props_lowercase_include( handler: DotNetEcosystemHandler, ) -> None: content = dedent("""\ """) parsed = handler.parse_manifest_content("Directory.Packages.props", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Serilog" @pytest.mark.parametrize( "content, expected_count", [ pytest.param("not xml <<<", 0, id="invalid-xml-string-path"), pytest.param( '', 0, id="empty-include-skipped", ), ], ) def test_parse_directory_packages_props_negative_cases( handler: DotNetEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("Directory.Packages.props", content) assert len(parsed.dependencies) == expected_count assert parsed.manifest_type == "manifest" # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("13.0.1", "13.0.1", id="three-part-unchanged"), pytest.param("1.2", "1.2.0", id="two-part-padded"), pytest.param("3", "3.0.0", id="single-part-padded"), pytest.param("4.7.0.2", "4.7.2", id="four-part-coerced"), pytest.param("6.0.1.3", "6.0.10003", id="four-part-weighted"), pytest.param(" 13.0.1 ", "13.0.1", id="whitespace-stripped"), pytest.param("v1.0.0", "1.0.0", id="strip-v-prefix"), pytest.param("1.0.0-preview.1", "1.0.0-preview.1", id="pre-release-preserved"), pytest.param("1.0.0-rc1", "1.0.0-rc1", id="rc-preserved"), ], ) def test_normalize_version( handler: DotNetEcosystemHandler, version: str, expected: str, ) -> None: assert handler._normalize_version(version) == expected @pytest.mark.parametrize( "version", [ pytest.param("not-a-version", id="alpha-only"), pytest.param("", id="empty-string"), pytest.param(" ", id="whitespace-only"), pytest.param("vvv", id="only-v-prefix"), ], ) def test_normalize_version_raises_on_invalid( handler: DotNetEcosystemHandler, version: str, ) -> None: with pytest.raises(ValueError): handler._normalize_version(version) # -------------------------------------------------------- # _normalize_vuln_range # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("", "", id="empty-string"), pytest.param(" ", "", id="whitespace-only"), pytest.param(None, "", id="none-input"), pytest.param("< 1.7.0", "<1.7.0", id="lt-spacing-collapsed"), pytest.param("<= 0.5.6", "<=0.5.6", id="lte-spacing-collapsed"), pytest.param(">= 1.0.0, < 1.5.1", ">=1.0.0 <1.5.1", id="comma-to-space-collapsed"), pytest.param("< 4.7.0.2", "<4.7.2", id="four-part-in-range-coerced"), pytest.param("<= 6.0.1.3", "<=6.0.10003", id="four-part-weighted-in-range"), pytest.param("1.2.3", "==1.2.3", id="bare-exact-version"), pytest.param("= 1.2.3", "==1.2.3", id="single-equals-normalized"), pytest.param("== 1.2.3", "==1.2.3", id="double-equals-spacing-collapsed"), pytest.param("= 4.7.0.2", "==4.7.2", id="single-equals-four-part-coerced"), ], ) def test_normalize_vuln_range( handler: DotNetEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- def test_build_spec_returns_npm_spec(handler: DotNetEcosystemHandler) -> None: spec = handler._build_spec("< 1.7.0") assert isinstance(spec, semantic_version.NpmSpec) assert spec.match(semantic_version.Version("1.6.9")) is True assert spec.match(semantic_version.Version("1.7.0")) is False # -------------------------------------------------------- # _normalize_nuget_specifier # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected_version, expected_requirement", [ pytest.param("13.0.1", "13.0.1", None, id="exact-version"), pytest.param("4.7.0.2", "4.7.0.2", None, id="four-part-exact"), pytest.param("1.0.0-preview.1", "1.0.0-preview.1", None, id="pre-release-exact"), pytest.param("[1.0,2.0)", None, ">=1.0 <2.0", id="inclusive-exclusive-range"), pytest.param("(1.0,2.0]", None, ">1.0 <=2.0", id="exclusive-inclusive-range"), pytest.param("[1.0]", None, "==1.0", id="exact-bracket-range"), pytest.param("(,2.0)", None, "<2.0", id="upper-only-range"), pytest.param("[1.0,)", None, ">=1.0", id="lower-only-range"), pytest.param("13.0.*", None, "13.0.*", id="floating-version"), pytest.param("", None, None, id="empty-string"), pytest.param(None, None, None, id="none-input"), pytest.param(" ", None, None, id="whitespace-only"), pytest.param("[]", None, None, id="empty-brackets"), pytest.param("[,]", None, None, id="both-empty-in-brackets"), pytest.param( "latest-stable", None, "latest-stable", id="non-version-non-range-as-requirement" ), ], ) def test_normalize_nuget_specifier( spec: str | None, expected_version: str | None, expected_requirement: str | None, ) -> None: version, requirement = DotNetEcosystemHandler._normalize_nuget_specifier(spec) assert version == expected_version assert requirement == expected_requirement # -------------------------------------------------------- # _nuget_range_to_npm # -------------------------------------------------------- @pytest.mark.parametrize( "nuget_range, expected", [ pytest.param("[1.0,2.0)", ">=1.0 <2.0", id="inclusive-exclusive"), pytest.param("(1.0,2.0]", ">1.0 <=2.0", id="exclusive-inclusive"), pytest.param("[1.0,2.0]", ">=1.0 <=2.0", id="inclusive-inclusive"), pytest.param("(1.0,2.0)", ">1.0 <2.0", id="exclusive-exclusive"), pytest.param("[1.0]", "==1.0", id="exact"), pytest.param("(,2.0)", "<2.0", id="upper-only"), pytest.param("(,2.0]", "<=2.0", id="upper-inclusive"), pytest.param("[1.0,)", ">=1.0", id="lower-only"), pytest.param("(1.0,)", ">1.0", id="lower-exclusive"), pytest.param("", None, id="empty-string"), pytest.param("x", None, id="too-short"), pytest.param("ab", None, id="no-brackets"), pytest.param("[,]", None, id="both-empty"), pytest.param("[]", None, id="empty-exact"), ], ) def test_nuget_range_to_npm( nuget_range: str, expected: str | None, ) -> None: assert DotNetEcosystemHandler._nuget_range_to_npm(nuget_range) == expected # -------------------------------------------------------- # _coerce_version_token # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ # Standard semver pytest.param("1.2.3", "1.2.3", id="three-part-unchanged"), pytest.param("1.2", "1.2.0", id="two-part-padded"), pytest.param("3", "3.0.0", id="single-part-padded"), # Four-part weighted encoding pytest.param("4.7.0.2", "4.7.2", id="four-part-zero-third"), pytest.param("6.0.1.3", "6.0.10003", id="four-part-weighted"), pytest.param("1.2.3.4", "1.2.30004", id="four-part-standard"), # Hyphen-separated pre-release (preserved) pytest.param("1.0.0-preview.1", "1.0.0-preview.1", id="hyphen-preview"), pytest.param("1.0.0-rc1", "1.0.0-rc1", id="hyphen-rc"), # Dot-separated pre-release (converted to hyphen) pytest.param("1.0.0.preview1", "1.0.0-preview1", id="dot-preview-to-hyphen"), pytest.param("2.0.0.rc1", "2.0.0-rc1", id="dot-rc-to-hyphen"), pytest.param("3.0.0.alpha2", "3.0.0-alpha2", id="dot-alpha-to-hyphen"), pytest.param("4.0.0.beta3", "4.0.0-beta3", id="dot-beta-to-hyphen"), # Dot-separated non-pre-release qualifiers (stripped) pytest.param("1.0.0.RELEASE", "1.0.0", id="dot-release-stripped"), pytest.param("2.0.0.FINAL", "2.0.0", id="dot-final-stripped"), pytest.param("1.2.3.unknownqualifier", "1.2.3", id="unknown-dot-qualifier-stripped"), # Non-numeric input pytest.param("notaversion", "0.0.0", id="all-alpha"), ], ) def test_coerce_version_token( version: str, expected: str, ) -> None: assert DotNetEcosystemHandler._coerce_version_token(version) == expected @pytest.mark.parametrize( "lower, higher", [ pytest.param("1.2.3.4", "1.2.3.5", id="fourth-segment-differs"), pytest.param("1.2.3.9", "1.2.4.0", id="third-segment-bump"), pytest.param("4.7.0.2", "4.7.1.0", id="real-world-ordering"), ], ) def test_coerce_version_token_preserves_ordering( lower: str, higher: str, ) -> None: coerced_lower = DotNetEcosystemHandler._coerce_version_token(lower) coerced_higher = DotNetEcosystemHandler._coerce_version_token(higher) v_lower = semantic_version.Version.coerce(coerced_lower) v_higher = semantic_version.Version.coerce(coerced_higher) assert ( v_lower < v_higher ), f"Ordering broken: {lower}→{coerced_lower} should be < {higher}→{coerced_higher}" # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep_kwargs, alert_kwargs, expected_is_vuln, expected_confidence", [ # ---- No/empty vuln_range -> early return ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.1", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="", manifest_path="packages.lock.json", ), True, "low", id="no-vuln-range-early-return", ), pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.1", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range=" ", manifest_path="packages.lock.json", ), True, "low", id="normalized-empty-range", ), # ---- Invalid version -> conservative ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="not-a-version", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="packages.lock.json", ), True, "low", id="invalid-version-returns-true-low", ), # ---- Lockfile exact match ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.1", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="packages.lock.json", ), True, "high", id="lockfile-vulnerable", ), pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.2", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="packages.lock.json", ), False, "high", id="lockfile-not-vulnerable", ), # ---- Manifest -> medium confidence ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.1", manifest_path="MyApp.csproj", is_lockfile=False, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="MyApp.csproj", ), True, "medium", id="manifest-medium-confidence", ), # ---- Combined range ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="12.0.5", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range=">= 12.0.0, < 13.0.0", manifest_path="packages.lock.json", ), True, "high", id="lockfile-combined-range-vulnerable", ), pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.0", manifest_path="packages.lock.json", is_lockfile=True, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range=">= 12.0.0, < 13.0.0", manifest_path="packages.lock.json", ), False, "high", id="lockfile-combined-range-not-vulnerable", ), # ---- Requirement paths ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version=None, requirement=">=12.0.0", manifest_path="MyApp.csproj", is_lockfile=False, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range=">=12.0.0, <12.5.0", manifest_path="MyApp.csproj", ), True, "low", id="requirement-overlap-true", ), pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version=None, requirement=" ", manifest_path="MyApp.csproj", is_lockfile=False, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="MyApp.csproj", ), True, "low", id="requirement-normalizes-empty", ), # ---- No version, no requirement -> conservative ---- pytest.param( dict( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version=None, requirement=None, manifest_path="MyApp.csproj", is_lockfile=False, ), dict( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="< 13.0.2", manifest_path="MyApp.csproj", ), True, "low", id="no-version-no-requirement-conservative", ), ], ) def test_is_vulnerable_parametrized( handler: DotNetEcosystemHandler, dep_kwargs: dict, alert_kwargs: dict, expected_is_vuln: bool, expected_confidence: str, ) -> None: dep = make_dependency(**dep_kwargs) alert = make_alert(**alert_kwargs) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_is_vuln assert confidence == expected_confidence def test_is_vulnerable_unexpected_exception_returns_true_low( handler: DotNetEcosystemHandler, ) -> None: dep = make_dependency( ecosystem=Ecosystem.NUGET, name="Newtonsoft.Json", version="13.0.1", manifest_path="packages.lock.json", is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.NUGET, package_name="Newtonsoft.Json", vulnerable_range="<13.0.2", manifest_path="packages.lock.json", ) with patch.object(handler, "_normalize_vuln_range", side_effect=RuntimeError("boom")): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" # -------------------------------------------------------- # is_vulnerable — four-part version regressions # -------------------------------------------------------- @pytest.mark.parametrize( "dep_version, vuln_range, expected_vuln", [ pytest.param("4.7.0.1", "<= 4.7.0.2", True, id="four-part-below-inclusive-upper"), pytest.param("4.7.0.3", "<= 4.7.0.2", False, id="four-part-above-inclusive-upper"), pytest.param("6.0.1.2", "< 6.0.1.3", True, id="four-part-below-exclusive-upper"), pytest.param("6.0.1.3", "< 6.0.1.3", False, id="four-part-at-exclusive-upper"), pytest.param("1.2.3.4", ">= 1.0.0, < 1.2.3.5", True, id="four-part-in-range"), pytest.param("1.2.3.5", ">= 1.0.0, < 1.2.3.5", False, id="four-part-at-boundary"), ], ) def test_is_vulnerable_four_part_versions( handler: DotNetEcosystemHandler, dep_version: str, vuln_range: str, expected_vuln: bool, ) -> None: dep = make_dependency( name="System.Net.Http", version=dep_version, ecosystem=Ecosystem.NUGET, manifest_path="packages.lock.json", is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.NUGET, package_name="System.Net.Http", vulnerable_range=vuln_range, ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == "high" # -------------------------------------------------------- # Base helper fallbacks # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: DotNetEcosystemHandler) -> None: with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._version_in_range(semantic_version.Version("1.0.0"), "<2.0.0") is True def test_ranges_overlap_exception_returns_true(handler: DotNetEcosystemHandler) -> None: with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap("<1.0.0", ">=2.0.0") is True # -------------------------------------------------------- # XML safety — XXE protection (reuses defusedxml) # -------------------------------------------------------- @pytest.mark.parametrize( "malicious_xml", [ pytest.param( '' "' " " "]>" "&b;", id="entity-expansion", ), pytest.param( '' "' "]>" "&xxe;", id="external-entity", ), ], ) def test_safe_parse_xml_rejects_malicious_xml(malicious_xml: str) -> None: assert DotNetEcosystemHandler._safe_parse_xml(malicious_xml) is None # -------------------------------------------------------- # Namespace-aware XML parsing # -------------------------------------------------------- _MSBUILD_NS = "http://schemas.microsoft.com/developer/msbuild/2003" def test_parse_csproj_with_msbuild_namespace(handler: DotNetEcosystemHandler) -> None: """Legacy .csproj files use the MSBuild namespace on all elements.""" content = dedent(f"""\ """) parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["Newtonsoft.Json", "Serilog"] def test_parse_csproj_with_namespace_version_child_element( handler: DotNetEcosystemHandler, ) -> None: """Version as child element inside a namespaced .csproj.""" content = dedent(f"""\ 13.0.1 """) parsed = handler.parse_manifest_content("MyApp.csproj", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].version == "13.0.1" def test_parse_directory_packages_props_with_namespace( handler: DotNetEcosystemHandler, ) -> None: content = dedent(f"""\ """) parsed = handler.parse_manifest_content("Directory.Packages.props", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Serilog" def test_parse_packages_config_with_namespace(handler: DotNetEcosystemHandler) -> None: """packages.config with a namespace (unusual but should still work).""" content = dedent(f"""\ """) parsed = handler.parse_manifest_content("packages.config", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "Newtonsoft.Json" # -------------------------------------------------------- # _strip_namespace / _iter_tag / _find_child helpers # -------------------------------------------------------- @pytest.mark.parametrize( "tag, expected", [ pytest.param("ItemGroup", "ItemGroup", id="no-namespace"), pytest.param( f"{{{_MSBUILD_NS}}}ItemGroup", "ItemGroup", id="msbuild-namespace", ), pytest.param( "{http://other.ns/}ItemGroup", "ItemGroup", id="arbitrary-namespace", ), ], ) def test_strip_namespace(tag: str, expected: str) -> None: assert DotNetEcosystemHandler._strip_namespace(tag) == expected def test_iter_tag_finds_namespaced_and_bare_elements() -> None: """_iter_tag matches elements regardless of namespace.""" import defusedxml.ElementTree as ET xml = dedent(f"""\ """) root = ET.fromstring(xml) refs = list(DotNetEcosystemHandler._iter_tag(root, "PackageReference")) assert len(refs) == 1 assert refs[0].get("Include") == "Pkg1" def test_find_child_returns_none_when_missing() -> None: import defusedxml.ElementTree as ET root = ET.fromstring("") assert DotNetEcosystemHandler._find_child(root, "Version") is None