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
495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""
|
|
Extended tests for the module whitelist security system.
|
|
|
|
Tests the whitelist constants, validation functions, error hierarchy,
|
|
and security-critical edge cases in local_deep_research.security.module_whitelist.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from local_deep_research.security.module_whitelist import (
|
|
ALLOWED_MODULE_PATHS,
|
|
ALLOWED_CLASS_NAMES,
|
|
ALLOWED_MODULES,
|
|
SecurityError,
|
|
ModuleNotAllowedError,
|
|
validate_module_import,
|
|
get_safe_module_class,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ALLOWED_MODULE_PATHS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowedModulePathsExtended:
|
|
"""Extended tests for the ALLOWED_MODULE_PATHS constant."""
|
|
|
|
def test_is_frozenset(self):
|
|
"""ALLOWED_MODULE_PATHS must be a frozenset (immutable)."""
|
|
assert isinstance(ALLOWED_MODULE_PATHS, frozenset)
|
|
|
|
def test_contains_brave_engine(self):
|
|
"""Whitelist contains the Brave search engine module."""
|
|
assert ".engines.search_engine_brave" in ALLOWED_MODULE_PATHS
|
|
|
|
def test_contains_arxiv_engine(self):
|
|
"""Whitelist contains the ArXiv search engine module."""
|
|
assert ".engines.search_engine_arxiv" in ALLOWED_MODULE_PATHS
|
|
|
|
def test_all_entries_start_with_dot(self):
|
|
"""Every entry must start with '.' enforcing relative-import-only policy."""
|
|
for path in ALLOWED_MODULE_PATHS:
|
|
assert path.startswith("."), (
|
|
f"Path {path!r} violates the relative-import-only policy"
|
|
)
|
|
|
|
def test_does_not_contain_os(self):
|
|
"""Whitelist must never contain the 'os' module."""
|
|
assert "os" not in ALLOWED_MODULE_PATHS
|
|
|
|
def test_does_not_contain_subprocess(self):
|
|
"""Whitelist must never contain the 'subprocess' module."""
|
|
assert "subprocess" not in ALLOWED_MODULE_PATHS
|
|
|
|
def test_does_not_contain_sys(self):
|
|
"""Whitelist must never contain the 'sys' module."""
|
|
assert "sys" not in ALLOWED_MODULE_PATHS
|
|
|
|
def test_no_dangerous_stdlib_paths(self):
|
|
"""Whitelist must not contain any well-known dangerous stdlib modules."""
|
|
dangerous = {
|
|
"os",
|
|
"sys",
|
|
"subprocess",
|
|
"shutil",
|
|
"ctypes",
|
|
"socket",
|
|
"pickle",
|
|
"shelve",
|
|
"code",
|
|
"codeop",
|
|
"compileall",
|
|
"importlib",
|
|
"builtins",
|
|
"io",
|
|
"signal",
|
|
}
|
|
for module in dangerous:
|
|
assert module not in ALLOWED_MODULE_PATHS, (
|
|
f"Dangerous module {module!r} must not appear in whitelist"
|
|
)
|
|
|
|
def test_immutability(self):
|
|
"""frozenset must refuse mutation attempts."""
|
|
with pytest.raises(AttributeError):
|
|
ALLOWED_MODULE_PATHS.add("os") # type: ignore[attr-defined]
|
|
|
|
with pytest.raises(AttributeError):
|
|
ALLOWED_MODULE_PATHS.discard( # type: ignore[attr-defined]
|
|
".engines.search_engine_brave"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ALLOWED_CLASS_NAMES
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowedClassNamesExtended:
|
|
"""Extended tests for the ALLOWED_CLASS_NAMES constant."""
|
|
|
|
def test_is_frozenset(self):
|
|
"""ALLOWED_CLASS_NAMES must be a frozenset (immutable)."""
|
|
assert isinstance(ALLOWED_CLASS_NAMES, frozenset)
|
|
|
|
def test_contains_brave_search_engine(self):
|
|
"""Whitelist contains the BraveSearchEngine class."""
|
|
assert "BraveSearchEngine" in ALLOWED_CLASS_NAMES
|
|
|
|
def test_contains_arxiv_search_engine(self):
|
|
"""Whitelist contains the ArXivSearchEngine class."""
|
|
assert "ArXivSearchEngine" in ALLOWED_CLASS_NAMES
|
|
|
|
def test_does_not_contain_system(self):
|
|
"""Whitelist must not contain 'system' (os.system target)."""
|
|
assert "system" not in ALLOWED_CLASS_NAMES
|
|
|
|
def test_does_not_contain_exec(self):
|
|
"""Whitelist must not contain 'exec' (code execution target)."""
|
|
assert "exec" not in ALLOWED_CLASS_NAMES
|
|
|
|
def test_does_not_contain_eval(self):
|
|
"""Whitelist must not contain 'eval' (code execution target)."""
|
|
assert "eval" not in ALLOWED_CLASS_NAMES
|
|
|
|
def test_no_dangerous_class_names(self):
|
|
"""Whitelist must not contain any well-known dangerous class/function names."""
|
|
dangerous = {
|
|
"system",
|
|
"exec",
|
|
"eval",
|
|
"Popen",
|
|
"call",
|
|
"run",
|
|
"loads",
|
|
"load",
|
|
"CDLL",
|
|
"interact",
|
|
"__import__",
|
|
"import_module",
|
|
"compile",
|
|
}
|
|
for name in dangerous:
|
|
assert name not in ALLOWED_CLASS_NAMES, (
|
|
f"Dangerous name {name!r} must not appear in class whitelist"
|
|
)
|
|
|
|
def test_immutability(self):
|
|
"""frozenset must refuse mutation attempts."""
|
|
with pytest.raises(AttributeError):
|
|
ALLOWED_CLASS_NAMES.add("system") # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ALLOWED_MODULES (legacy alias)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowedModulesAlias:
|
|
"""Tests for the ALLOWED_MODULES legacy alias."""
|
|
|
|
def test_is_same_object_as_allowed_module_paths(self):
|
|
"""ALLOWED_MODULES must be the exact same object as ALLOWED_MODULE_PATHS."""
|
|
assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SecurityError and ModuleNotAllowedError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSecurityErrorHierarchy:
|
|
"""Tests for the SecurityError and ModuleNotAllowedError exception classes."""
|
|
|
|
def test_security_error_is_exception_subclass(self):
|
|
"""SecurityError must be a subclass of Exception."""
|
|
assert issubclass(SecurityError, Exception)
|
|
|
|
def test_module_not_allowed_error_is_security_error(self):
|
|
"""ModuleNotAllowedError must be the same class as SecurityError (legacy alias)."""
|
|
assert ModuleNotAllowedError is SecurityError
|
|
|
|
def test_security_error_is_catchable_as_exception(self):
|
|
"""SecurityError instances must be catchable as plain Exception."""
|
|
with pytest.raises(Exception):
|
|
raise SecurityError("test")
|
|
|
|
def test_module_not_allowed_error_is_catchable_as_security_error(self):
|
|
"""ModuleNotAllowedError instances must be catchable as SecurityError."""
|
|
with pytest.raises(SecurityError):
|
|
raise ModuleNotAllowedError("test")
|
|
|
|
def test_security_error_preserves_message(self):
|
|
"""SecurityError must preserve its error message."""
|
|
err = SecurityError("blocked import attempt")
|
|
assert str(err) == "blocked import attempt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_module_import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateModuleImportExtended:
|
|
"""Extended tests for the validate_module_import function."""
|
|
|
|
# --- happy-path ---
|
|
|
|
def test_valid_module_and_valid_class_returns_true(self):
|
|
"""Returns True when both module and class are whitelisted."""
|
|
assert (
|
|
validate_module_import(
|
|
".engines.search_engine_brave", "BraveSearchEngine"
|
|
)
|
|
is True
|
|
)
|
|
|
|
# --- single-invalid dimension ---
|
|
|
|
def test_invalid_module_valid_class_returns_false(self):
|
|
"""Returns False when only the module is not whitelisted."""
|
|
assert (
|
|
validate_module_import(
|
|
".engines.nonexistent_engine", "BraveSearchEngine"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_valid_module_invalid_class_returns_false(self):
|
|
"""Returns False when only the class is not whitelisted."""
|
|
assert (
|
|
validate_module_import(".engines.search_engine_brave", "EvilClass")
|
|
is False
|
|
)
|
|
|
|
# --- both-invalid dimension ---
|
|
|
|
def test_both_invalid_returns_false(self):
|
|
"""Returns False when neither module nor class is whitelisted."""
|
|
assert validate_module_import(".evil.module", "EvilClass") is False
|
|
|
|
# --- empty / None inputs ---
|
|
|
|
def test_empty_module_path_returns_false(self):
|
|
"""Returns False for an empty string module_path."""
|
|
assert validate_module_import("", "BraveSearchEngine") is False
|
|
|
|
def test_empty_class_name_returns_false(self):
|
|
"""Returns False for an empty string class_name."""
|
|
assert (
|
|
validate_module_import(".engines.search_engine_brave", "") is False
|
|
)
|
|
|
|
def test_none_module_path_returns_false(self):
|
|
"""Returns False when module_path is None."""
|
|
assert validate_module_import(None, "BraveSearchEngine") is False
|
|
|
|
def test_none_class_name_returns_false(self):
|
|
"""Returns False when class_name is None."""
|
|
assert (
|
|
validate_module_import(".engines.search_engine_brave", None)
|
|
is False
|
|
)
|
|
|
|
def test_both_none_returns_false(self):
|
|
"""Returns False when both arguments are None."""
|
|
assert validate_module_import(None, None) is False
|
|
|
|
# --- CRITICAL SECURITY: non-relative paths ---
|
|
|
|
def test_non_relative_os_returns_false(self):
|
|
"""CRITICAL: bare 'os' module must be rejected as non-relative path."""
|
|
assert validate_module_import("os", "system") is False
|
|
|
|
def test_absolute_fully_qualified_path_returns_false(self):
|
|
"""Absolute fully-qualified module path must be rejected."""
|
|
assert (
|
|
validate_module_import(
|
|
"local_deep_research.web_search_engines.engines.search_engine_brave",
|
|
"BraveSearchEngine",
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_double_dot_prefix_returns_false(self):
|
|
"""Path starting with '..' must be rejected (not in whitelist)."""
|
|
assert validate_module_import("..os", "system") is False
|
|
|
|
def test_triple_dot_prefix_returns_false(self):
|
|
"""Path starting with '...' must be rejected."""
|
|
assert validate_module_import("...os", "system") is False
|
|
|
|
def test_non_relative_subprocess_returns_false(self):
|
|
"""Bare 'subprocess' module must be rejected."""
|
|
assert validate_module_import("subprocess", "Popen") is False
|
|
|
|
def test_non_relative_sys_returns_false(self):
|
|
"""Bare 'sys' module must be rejected."""
|
|
assert validate_module_import("sys", "exit") is False
|
|
|
|
# --- path traversal attempts ---
|
|
|
|
def test_path_traversal_slash(self):
|
|
"""Path traversal using forward slashes must be rejected."""
|
|
assert validate_module_import(".engines/../../../os", "system") is False
|
|
|
|
def test_path_traversal_backslash(self):
|
|
"""Path traversal using backslashes must be rejected."""
|
|
assert (
|
|
validate_module_import(".engines\\..\\..\\subprocess", "Popen")
|
|
is False
|
|
)
|
|
|
|
# --- case sensitivity ---
|
|
|
|
def test_case_sensitive_module_path(self):
|
|
"""Module paths are case-sensitive -- upper-cased variant must be rejected."""
|
|
assert (
|
|
validate_module_import(
|
|
".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_case_sensitive_class_name(self):
|
|
"""Class names are case-sensitive -- lower-cased variant must be rejected."""
|
|
assert (
|
|
validate_module_import(
|
|
".engines.search_engine_brave", "bravesearchengine"
|
|
)
|
|
is False
|
|
)
|
|
|
|
# --- whitespace / special chars ---
|
|
|
|
def test_whitespace_module_path_returns_false(self):
|
|
"""A whitespace-only module_path must be rejected (not starting with '.')."""
|
|
assert validate_module_import(" ", "BraveSearchEngine") is False
|
|
|
|
def test_module_path_with_trailing_space_returns_false(self):
|
|
"""Module path with trailing space must not match whitelist entry."""
|
|
assert (
|
|
validate_module_import(
|
|
".engines.search_engine_brave ", "BraveSearchEngine"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_class_name_with_trailing_space_returns_false(self):
|
|
"""Class name with trailing space must not match whitelist entry."""
|
|
assert (
|
|
validate_module_import(
|
|
".engines.search_engine_brave", "BraveSearchEngine "
|
|
)
|
|
is False
|
|
)
|
|
|
|
# --- injection-style inputs ---
|
|
|
|
def test_semicolon_injection_in_module_path(self):
|
|
"""Semicolon-based injection attempt in module path must be rejected."""
|
|
assert validate_module_import(".engines; import os;", "system") is False
|
|
|
|
def test_newline_injection_in_module_path(self):
|
|
"""Newline injection attempt in module path must be rejected."""
|
|
assert validate_module_import(".engines\nimport os", "system") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_safe_module_class
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetSafeModuleClassExtended:
|
|
"""Extended tests for the get_safe_module_class function."""
|
|
|
|
def test_raises_security_error_for_non_whitelisted_module(self):
|
|
"""Raises SecurityError when the module path is not whitelisted."""
|
|
with pytest.raises(SecurityError):
|
|
get_safe_module_class(".evil.module", "BraveSearchEngine")
|
|
|
|
def test_raises_security_error_for_non_whitelisted_class(self):
|
|
"""Raises SecurityError when the class name is not whitelisted."""
|
|
with pytest.raises(SecurityError):
|
|
get_safe_module_class(
|
|
".engines.search_engine_brave", "MaliciousClass"
|
|
)
|
|
|
|
def test_raises_security_error_for_absolute_os(self):
|
|
"""Raises SecurityError for absolute path 'os'."""
|
|
with pytest.raises(SecurityError):
|
|
get_safe_module_class("os", "system")
|
|
|
|
def test_raises_security_error_for_subprocess(self):
|
|
"""Raises SecurityError for absolute path 'subprocess'."""
|
|
with pytest.raises(SecurityError):
|
|
get_safe_module_class("subprocess", "Popen")
|
|
|
|
def test_error_message_contains_module_path(self):
|
|
"""The SecurityError message must include the rejected module path."""
|
|
with pytest.raises(SecurityError, match=r"\.evil\.module"):
|
|
get_safe_module_class(".evil.module", "EvilClass")
|
|
|
|
def test_error_message_contains_class_name(self):
|
|
"""The SecurityError message must include the rejected class name."""
|
|
with pytest.raises(SecurityError, match="EvilClass"):
|
|
get_safe_module_class(".evil.module", "EvilClass")
|
|
|
|
def test_error_message_mentions_whitelist(self):
|
|
"""The SecurityError message must mention 'whitelist' for clarity."""
|
|
with pytest.raises(SecurityError, match="whitelist"):
|
|
get_safe_module_class("os", "system")
|
|
|
|
# --- default package for relative imports ---
|
|
|
|
def test_default_package_used_for_relative_import(self):
|
|
"""When no package kwarg is given, the default package is used 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")
|
|
|
|
mock_import.assert_called_once_with(
|
|
".engines.fake",
|
|
package="local_deep_research.web_search_engines",
|
|
)
|
|
|
|
# --- custom package parameter ---
|
|
|
|
def test_custom_package_parameter_is_used(self):
|
|
"""When a custom package kwarg is given, it overrides the default."""
|
|
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="my.custom.package",
|
|
)
|
|
|
|
mock_import.assert_called_once_with(
|
|
".engines.fake",
|
|
package="my.custom.package",
|
|
)
|
|
|
|
# --- downstream exception propagation ---
|
|
|
|
def test_module_not_found_error_propagates(self):
|
|
"""ModuleNotFoundError propagates when a whitelisted module cannot be found."""
|
|
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_attribute_error_propagates(self):
|
|
"""AttributeError propagates when the class is missing from the module."""
|
|
with patch(
|
|
"local_deep_research.security.module_whitelist.validate_module_import",
|
|
return_value=True,
|
|
):
|
|
mock_module = MagicMock(spec=[])
|
|
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",
|
|
)
|