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.go import GoEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> GoEcosystemHandler: return GoEcosystemHandler() # -------------------------------------------------------- # Handler identity # -------------------------------------------------------- def test_handler_id_and_ecosystem(handler: GoEcosystemHandler) -> None: assert handler.id == "go" assert handler.ecosystem == Ecosystem.GO # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: GoEcosystemHandler) -> None: assert handler.manifest_names == {"go.mod"} assert handler.lockfile_names == {"go.sum"} assert handler.manifest_globs == set() @pytest.mark.parametrize( "path, expected", [ ("go.mod", True), ("path/to/go.mod", True), ("go.sum", True), ("nope.txt", False), ], ) def test_supports_manifest(handler: GoEcosystemHandler, path: str, expected: bool) -> None: assert handler.supports_manifest(path) is expected def test_is_lockfile(handler: GoEcosystemHandler) -> None: assert handler.is_lockfile("go.sum") is True assert handler.is_lockfile("go.mod") is False def test_is_manifest(handler: GoEcosystemHandler) -> None: assert handler.is_manifest("go.mod") is True assert handler.is_manifest("go.sum") is False assert handler.is_manifest("nope.txt") is False # -------------------------------------------------------- # parse_manifest reads file and dispatches # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: GoEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "go.mod" manifest.write_text( dedent("""\ module github.com/example/project go 1.21 require github.com/foo/bar v1.2.3 """), encoding="utf-8", ) parsed = handler.parse_manifest("go.mod", manifest) assert parsed.manifest_type == "manifest" assert parsed.parser_name == "go" assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.ecosystem == Ecosystem.GO assert dep.name == "github.com/foo/bar" assert dep.version == "1.2.3" assert dep.is_lockfile is False # -------------------------------------------------------- # parse_manifest_content dispatch # -------------------------------------------------------- @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_first_name", [ pytest.param("unknown.xyz", "x", "manifest", 0, None, id="unknown-file-returns-empty"), pytest.param( "go.mod", "require github.com/foo/bar v1.2.3\n", "manifest", 1, "github.com/foo/bar", id="go-mod-dispatch", ), pytest.param( "go.sum", "github.com/foo/bar v1.2.3 h1:abc123=\n", "lockfile", 1, "github.com/foo/bar", id="go-sum-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: GoEcosystemHandler, 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 == "go" assert len(parsed.dependencies) == expected_count if expected_first_name is not None: assert parsed.dependencies[0].name == expected_first_name # -------------------------------------------------------- # go.sum parsing # -------------------------------------------------------- def test_parse_go_sum_deduplicates_mod_entries(handler: GoEcosystemHandler) -> None: """go.sum has two lines per module (module + go.mod); should deduplicate.""" content = dedent("""\ github.com/foo/bar v1.2.3 h1:abc123= github.com/foo/bar v1.2.3/go.mod h1:def456= """) parsed = handler.parse_manifest_content("go.sum", content) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "github.com/foo/bar" assert dep.version == "1.2.3" assert dep.is_lockfile is True def test_parse_go_sum_multiple_modules(handler: GoEcosystemHandler) -> None: content = dedent("""\ github.com/foo/bar v1.2.3 h1:abc= github.com/baz/qux v0.5.0 h1:xyz= """) parsed = handler.parse_manifest_content("go.sum", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["github.com/baz/qux", "github.com/foo/bar"] def test_parse_go_sum_multiple_versions_same_module(handler: GoEcosystemHandler) -> None: content = dedent("""\ github.com/foo/bar v1.2.3 h1:abc= github.com/foo/bar v1.3.0 h1:def= """) parsed = handler.parse_manifest_content("go.sum", content) assert len(parsed.dependencies) == 2 versions = sorted(d.version for d in parsed.dependencies if d.version) assert versions == ["1.2.3", "1.3.0"] @pytest.mark.parametrize( "content, expected_count", [ pytest.param( "github.com/foo/bar v3.0.0+incompatible h1:abc=\n", 1, id="incompatible-suffix-stripped", ), pytest.param( "github.com/foo/bar v0.0.0-20230420150708-abcd1234ef56 h1:abc=\n", 1, id="pseudo-version-parsed", ), pytest.param( "\n// comment\ngithub.com/foo/bar v1.2.3 h1:abc=\n", 1, id="skips-empty-and-comment-lines", ), pytest.param( "github.com/foo/bar v1.2.3 h1:abc=\nonly-two-parts\nshort\n", 1, id="skips-malformed-lines", ), pytest.param( "github.com/foo/bar /go.mod h1:abc=\ngithub.com/baz/qux v1.0.0 h1:xyz=\n", 1, id="skips-entry-where-version-is-only-go-mod", ), ], ) def test_parse_go_sum_edge_cases( handler: GoEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("go.sum", content) assert len(parsed.dependencies) == expected_count def test_parse_go_sum_incompatible_version_value(handler: GoEcosystemHandler) -> None: content = "github.com/foo/bar v3.0.0+incompatible h1:abc=\n" parsed = handler.parse_manifest_content("go.sum", content) assert parsed.dependencies[0].version == "3.0.0" # -------------------------------------------------------- # go.mod parsing # -------------------------------------------------------- def test_parse_go_mod_inline_require(handler: GoEcosystemHandler) -> None: content = dedent("""\ module github.com/example/project go 1.21 require github.com/foo/bar v1.2.3 """) parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "github.com/foo/bar" assert dep.version == "1.2.3" assert dep.is_lockfile is False def test_parse_go_mod_block_require(handler: GoEcosystemHandler) -> None: content = dedent("""\ require ( github.com/foo/bar v1.2.3 github.com/baz/qux v0.5.0 // indirect ) """) parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["github.com/baz/qux", "github.com/foo/bar"] def test_parse_go_mod_incompatible(handler: GoEcosystemHandler) -> None: content = "require github.com/foo/bar v3.0.0+incompatible\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].version == "3.0.0" @pytest.mark.parametrize( "content, expected_count", [ pytest.param("", 0, id="empty-content"), pytest.param( "require (\n singletoken\n)\n", 0, id="require-block-line-with-single-token-skipped", ), pytest.param( dedent("""\ module github.com/example/project go 1.21 exclude github.com/old/thing v0.1.0 require github.com/foo/bar v1.2.3 """), 1, id="skips-non-require-directives", ), ], ) def test_parse_go_mod_edge_cases( handler: GoEcosystemHandler, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == expected_count def test_parse_go_mod_require_deduplicates(handler: GoEcosystemHandler) -> None: """Same module+version via inline require and block require — deduplicated.""" content = dedent("""\ require github.com/foo/bar v1.2.3 require ( github.com/foo/bar v1.2.3 ) """) parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 # -------------------------------------------------------- # go.mod replace directives # -------------------------------------------------------- def test_parse_go_mod_replace_inline(handler: GoEcosystemHandler) -> None: content = dedent("""\ require github.com/foo/bar v1.2.3 replace github.com/foo/bar => github.com/fork/bar v1.2.4 """) parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 2 names = {d.name: d.version for d in parsed.dependencies} assert names["github.com/foo/bar"] == "1.2.3" assert names["github.com/fork/bar"] == "1.2.4" def test_parse_go_mod_replace_block(handler: GoEcosystemHandler) -> None: content = dedent("""\ replace ( github.com/foo/bar => github.com/fork/bar v1.2.4 ) """) parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "github.com/fork/bar" assert parsed.dependencies[0].version == "1.2.4" @pytest.mark.parametrize( "content", [ pytest.param( "replace (\n github.com/foo/bar v1.2.3\n)\n", id="replace-without-arrow", ), pytest.param( "replace github.com/foo/bar => ./local\n", id="replace-local-path-no-version", ), pytest.param( "replace (\n github.com/foo/bar => singletoken\n)\n", id="replace-target-missing-version", ), ], ) def test_parse_go_mod_replace_skipped(handler: GoEcosystemHandler, content: str) -> None: parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 0 def test_parse_go_mod_replace_deduplicates_with_require(handler: GoEcosystemHandler) -> None: """Replace target matches existing require — same (module, version) deduplicated.""" content = dedent("""\ require github.com/fork/bar v1.2.4 replace github.com/foo/bar => github.com/fork/bar v1.2.4 """) parsed = handler.parse_manifest_content("go.mod", content) fork_deps = [d for d in parsed.dependencies if d.name == "github.com/fork/bar"] assert len(fork_deps) == 1 def test_parse_go_mod_tab_separated_inline_require(handler: GoEcosystemHandler) -> None: """go.mod may use tabs between 'require' and the module path.""" content = "require\tgithub.com/foo/bar\tv1.2.3\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "github.com/foo/bar" assert parsed.dependencies[0].version == "1.2.3" def test_parse_go_mod_tab_separated_block_require(handler: GoEcosystemHandler) -> None: """go.mod may use tabs around the opening paren of a require block.""" content = "require\t(\n\tgithub.com/foo/bar\tv1.2.3\n\tgithub.com/baz/qux\tv0.5.0\n)\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 2 names = sorted(d.name for d in parsed.dependencies) assert names == ["github.com/baz/qux", "github.com/foo/bar"] def test_parse_go_mod_tab_separated_replace(handler: GoEcosystemHandler) -> None: """Replace directives with tabs.""" content = "replace\tgithub.com/foo/bar\t=>\tgithub.com/fork/bar\tv1.2.4\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "github.com/fork/bar" assert parsed.dependencies[0].version == "1.2.4" def test_parse_go_mod_tab_separated_replace_block(handler: GoEcosystemHandler) -> None: """Replace block with tab before opening paren.""" content = "replace\t(\n\tgithub.com/foo/bar => github.com/fork/bar v1.2.4\n)\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "github.com/fork/bar" def test_parse_go_mod_no_space_require_block(handler: GoEcosystemHandler) -> None: """require( with no whitespace before paren.""" content = "require(\n\tgithub.com/foo/bar v1.2.3\n)\n" parsed = handler.parse_manifest_content("go.mod", content) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "github.com/foo/bar" # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "raw, expected", [ pytest.param("v1.2.3", "1.2.3", id="strips-leading-v"), pytest.param("V1.2.3", "1.2.3", id="strips-capital-v"), pytest.param("1.2.3", "1.2.3", id="no-prefix-unchanged"), pytest.param(" v1.2.3 ", "1.2.3", id="trims-whitespace"), pytest.param("v3.0.0+incompatible", "3.0.0", id="strips-incompatible"), pytest.param("v1.2", "1.2.0", id="pads-two-part"), pytest.param("", "", id="empty"), pytest.param( "v0.0.0-20230420150708-abcd1234ef56", "0.0.0-20230420150708-abcd1234ef56", id="pseudo-version-preserved", ), pytest.param( "v1.2.3-pre.0.20230420150708-abcd1234ef56", "1.2.3-pre.0.20230420150708-abcd1234ef56", id="pseudo-version-with-pre-release-prefix", ), ], ) def test_normalize_version(handler: GoEcosystemHandler, raw: str, expected: str) -> None: assert handler._normalize_version(raw) == expected # -------------------------------------------------------- # _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(">= v1.0.0, < v1.5.1", ">=1.0.0 <1.5.1", id="strips-v-from-range"), pytest.param(">= 1.0.0+incompatible", ">=1.0.0", id="strips-incompatible-from-range"), ], ) def test_normalize_vuln_range( handler: GoEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- def test_build_spec_returns_npm_spec(handler: GoEcosystemHandler) -> 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 # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep_kwargs, alert_kwargs, expected_is_vuln, expected_confidence", [ # ---- No vuln_range -> early return ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.2.3", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="", manifest_path="go.sum", ), True, "low", id="no-vuln-range-early-return", ), # ---- normalized == "" (whitespace-only range) ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.2.3", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range=" ", manifest_path="go.sum", ), True, "low", id="normalized-empty-range-returns-true-low", ), # ---- ValueError in Version.coerce ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="not-a-version", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.sum", ), True, "low", id="version-coerce-value-error-returns-true-low", ), # ---- Lockfile exact version — vulnerable ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.6.9", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.sum", ), True, "high", id="lockfile-vulnerable-lt", ), # ---- Lockfile exact version — not vulnerable at boundary ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.7.0", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.sum", ), False, "high", id="lockfile-not-vulnerable-at-boundary", ), # ---- Lockfile lte — at boundary ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="0.5.6", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="<= 0.5.6", manifest_path="go.sum", ), True, "high", id="lockfile-vulnerable-lte-at-boundary", ), # ---- Lockfile lte — above boundary ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="0.5.7", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="<= 0.5.6", manifest_path="go.sum", ), False, "high", id="lockfile-not-vulnerable-above-boundary", ), # ---- Combined range ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.3.0", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range=">= 1.0.0, < 1.5.1", manifest_path="go.sum", ), True, "high", id="lockfile-combined-range-vulnerable", ), pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.5.1", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range=">= 1.0.0, < 1.5.1", manifest_path="go.sum", ), False, "high", id="lockfile-combined-range-not-vulnerable", ), # ---- go.mod (not lockfile) -> confidence medium ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.6.9", manifest_path="go.mod", is_lockfile=False, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.mod", ), True, "medium", id="manifest-confidence-medium", ), # ---- Requirement overlap ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version=None, requirement="< 1.7.0", manifest_path="go.mod", is_lockfile=False, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="<= 1.6.9", manifest_path="go.mod", ), True, "low", id="requirement-overlap-true", ), # ---- Requirement normalizes to empty ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version=None, requirement=" ", manifest_path="go.mod", is_lockfile=False, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.mod", ), True, "low", id="requirement-normalizes-empty", ), # ---- No version, no requirement -> conservative ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version=None, requirement=None, manifest_path="go.mod", is_lockfile=False, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 1.7.0", manifest_path="go.mod", ), True, "low", id="no-version-no-requirement-conservative", ), # ---- +incompatible against range ---- pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="v3.0.0+incompatible", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 3.1.0", manifest_path="go.sum", ), True, "high", id="incompatible-version-vulnerable", ), pytest.param( dict( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="v3.1.0+incompatible", manifest_path="go.sum", is_lockfile=True, ), dict( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="< 3.1.0", manifest_path="go.sum", ), False, "high", id="incompatible-version-not-vulnerable", ), ], ) def test_is_vulnerable_parametrized( handler: GoEcosystemHandler, 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: GoEcosystemHandler, ) -> None: dep = make_dependency( ecosystem=Ecosystem.GO, name="github.com/foo/bar", version="1.2.3", manifest_path="go.sum", is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.GO, package_name="github.com/foo/bar", vulnerable_range="<1.7.0", manifest_path="go.sum", ) 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" # -------------------------------------------------------- # Base helper fallbacks # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: GoEcosystemHandler) -> 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: GoEcosystemHandler) -> None: with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap("<1.0.0", ">=2.0.0") is True