from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Dependency, Ecosystem, SecurityVulnerability from vuln_scan.ecosystems.node import NodeEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> NodeEcosystemHandler: return NodeEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: NodeEcosystemHandler) -> None: assert handler.manifest_names == {"package.json"} assert handler.lockfile_names == {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"} assert handler.manifest_globs == set() # -------------------------------------------------------- # parse_manifest / parse_manifest_content dispatch # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: NodeEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "package.json" manifest.write_text( '{"dependencies": {"express": "4.18.2"}}', encoding="utf-8", ) parsed = handler.parse_manifest("package.json", manifest) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "express" assert parsed.manifest_type == "manifest" assert parsed.parser_name == "npm" @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_name", [ pytest.param( "unknown.xyz", "x", "manifest", 0, None, id="unknown-file-returns-empty", ), pytest.param( "package.json", '{"dependencies": {"express": "^4.18.0"}}', "manifest", 1, "express", id="package-json-dispatch", ), pytest.param( "package-lock.json", '{"packages": {"node_modules/express": {"version": "4.18.2"}}}', "lockfile", 1, "express", id="package-lock-json-dispatch", ), pytest.param( "pnpm-lock.yaml", "packages:\n /express@4.18.2: {}\n", "lockfile", 1, "express", id="pnpm-lock-yaml-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: NodeEcosystemHandler, filename: str, content: str, expected_type: str, expected_count: int, expected_name: str | None, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert parsed.manifest_type == expected_type assert len(parsed.dependencies) == expected_count if expected_name is not None: assert parsed.dependencies[0].name == expected_name @pytest.mark.parametrize( "filename, content, expected_count", [ # ---- package.json ---- pytest.param( "package.json", "not valid json {{{", 0, id="package-json-invalid-json", ), pytest.param( "package.json", '["not", "a", "dict"]', 0, id="package-json-non-dict-top-level", ), pytest.param( "package.json", '{"dependencies": "not-a-dict"}', 0, id="package-json-non-dict-dependencies", ), pytest.param( "package.json", '{"dependencies": {"bad-pkg": 42}}', 0, id="package-json-non-string-version-spec", ), pytest.param( "package.json", '{"dependencies": {}}', 0, id="package-json-empty-dependencies", ), pytest.param( "package.json", "{}", 0, id="package-json-no-dependency-sections", ), # ---- package-lock.json ---- pytest.param( "package-lock.json", "not valid json {{{", 0, id="package-lock-invalid-json", ), pytest.param( "package-lock.json", '["not", "a", "dict"]', 0, id="package-lock-non-dict-top-level", ), pytest.param( "package-lock.json", '{"packages": {"": {"name": "root"}}}', 0, id="package-lock-v7-skips-root-entry", ), pytest.param( "package-lock.json", '{"packages": {"node_modules/pkg": "not-a-dict"}}', 0, id="package-lock-v7-non-dict-package-info", ), pytest.param( "package-lock.json", '{"packages": {"node_modules/pkg": {"version": 42}}}', 0, id="package-lock-v7-non-string-version", ), pytest.param( "package-lock.json", '{"packages": {"node_modules/pkg": {"version": ""}}}', 0, id="package-lock-v7-empty-version", ), pytest.param( "package-lock.json", '{"packages": {"": {"name": "root"}}, "dependencies": {"express": {"version": "4.18.2"}}}', 0, id="package-lock-v7-present-ignores-v1-dependencies", ), pytest.param( "package-lock.json", '{"dependencies": {"bad": "not-a-dict"}}', 0, id="package-lock-v1-non-dict-dependency-info", ), pytest.param( "package-lock.json", "{}", 0, id="package-lock-no-packages-or-dependencies", ), # ---- yarn.lock ---- pytest.param( "yarn.lock", "{{totally invalid content", 0, id="yarn-lock-invalid-content", ), # ---- pnpm-lock.yaml ---- pytest.param( "pnpm-lock.yaml", ": invalid: yaml: {{", 0, id="pnpm-lock-invalid-yaml", ), pytest.param( "pnpm-lock.yaml", "- not-a-dict\n", 0, id="pnpm-lock-non-dict-top-level", ), pytest.param( "pnpm-lock.yaml", "lockfileVersion: 6.0\n", 0, id="pnpm-lock-no-packages-section", ), ], ) def test_parse_manifest_content_negative_cases( handler: NodeEcosystemHandler, filename: str, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # package.json # -------------------------------------------------------- def test_parse_package_json_all_sections(handler: NodeEcosystemHandler) -> None: content = """{ "dependencies": {"express": "^4.18.0"}, "devDependencies": {"jest": "^29.0.0"}, "peerDependencies": {"react": ">=18"}, "optionalDependencies": {"fsevents": "~2.3.0"} }""" deps = handler._parse_package_json_content(content, "package.json") assert len(deps) == 4 assert {d.name for d in deps} == {"express", "jest", "react", "fsevents"} assert all(d.ecosystem == Ecosystem.NPM for d in deps) assert all(not d.is_lockfile for d in deps) # -------------------------------------------------------- # package-lock.json # -------------------------------------------------------- def test_parse_package_lock_v7_with_named_packages(handler: NodeEcosystemHandler) -> None: content = """{ "packages": { "": {"name": "my-app", "version": "1.0.0"}, "node_modules/express": {"version": "4.18.2"}, "node_modules/@babel/core": {"name": "@babel/core", "version": "7.24.0"} } }""" deps = handler._parse_package_lock_content(content, "package-lock.json") assert len(deps) == 2 assert {d.name for d in deps} == {"express", "@babel/core"} assert all(d.is_lockfile for d in deps) def test_parse_package_lock_v7_deduplicates(handler: NodeEcosystemHandler) -> None: content = """{ "packages": { "node_modules/lodash": {"version": "4.17.21"}, "node_modules/sub/node_modules/lodash": {"version": "4.17.21"} } }""" deps = handler._parse_package_lock_content(content, "package-lock.json") assert len(deps) == 1 assert deps[0].name == "lodash" def test_parse_package_lock_v1_with_nested_dependencies(handler: NodeEcosystemHandler) -> None: content = """{ "dependencies": { "express": { "version": "4.18.2", "dependencies": { "body-parser": {"version": "1.20.1"} } } } }""" deps = handler._parse_package_lock_content(content, "package-lock.json") assert len(deps) == 2 assert {d.name for d in deps} == {"express", "body-parser"} def test_parse_package_lock_v1_deduplicates(handler: NodeEcosystemHandler) -> None: content = """{ "dependencies": { "lodash": { "version": "4.17.21", "dependencies": { "lodash": {"version": "4.17.21"} } } } }""" deps = handler._parse_package_lock_content(content, "package-lock.json") assert len(deps) == 1 # -------------------------------------------------------- # yarn.lock (pyarn) # -------------------------------------------------------- def test_parse_yarn_lock_basic(handler: NodeEcosystemHandler) -> None: content = """# yarn lockfile v1 express@^4.18.0: version "4.18.2" resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz" integrity sha512-abc123 lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" integrity sha512-def456 """ deps = handler._parse_yarn_lock_content(content, "yarn.lock") assert len(deps) == 2 assert {d.name for d in deps} == {"express", "lodash"} assert all(d.is_lockfile for d in deps) def test_parse_yarn_lock_deduplicates(handler: NodeEcosystemHandler) -> None: content = """# yarn lockfile v1 lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" """ deps = handler._parse_yarn_lock_content(content, "yarn.lock") assert len(deps) == 1 assert deps[0].name == "lodash" assert deps[0].version == "4.17.21" def test_parse_yarn_lock_scoped_package(handler: NodeEcosystemHandler) -> None: content = """# yarn lockfile v1 "@babel/core@^7.24.0": version "7.24.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.0.tgz" """ deps = handler._parse_yarn_lock_content(content, "yarn.lock") assert len(deps) == 1 assert deps[0].name == "@babel/core" assert deps[0].version == "7.24.0" def test_parse_yarn_lock_skips_entries_missing_name_or_version( handler: NodeEcosystemHandler, ) -> None: """Covers line 291: entry with empty name or version is skipped.""" from unittest.mock import MagicMock mock_pkg_no_name = MagicMock() mock_pkg_no_name.name = "" mock_pkg_no_name.version = "1.0.0" mock_pkg_no_version = MagicMock() mock_pkg_no_version.name = "express" mock_pkg_no_version.version = "" mock_pkg_valid = MagicMock() mock_pkg_valid.name = "lodash" mock_pkg_valid.version = "4.17.21" mock_lockfile = MagicMock() mock_lockfile.packages.return_value = [mock_pkg_no_name, mock_pkg_no_version, mock_pkg_valid] with patch("vuln_scan.ecosystems.node.YarnLockfile.from_str", return_value=mock_lockfile): deps = handler._parse_yarn_lock_content("mocked", "yarn.lock") assert len(deps) == 1 assert deps[0].name == "lodash" # -------------------------------------------------------- # pnpm-lock.yaml # -------------------------------------------------------- @pytest.mark.parametrize( "key, expected_name, expected_version", [ pytest.param("/express@4.18.2", "express", "4.18.2", id="simple-package"), pytest.param("/@babel/core@7.24.0", "@babel/core", "7.24.0", id="scoped-package"), pytest.param("/lodash/4.17.21", "lodash", "4.17.21", id="slash-separator"), pytest.param("", None, None, id="empty-key"), pytest.param("/", None, None, id="only-slash"), pytest.param("/@scope-only", None, None, id="scoped-no-version"), pytest.param("express@4.18.2", "express", "4.18.2", id="v6-no-leading-slash"), pytest.param("@scope/pkg@1.0.0", "@scope/pkg", "1.0.0", id="v6-scoped"), pytest.param("no-at-no-slash", None, None, id="no-separator"), ], ) def test_parse_pnpm_package_key( key: str, expected_name: str | None, expected_version: str | None, ) -> None: name, version = NodeEcosystemHandler._parse_pnpm_package_key(key) assert name == expected_name assert version == expected_version def test_parse_pnpm_lock_content_multiple_packages(handler: NodeEcosystemHandler) -> None: content = """lockfileVersion: '6.0' packages: /express@4.18.2: {} /@babel/core@7.24.0: {} /lodash/4.17.21: {} """ deps = handler._parse_pnpm_lock_content(content, "pnpm-lock.yaml") assert len(deps) == 3 assert {d.name for d in deps} == {"express", "@babel/core", "lodash"} assert all(d.is_lockfile for d in deps) def test_parse_pnpm_lock_content_deduplicates(handler: NodeEcosystemHandler) -> None: content = """lockfileVersion: '6.0' packages: /lodash@4.17.21: {} /lodash/4.17.21: {} """ deps = handler._parse_pnpm_lock_content(content, "pnpm-lock.yaml") assert len(deps) == 1 assert deps[0].name == "lodash" assert deps[0].version == "4.17.21" # -------------------------------------------------------- # _safe_json_load # -------------------------------------------------------- @pytest.mark.parametrize( "content, expected", [ pytest.param('{"key": "value"}', {"key": "value"}, id="valid-dict"), pytest.param("invalid json {{{", None, id="invalid-json"), pytest.param('["a", "b"]', None, id="non-dict-json"), ], ) def test_safe_json_load( content: str, expected: dict[str, object] | None, ) -> None: assert NodeEcosystemHandler._safe_json_load(content) == expected # -------------------------------------------------------- # _normalize_npm_specifier # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected_version, expected_requirement", [ pytest.param("4.18.2", "4.18.2", None, id="exact-version"), pytest.param("1.0", "1.0", None, id="two-part-version"), pytest.param("^4.18.0", None, "^4.18.0", id="caret-range"), pytest.param("~4.18.0", None, "~4.18.0", id="tilde-range"), pytest.param(">=4.0.0", None, ">=4.0.0", id="gte-range"), pytest.param("*", None, "*", id="wildcard"), pytest.param("", None, None, id="empty-string"), pytest.param(" ", None, None, id="whitespace-only"), pytest.param("https://github.com/user/repo", None, None, id="https-url"), pytest.param("git+https://github.com/user/repo", None, None, id="git-url"), pytest.param("file:../local-pkg", None, None, id="file-url"), pytest.param("workspace:*", None, None, id="workspace-protocol"), pytest.param("npm:pkg@^1.0.0", None, "npm:pkg@^1.0.0", id="npm-alias"), ], ) def test_normalize_npm_specifier( spec: str, expected_version: str | None, expected_requirement: str | None, ) -> None: version, requirement = NodeEcosystemHandler._normalize_npm_specifier(spec) assert version == expected_version assert requirement == expected_requirement # -------------------------------------------------------- # _name_from_node_modules_path # -------------------------------------------------------- @pytest.mark.parametrize( "pkg_path, expected", [ pytest.param("node_modules/express", "express", id="simple-package"), pytest.param("node_modules/@babel/core", "@babel/core", id="scoped-package"), pytest.param( "node_modules/a/node_modules/b", "b", id="nested-node-modules", ), pytest.param("node_modules/", None, id="trailing-slash-empty"), ], ) def test_name_from_node_modules_path( pkg_path: str, expected: str | None, ) -> None: assert NodeEcosystemHandler._name_from_node_modules_path(pkg_path) == 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.0.0, <2.0.0", ">=1.0.0 <2.0.0", id="comma-separated"), pytest.param(">= 1.0.0 < 2.0.0", ">=1.0.0 <2.0.0", id="spaces-after-operators"), pytest.param(">=1.0.0 || <0.5.0", ">=1.0.0 || <0.5.0", id="or-ranges"), pytest.param("^1.0.0", "^1.0.0", id="caret-unchanged"), pytest.param("~1.0.0", "~1.0.0", id="tilde-unchanged"), ], ) def test_normalize_vuln_range( handler: NodeEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep, alert, expected_vuln, expected_confidence", [ pytest.param( make_dependency(version="4.17.0", ecosystem=Ecosystem.NPM, is_lockfile=True), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=None), True, "low", id="no-vuln-range-returns-true-low", ), pytest.param( make_dependency(version="4.17.0", ecosystem=Ecosystem.NPM, is_lockfile=True), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=""), True, "low", id="empty-vuln-range-returns-true-low", ), pytest.param( make_dependency(version="4.17.0", ecosystem=Ecosystem.NPM, is_lockfile=True), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), True, "high", id="lockfile-version-vulnerable-high-confidence", ), pytest.param( make_dependency(version="4.18.2", ecosystem=Ecosystem.NPM, is_lockfile=True), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), False, "high", id="lockfile-version-not-vulnerable-high-confidence", ), pytest.param( make_dependency(version="4.17.0", ecosystem=Ecosystem.NPM, is_lockfile=False), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), True, "medium", id="manifest-version-vulnerable-medium-confidence", ), pytest.param( make_dependency( version=None, requirement="^4.0.0", ecosystem=Ecosystem.NPM, ), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), True, "low", id="requirement-overlap-true", ), pytest.param( make_dependency( version=None, requirement=">=5.0.0", ecosystem=Ecosystem.NPM, ), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), False, "low", id="requirement-overlap-false", ), pytest.param( make_dependency( version=None, requirement=None, ecosystem=Ecosystem.NPM, ), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), True, "low", id="no-version-no-requirement-conservative", ), pytest.param( make_dependency(version="not-a-version", ecosystem=Ecosystem.NPM), make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=4.0.0, <4.18.0"), True, "low", id="invalid-version-returns-true-low", ), ], ) def test_is_vulnerable( handler: NodeEcosystemHandler, dep: Dependency, alert: SecurityVulnerability, expected_vuln: bool, expected_confidence: str, ) -> None: is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == expected_confidence def test_is_vulnerable_normalize_returns_empty(handler: NodeEcosystemHandler) -> None: """When _normalize_vuln_range produces an empty string, is_vulnerable falls back to the conservative (True, 'low') result.""" dep = make_dependency(version="4.17.0", ecosystem=Ecosystem.NPM, is_lockfile=True) alert = make_alert(ecosystem=Ecosystem.NPM, vulnerable_range="some-range") with patch.object(handler, "_normalize_vuln_range", return_value=""): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" def test_is_vulnerable_unexpected_exception_returns_true_low( handler: NodeEcosystemHandler, ) -> None: """Covers: outer `except Exception: return True, 'low'` catch-all.""" dep = make_dependency( version="3.0.0", ecosystem=Ecosystem.NPM, is_lockfile=True, ) alert = make_alert(ecosystem=Ecosystem.NPM, vulnerable_range=">=2.0.0, <3.5.0") with patch.object( handler, "_normalize_vuln_range", side_effect=RuntimeError("unexpected"), ): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" # -------------------------------------------------------- # _version_in_range / _ranges_overlap — error paths # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: NodeEcosystemHandler) -> None: """Covers: _version_in_range catches exception and returns True.""" import semantic_version version = semantic_version.Version.coerce("1.0.0") with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._version_in_range(version, ">=1.0.0") is True def test_ranges_overlap_exception_returns_true(handler: NodeEcosystemHandler) -> None: """Covers: _ranges_overlap catches exception and returns True.""" with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap("^1.0.0", ">=1.0.0 <2.0.0") is True # -------------------------------------------------------- # _collect_v7_packages edge cases # -------------------------------------------------------- @pytest.mark.parametrize( "packages, expected_count, expected_name", [ pytest.param( {"some/weird/path": {"version": "1.0.0"}}, 1, "some/weird/path", id="no-node-modules-uses-full-path-as-name", ), pytest.param( {"node_modules/": {"version": "1.0.0"}}, 0, None, id="empty-name-after-node-modules-skipped", ), pytest.param( {"node_modules/valid": {}}, 0, None, id="missing-version-skipped", ), pytest.param( {"node_modules/also-valid": {"version": ""}}, 0, None, id="empty-version-skipped", ), pytest.param( {"node_modules/pkg": "not-a-dict"}, 0, None, id="non-dict-package-info-skipped", ), pytest.param( {"node_modules/pkg": {"version": 42}}, 0, None, id="non-string-version-skipped", ), ], ) def test_collect_v7_packages( handler: NodeEcosystemHandler, packages: dict[str, object], expected_count: int, expected_name: str | None, ) -> None: seen: set[tuple[str, str]] = set() deps = handler._collect_v7_packages(packages, "package-lock.json", seen) assert len(deps) == expected_count if expected_name is not None: assert deps[0].name == expected_name