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