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

1""" 

2Module whitelist for safe dynamic imports. 

3 

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. 

7 

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""" 

14 

15import importlib 

16from typing import Optional, Type 

17 

18from loguru import logger 

19 

20 

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) 

69 

70# Legacy alias for backward compatibility 

71ALLOWED_MODULES = ALLOWED_MODULE_PATHS 

72 

73 

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) 

117 

118 

119class SecurityError(Exception): 

120 """Raised when a security validation fails during module import.""" 

121 

122 pass 

123 

124 

125# Legacy alias for backward compatibility 

126ModuleNotAllowedError = SecurityError 

127 

128 

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. 

132 

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 

138 

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 

142 

143 Returns: 

144 True if all validations pass, False otherwise 

145 

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 

157 

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 

167 

168 module_valid = module_path in ALLOWED_MODULE_PATHS 

169 class_valid = class_name in ALLOWED_CLASS_NAMES 

170 

171 if not module_valid: 

172 logger.warning(f"Module path not in whitelist: {module_path}") 

173 

174 if not class_valid: 

175 logger.warning(f"Class name not in whitelist: {class_name}") 

176 

177 return module_valid and class_valid 

178 

179 

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. 

187 

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 

193 

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" 

201 

202 Returns: 

203 The requested class from the module 

204 

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 

209 

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 ) 

225 

226 # Determine package for relative imports 

227 if package is None and module_path.startswith("."): 

228 package = "local_deep_research.web_search_engines" 

229 

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 

237 

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 

246 

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