""" Module Whitelist Tests Tests for the dynamic import security module that prevents arbitrary code execution through user-controlled configuration values. Security model: - Only allows relative imports (starting with ".") - Validates module paths against a strict whitelist of trusted search engine modules - Validates class names against a strict whitelist of legitimate search engine classes - Blocks dangerous modules like os, subprocess, sys, etc. - Prevents path traversal attacks in module paths """ import pytest from unittest.mock import MagicMock, patch from local_deep_research.security.module_whitelist import ( ALLOWED_CLASS_NAMES, ALLOWED_MODULE_PATHS, ModuleNotAllowedError, SecurityError, get_safe_module_class, validate_module_import, ) class TestAllowedModulePaths: """Test the ALLOWED_MODULE_PATHS constant.""" def test_constants_are_relative_paths(self): """All allowed module paths should start with '.' (relative imports).""" for module_path in ALLOWED_MODULE_PATHS: assert module_path.startswith("."), ( f"Module path '{module_path}' should be a relative import " f"(starting with '.'). Absolute imports are not allowed for security." ) def test_whitelist_is_frozen_set(self): """ALLOWED_MODULE_PATHS should be a frozenset (immutable).""" assert isinstance(ALLOWED_MODULE_PATHS, frozenset) def test_whitelist_contains_expected_engines(self): """Whitelist should contain expected search engine modules.""" expected_engines = [ ".engines.search_engine_brave", ".engines.search_engine_ddg", ".engines.search_engine_tavily", ".engines.search_engine_wikipedia", ] for engine in expected_engines: assert engine in ALLOWED_MODULE_PATHS, ( f"Expected engine '{engine}' not found in whitelist" ) class TestAllowedClassNames: """Test the ALLOWED_CLASS_NAMES constant.""" def test_class_names_whitelist_is_frozen_set(self): """ALLOWED_CLASS_NAMES should be a frozenset (immutable).""" assert isinstance(ALLOWED_CLASS_NAMES, frozenset) def test_whitelist_contains_expected_classes(self): """Whitelist should contain expected search engine classes.""" expected_classes = [ "BraveSearchEngine", "DuckDuckGoSearchEngine", "TavilySearchEngine", "WikipediaSearchEngine", "BaseSearchEngine", ] for cls_name in expected_classes: assert cls_name in ALLOWED_CLASS_NAMES, ( f"Expected class '{cls_name}' not found in whitelist" ) class TestLegacyAliases: """Test backward compatibility aliases.""" def test_module_not_allowed_error_is_security_error(self): """ModuleNotAllowedError should be an alias for SecurityError.""" assert ModuleNotAllowedError is SecurityError class TestValidateModuleImport: """Test the validate_module_import function.""" def test_valid_module_and_class(self): """Should return True for valid whitelisted module and class.""" result = validate_module_import( ".engines.search_engine_brave", "BraveSearchEngine" ) assert result is True def test_valid_base_search_engine(self): """Should return True for the base search engine module.""" result = validate_module_import( ".search_engine_base", "BaseSearchEngine" ) assert result is True def test_rejects_absolute_path(self): """Should reject absolute module paths.""" # Absolute path attempt result = validate_module_import( "local_deep_research.web_search_engines.engines.search_engine_brave", "BraveSearchEngine", ) assert result is False def test_rejects_subprocess_module(self): """Should reject dangerous subprocess module.""" result = validate_module_import("subprocess", "Popen") assert result is False def test_rejects_os_module(self): """Should reject dangerous os module.""" result = validate_module_import("os", "system") assert result is False def test_rejects_sys_module(self): """Should reject dangerous sys module.""" result = validate_module_import("sys", "exit") assert result is False def test_case_sensitivity(self): """Module and class names should be case-sensitive.""" # Lowercase - should fail result = validate_module_import( ".engines.search_engine_brave", "bravesearchengine" ) assert result is False # Uppercase - should fail result = validate_module_import( ".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine" ) assert result is False def test_empty_module_path(self): """Should reject empty module path.""" result = validate_module_import("", "BraveSearchEngine") assert result is False def test_empty_class_name(self): """Should reject empty class name.""" result = validate_module_import(".engines.search_engine_brave", "") assert result is False def test_none_module_path(self): """Should reject None module path.""" result = validate_module_import(None, "BraveSearchEngine") assert result is False def test_none_class_name(self): """Should reject None class name.""" result = validate_module_import(".engines.search_engine_brave", None) assert result is False def test_valid_module_invalid_class(self): """Should reject valid module with invalid class name.""" result = validate_module_import( ".engines.search_engine_brave", "MaliciousClass" ) assert result is False def test_invalid_module_valid_class(self): """Should reject invalid module with valid class name.""" result = validate_module_import( ".engines.nonexistent_engine", "BraveSearchEngine" ) assert result is False class TestGetSafeModuleClass: """Test the get_safe_module_class function.""" def test_loads_valid_search_engine(self): """Should successfully load a valid whitelisted search engine class.""" # Use a module that exists and should be loadable cls = get_safe_module_class(".search_engine_base", "BaseSearchEngine") assert cls is not None assert cls.__name__ == "BaseSearchEngine" def test_raises_security_error_for_invalid_module(self): """Should raise SecurityError for non-whitelisted module.""" with pytest.raises(SecurityError) as exc_info: get_safe_module_class("os", "system") assert "not in the security whitelist" in str(exc_info.value) def test_rejects_absolute_import_from_own_package(self): """Should reject absolute paths — normalization was removed.""" with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.web_search_engines.search_engine_base", "BaseSearchEngine", ) def test_raises_security_error_for_foreign_absolute_import(self): """Should raise SecurityError for absolute import paths outside our package.""" with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.security.module_whitelist", "SecurityError", ) def test_handles_module_not_found(self): """Should raise ModuleNotFoundError if whitelisted module doesn't exist.""" # First, temporarily add a fake module to the whitelist for testing # We can't actually test this without modifying the whitelist, so we mock with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module", side_effect=ModuleNotFoundError("No module named 'fake'"), ): with pytest.raises(ModuleNotFoundError): get_safe_module_class(".engines.fake_module", "FakeEngine") def test_handles_attribute_not_found(self): """Should raise AttributeError if class doesn't exist in module.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): mock_module = MagicMock(spec=[]) # Module without the class with patch( "local_deep_research.security.module_whitelist.importlib.import_module", return_value=mock_module, ): with pytest.raises(AttributeError): get_safe_module_class( ".engines.search_engine_brave", "NonExistentClass", ) def test_uses_default_package_for_relative_imports(self): """Should use default package when none provided for relative imports.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module" ) as mock_import: mock_module = MagicMock() mock_module.FakeEngine = type("FakeEngine", (), {}) mock_import.return_value = mock_module get_safe_module_class(".engines.fake", "FakeEngine") # Check that default package was used mock_import.assert_called_once_with( ".engines.fake", package="local_deep_research.web_search_engines", ) def test_uses_custom_package_when_provided(self): """Should use custom package when explicitly provided.""" with patch( "local_deep_research.security.module_whitelist.validate_module_import", return_value=True, ): with patch( "local_deep_research.security.module_whitelist.importlib.import_module" ) as mock_import: mock_module = MagicMock() mock_module.FakeEngine = type("FakeEngine", (), {}) mock_import.return_value = mock_module get_safe_module_class( ".engines.fake", "FakeEngine", package="custom.package", ) mock_import.assert_called_once_with( ".engines.fake", package="custom.package", ) def test_rejects_collection_engine_absolute_path(self): """Absolute paths are rejected — normalization was removed.""" with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.web_search_engines.engines.search_engine_collection", "CollectionSearchEngine", ) def test_rejects_exact_prefix_as_absolute(self): """Exact prefix without module suffix is rejected as absolute path.""" with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.web_search_engines", "BaseSearchEngine", ) def test_prefix_boundary_rejects_similar_prefix(self): """Prefix that extends the package name without a dot should NOT be normalized. 'local_deep_research.web_search_enginesXXX.evil' should not match the normalization prefix 'local_deep_research.web_search_engines.'. """ with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.web_search_enginesXXX.evil", "BaseSearchEngine", ) def test_rejects_absolute_path_from_own_package_without_normalization(self): """Absolute paths are rejected — registry injection means normalization is unnecessary.""" with pytest.raises(SecurityError): get_safe_module_class( "local_deep_research.web_search_engines.engines.search_engine_searxng", "SearXNGSearchEngine", ) class TestSecurityScenarios: """Test specific security attack scenarios.""" def test_blocks_os_system_import(self): """Should block attempts to import os.system for command execution.""" with pytest.raises(SecurityError): get_safe_module_class("os", "system") def test_blocks_subprocess_import(self): """Should block attempts to import subprocess for command execution.""" with pytest.raises(SecurityError): get_safe_module_class("subprocess", "Popen") with pytest.raises(SecurityError): get_safe_module_class("subprocess", "call") with pytest.raises(SecurityError): get_safe_module_class("subprocess", "run") def test_path_traversal_in_module_path(self): """Should block path traversal attempts in module paths.""" # Attempt to break out of expected package malicious_paths = [ "..os", "....subprocess", ".engines/../../../os", ".engines/..\\..\\subprocess", # Windows-style ".engines%2F..%2F..%2Fos", # URL-encoded ] for path in malicious_paths: result = validate_module_import(path, "system") assert result is False, ( f"Path traversal attempt '{path}' should be blocked" ) def test_blocks_builtins_module(self): """Should block attempts to import builtins.""" with pytest.raises(SecurityError): get_safe_module_class("builtins", "eval") with pytest.raises(SecurityError): get_safe_module_class("builtins", "__import__") def test_blocks_importlib_module(self): """Should block attempts to import importlib directly.""" with pytest.raises(SecurityError): get_safe_module_class("importlib", "import_module") def test_blocks_code_module(self): """Should block attempts to import code execution modules.""" with pytest.raises(SecurityError): get_safe_module_class("code", "interact") def test_blocks_pickle_module(self): """Should block attempts to import pickle (deserialization attacks).""" with pytest.raises(SecurityError): get_safe_module_class("pickle", "loads") def test_blocks_ctypes_module(self): """Should block attempts to import ctypes (memory manipulation).""" with pytest.raises(SecurityError): get_safe_module_class("ctypes", "CDLL") def test_blocks_socket_module(self): """Should block attempts to import socket (network access).""" with pytest.raises(SecurityError): get_safe_module_class("socket", "socket") def test_sql_injection_style_attack(self): """Should block SQL-injection-style attacks in module names.""" malicious_inputs = [ (".engines; import os;", "system"), ('.engines"; import os; "', "system"), (".engines' OR '1'='1", "BraveSearchEngine"), ] for module_path, class_name in malicious_inputs: result = validate_module_import(module_path, class_name) assert result is False, ( f"Injection attempt '{module_path}' should be blocked" ) def test_unicode_bypass_attempt(self): """Should handle unicode normalization bypass attempts.""" # These are unlikely to work but good to test unicode_attempts = [ "\u006fs", # 'o' followed by 's' -> 'os' "o\u0073", # 'o' followed by 's' -> 'os' ] for module_path in unicode_attempts: # Even if they normalize to 'os', should still be blocked result = validate_module_import(module_path, "system") assert result is False class TestSecurityErrorException: """Test the SecurityError exception class.""" def test_security_error_is_exception(self): """SecurityError should be an Exception.""" assert issubclass(SecurityError, Exception) def test_security_error_message(self): """SecurityError should contain helpful error message.""" error = SecurityError("Test error message") assert "Test error message" in str(error) def test_security_error_can_be_raised_and_caught(self): """SecurityError should be raisable and catchable.""" with pytest.raises(SecurityError) as exc_info: raise SecurityError("blocked import") assert "blocked import" in str(exc_info.value) class TestDocumentation: """Documentation tests explaining the security model.""" @pytest.mark.skip(reason="documentation/placeholder test - not implemented") def test_security_model_documentation(self): """ Document the module whitelist security model. WHY THIS EXISTS: - Dynamic imports from user configuration can lead to arbitrary code execution - Attackers could inject malicious module paths to run arbitrary code - Example: config.module = "os"; config.class = "system" -> command execution THE WHITELIST APPROACH: - Only explicitly approved modules can be loaded - Only explicitly approved class names can be instantiated - All imports must be relative (starting with '.') for additional safety - Relative imports are anchored to 'local_deep_research.web_search_engines' DEFENSE IN DEPTH: - Whitelist validation before any import attempt - Relative import requirement prevents direct access to dangerous modules - Class name whitelist prevents instantiation of dangerous classes - Logging of blocked attempts for security auditing SECURITY MODEL: | Input Type | Action | Reason | |-------------------------|------------|-------------------------------------| | Relative + listed | ALLOWED | Trusted search engine module | | Absolute (any) | BLOCKED | Could access dangerous modules | | Unlisted module | BLOCKED | Not in trusted whitelist | | Unlisted class | BLOCKED | Could be dangerous class | | Empty/None | BLOCKED | Invalid input | """ # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: DELETE). assert True # Documentation test