Coverage for src/local_deep_research/security/module_whitelist.py: 100%
41 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Module whitelist for safe dynamic imports.
4This module provides secure dynamic import functionality with a strict whitelist
5of allowed modules and class names. This prevents arbitrary code execution through
6user-controlled configuration values.
8Security Features:
9- Validates module paths against a whitelist of trusted modules
10- Validates class names against a whitelist of legitimate search engine classes
11- Prevents loading of arbitrary code through malicious configuration
12- Logs security-relevant events for auditing
13"""
15import importlib
16from typing import Optional, Type
18from loguru import logger
21# Whitelist of allowed module paths for dynamic import.
22# Only modules in this list can be loaded dynamically.
23# SECURITY: Only relative paths (starting with ".") are allowed.
24# This ensures all imports are relative to local_deep_research.web_search_engines
25# and prevents arbitrary module imports from other packages.
26ALLOWED_MODULE_PATHS: frozenset[str] = frozenset(
27 [
28 # Relative imports only (relative to local_deep_research.web_search_engines)
29 # Absolute paths are NOT allowed for security reasons
30 ".engines.full_search",
31 ".engines.search_engine_arxiv",
32 ".engines.search_engine_brave",
33 ".engines.search_engine_collection",
34 ".engines.search_engine_elasticsearch",
35 ".engines.search_engine_exa",
36 ".engines.search_engine_federal_register",
37 ".engines.search_engine_github",
38 ".engines.search_engine_google_pse",
39 ".engines.search_engine_guardian",
40 ".engines.search_engine_library",
41 ".engines.local_embedding_manager",
42 ".engines.search_engine_mojeek",
43 ".engines.search_engine_nasa_ads",
44 ".engines.search_engine_openalex",
45 ".engines.search_engine_paperless",
46 ".engines.search_engine_pubmed",
47 ".engines.search_engine_retriever",
48 ".engines.search_engine_scaleserp",
49 ".engines.search_engine_searxng",
50 ".engines.search_engine_semantic_scholar",
51 ".engines.search_engine_serper",
52 ".engines.search_engine_serpapi",
53 ".engines.search_engine_serply",
54 ".engines.search_engine_webz",
55 ".engines.search_engine_sofya",
56 ".engines.search_engine_tavily",
57 ".engines.search_engine_tinyfish",
58 ".engines.search_engine_gutenberg",
59 ".engines.search_engine_openlibrary",
60 ".engines.search_engine_pubchem",
61 ".engines.search_engine_stackexchange",
62 ".engines.search_engine_wayback",
63 ".engines.search_engine_wikipedia",
64 ".engines.search_engine_wikinews",
65 ".engines.search_engine_zenodo",
66 ".search_engine_base",
67 ]
68)
70# Legacy alias for backward compatibility
71ALLOWED_MODULES = ALLOWED_MODULE_PATHS
74# Whitelist of allowed class names for search engines.
75# Only classes in this list can be instantiated through dynamic import.
76ALLOWED_CLASS_NAMES: frozenset[str] = frozenset(
77 [
78 # Search engine implementation classes
79 "ArXivSearchEngine",
80 "BaseSearchEngine",
81 "BraveSearchEngine",
82 "CollectionSearchEngine",
83 "ElasticsearchSearchEngine",
84 "GutenbergSearchEngine",
85 "ExaSearchEngine",
86 "FederalRegisterSearchEngine",
87 "FullSearchResults",
88 "GitHubSearchEngine",
89 "GooglePSESearchEngine",
90 "GuardianSearchEngine",
91 "LibraryRAGSearchEngine",
92 "MojeekSearchEngine",
93 "NasaAdsSearchEngine",
94 "OpenAlexSearchEngine",
95 "OpenLibrarySearchEngine",
96 "PaperlessSearchEngine",
97 "PubChemSearchEngine",
98 "PubMedSearchEngine",
99 "RetrieverSearchEngine",
100 "ScaleSerpSearchEngine",
101 "SearXNGSearchEngine",
102 "SemanticScholarSearchEngine",
103 "SerpAPISearchEngine",
104 "SerperSearchEngine",
105 "SerplySearchEngine",
106 "WebzSearchEngine",
107 "SofyaSearchEngine",
108 "StackExchangeSearchEngine",
109 "TavilySearchEngine",
110 "TinyFishSearchEngine",
111 "WaybackSearchEngine",
112 "WikinewsSearchEngine",
113 "WikipediaSearchEngine",
114 "ZenodoSearchEngine",
115 ]
116)
119class SecurityError(Exception):
120 """Raised when a security validation fails during module import."""
122 pass
125# Legacy alias for backward compatibility
126ModuleNotAllowedError = SecurityError
129def validate_module_import(module_path: str, class_name: str) -> bool:
130 """
131 Validate that both module_path and class_name are in their respective whitelists.
133 This function provides a security check to ensure that dynamically loaded
134 search engine modules are from trusted sources only. It validates:
135 1. Module path starts with "." (relative import only)
136 2. Module path is in the whitelist
137 3. Class name is in the whitelist
139 Args:
140 module_path: The Python module path (MUST be relative, starting with ".")
141 class_name: The class name to import from the module
143 Returns:
144 True if all validations pass, False otherwise
146 Example:
147 >>> validate_module_import(".engines.search_engine_brave", "BraveSearchEngine")
148 True
149 >>> validate_module_import("os", "system")
150 False
151 """
152 if not module_path or not class_name:
153 logger.warning(
154 "Module validation failed: empty module_path or class_name"
155 )
156 return False
158 # SECURITY: Only allow relative imports (starting with ".")
159 # This ensures imports are relative to local_deep_research.web_search_engines
160 # and prevents loading arbitrary modules like "os" or "subprocess"
161 if not module_path.startswith("."):
162 logger.warning(
163 f"Security: Rejected non-relative module path: {module_path}. "
164 f"Only relative imports (starting with '.') are allowed."
165 )
166 return False
168 module_valid = module_path in ALLOWED_MODULE_PATHS
169 class_valid = class_name in ALLOWED_CLASS_NAMES
171 if not module_valid:
172 logger.warning(f"Module path not in whitelist: {module_path}")
174 if not class_valid:
175 logger.warning(f"Class name not in whitelist: {class_name}")
177 return module_valid and class_valid
180def get_safe_module_class(
181 module_path: str,
182 class_name: str,
183 package: Optional[str] = None,
184) -> Type:
185 """
186 Safely import a class from a module, validating against both whitelists.
188 This function provides secure dynamic import functionality by:
189 1. Validating the module path against a strict whitelist
190 2. Validating the class name against a strict whitelist
191 3. Only allowing imports from trusted local_deep_research modules
192 4. Preventing arbitrary code execution through configuration
194 Args:
195 module_path: The module path to import from
196 (e.g., ".engines.search_engine_brave" for relative imports)
197 class_name: The class name to retrieve from the module
198 package: Optional package for relative imports. If not provided and
199 module_path starts with ".", defaults to
200 "local_deep_research.web_search_engines"
202 Returns:
203 The requested class from the module
205 Raises:
206 SecurityError: If the module path or class name is not in the whitelist
207 ModuleNotFoundError: If the module cannot be imported
208 AttributeError: If the class does not exist in the module
210 Example:
211 >>> cls = get_safe_module_class(".engines.search_engine_brave", "BraveSearchEngine")
212 >>> engine = cls(api_key="...")
213 """
214 # Validate both module path and class name against whitelists
215 if not validate_module_import(module_path, class_name):
216 logger.error(
217 f"Security: Blocked attempt to import non-whitelisted module/class: "
218 f"module_path={module_path!r}, class_name={class_name!r}"
219 )
220 raise SecurityError(
221 f"Import blocked: module_path={module_path!r} or class_name={class_name!r} "
222 f"is not in the security whitelist. Only trusted local_deep_research "
223 f"modules and classes can be dynamically imported."
224 )
226 # Determine package for relative imports
227 if package is None and module_path.startswith("."):
228 package = "local_deep_research.web_search_engines"
230 # Import the module
231 try:
232 # bearer:disable python_lang_code_injection
233 module = importlib.import_module(module_path, package=package)
234 except ModuleNotFoundError:
235 logger.exception(f"Failed to import whitelisted module {module_path}")
236 raise
238 # Get the class from the module
239 try:
240 engine_class = getattr(module, class_name)
241 except AttributeError:
242 logger.exception(
243 f"Class '{class_name}' not found in module '{module_path}'"
244 )
245 raise
247 # Successful imports are routine and may occur several times while policy
248 # and factory layers validate one engine. Keep denials at warning/error,
249 # but do not persist this success chatter into research logs.
250 logger.trace(f"Successfully loaded {class_name} from {module_path}")
251 return engine_class