7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
477 lines
19 KiB
Python
477 lines
19 KiB
Python
"""
|
|
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
|