iotsploit-core 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. iotsploit_core/__init__.py +7 -0
  2. iotsploit_core/context.py +27 -0
  3. iotsploit_core/core/__init__.py +0 -0
  4. iotsploit_core/core/base_plugin.py +290 -0
  5. iotsploit_core/core/device_config.py +105 -0
  6. iotsploit_core/core/device_manager.py +942 -0
  7. iotsploit_core/core/device_registry.py +118 -0
  8. iotsploit_core/core/device_scanner.py +74 -0
  9. iotsploit_core/core/device_spec.py +105 -0
  10. iotsploit_core/core/device_store.py +86 -0
  11. iotsploit_core/core/execution_backend.py +464 -0
  12. iotsploit_core/core/execution_queue.py +456 -0
  13. iotsploit_core/core/exploit_manager.py +728 -0
  14. iotsploit_core/core/exploit_spec.py +73 -0
  15. iotsploit_core/core/stream_manager.py +157 -0
  16. iotsploit_core/core/tool_config.py +430 -0
  17. iotsploit_core/core/tool_manager.py +1166 -0
  18. iotsploit_core/core/tool_service.py +936 -0
  19. iotsploit_core/domain/__init__.py +3 -0
  20. iotsploit_core/domain/device.py +70 -0
  21. iotsploit_core/domain/execution_plan.py +29 -0
  22. iotsploit_core/domain/plugin.py +17 -0
  23. iotsploit_core/domain/stream.py +63 -0
  24. iotsploit_core/domain/target.py +243 -0
  25. iotsploit_core/platforms/__init__.py +19 -0
  26. iotsploit_core/platforms/consts.py +25 -0
  27. iotsploit_core/ports/__init__.py +6 -0
  28. iotsploit_core/ports/driver_state_repo.py +15 -0
  29. iotsploit_core/ports/plugin_repo.py +26 -0
  30. iotsploit_core/ports/stream_backend.py +28 -0
  31. iotsploit_core/ports/task_runner.py +22 -0
  32. iotsploit_core/ports/wifi_backend.py +148 -0
  33. iotsploit_core/py.typed +2 -0
  34. iotsploit_core/utils/__init__.py +53 -0
  35. iotsploit_core/utils/exceptions.py +152 -0
  36. iotsploit_core/utils/helpers.py +65 -0
  37. iotsploit_core/utils/iots_logger.py +164 -0
  38. iotsploit_core/utils/result.py +41 -0
  39. iotsploit_core-0.0.6.dist-info/METADATA +117 -0
  40. iotsploit_core-0.0.6.dist-info/RECORD +41 -0
  41. iotsploit_core-0.0.6.dist-info/WHEEL +4 -0
@@ -0,0 +1,728 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ # Disable pwntools terminal mode before any plugin imports to prevent
5
+ # "argument must be an int, or have a fileno() method" errors in non-TTY environments
6
+ os.environ.setdefault("PWNLIB_NOTERM", "1")
7
+
8
+ import glob
9
+ import importlib
10
+ from importlib import metadata as importlib_metadata
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from typing import Optional, Callable, Any
14
+
15
+ import pluggy
16
+
17
+ from iotsploit_core.core.base_plugin import BasePlugin
18
+ from iotsploit_core.core.exploit_spec import ExploitPluginSpec
19
+ from iotsploit_core.domain.execution_plan import GroupStepSpec, PluginGroupSpec, PluginStepSpec
20
+ from iotsploit_core.domain.plugin import PluginMeta
21
+ from iotsploit_core.ports.plugin_repo import PluginGroupRepository, PluginMetaRepository
22
+ from iotsploit_core.ports.task_runner import TaskRunner
23
+ import logging
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ class ExploitPluginManager:
28
+ def __init__(
29
+ self,
30
+ *,
31
+ plugin_repo: PluginMetaRepository,
32
+ group_repo: PluginGroupRepository,
33
+ task_runner: TaskRunner,
34
+ plugins_dir: str | Path | None = None,
35
+ context_factory: Optional[Callable[[], Any]] = None,
36
+ ):
37
+ self._plugin_repo = plugin_repo
38
+ self._group_repo = group_repo
39
+ self._task_runner = task_runner
40
+ self._context_factory = context_factory
41
+
42
+ self.plugins = {} # plugin_name -> plugin instance (loaded on demand)
43
+ self.plugin_registry = {} # plugin_name -> {file_path, class_name}
44
+ self.pm = pluggy.PluginManager("exploit_mgr")
45
+ self.pm.add_hookspecs(ExploitPluginSpec)
46
+ self.plugins_dir = Path(plugins_dir) if plugins_dir is not None else self._default_plugins_dir()
47
+ self.load_plugins()
48
+ import atexit
49
+ atexit.register(self.cleanup_all_plugins)
50
+
51
+ @staticmethod
52
+ def _default_plugins_dir() -> Path | None:
53
+ env = os.getenv("IOTSPLOIT_EXPLOIT_PLUGINS_DIR") or os.getenv("SAT_EXPLOIT_PLUGINS_DIR")
54
+ if env:
55
+ return Path(env)
56
+ return None
57
+
58
+ @staticmethod
59
+ def _normalize_target_for_plugin(target):
60
+ """
61
+ Normalize target into a dict snapshot when possible.
62
+
63
+ Rationale:
64
+ - Django web path currently passes a domain object (e.g., Vehicle).
65
+ - sudo-runner / CLI often pass dict already.
66
+ - Plugins should not need Django imports; dict mode is the stable boundary.
67
+ """
68
+ if target is None:
69
+ return None
70
+ if isinstance(target, dict):
71
+ return target
72
+ # Domain targets implement get_info() to provide a serializable snapshot.
73
+ if hasattr(target, "get_info"):
74
+ try:
75
+ return target.get_info()
76
+ except Exception:
77
+ # Fall back to passing through if snapshot fails
78
+ return target
79
+ return target
80
+
81
+ def _ensure_context_injected(self, plugin_instance: BasePlugin) -> None:
82
+ """
83
+ Ensure plugin has backend context injected (idempotent).
84
+
85
+ This method:
86
+ 1. Checks if context was already injected (via _iots_ctx_injected flag)
87
+ 2. If not, builds context using context_factory and calls plugin.initialize()
88
+ 3. Marks plugin as injected to avoid duplicate initialization
89
+
90
+ Note: context_factory is provided by the application layer (wiring),
91
+ keeping core free of platform/env dependencies.
92
+
93
+ Args:
94
+ plugin_instance: Plugin instance to inject context into
95
+ """
96
+ # Skip if already injected (idempotent)
97
+ if getattr(plugin_instance, "_iots_ctx_injected", False):
98
+ return
99
+
100
+ try:
101
+ # Initialize plugin right before execution.
102
+ # Important: NEVER call initialize(None). Prefer initialize(ctx), fallback to initialize().
103
+ if hasattr(plugin_instance, "initialize"):
104
+ if self._context_factory:
105
+ ctx = self._context_factory()
106
+ try:
107
+ plugin_instance.initialize(ctx)
108
+ except TypeError:
109
+ # Legacy plugins may expose initialize() without params
110
+ try:
111
+ plugin_instance.initialize()
112
+ except TypeError:
113
+ logger.warning(
114
+ "[exploit_mgr] Plugin initialize() signature mismatch; "
115
+ "skipping initialization"
116
+ )
117
+ else:
118
+ # No context factory (no backend injection). Still try legacy initialize().
119
+ try:
120
+ plugin_instance.initialize()
121
+ except TypeError:
122
+ logger.debug(
123
+ "[exploit_mgr] No context_factory and plugin initialize() requires args; "
124
+ "skipping initialization for %s",
125
+ plugin_instance.__class__.__name__,
126
+ )
127
+
128
+ # Mark as injected
129
+ plugin_instance._iots_ctx_injected = True
130
+ logger.debug("[exploit_mgr] Injected backend context into plugin")
131
+
132
+ except Exception as e:
133
+ # Log error but don't fail execution (graceful degradation)
134
+ logger.warning(
135
+ "[exploit_mgr] Failed to inject backend context: %s. "
136
+ "Plugin may not have backend access.", str(e)
137
+ )
138
+
139
+ @staticmethod
140
+ def _iter_entry_points(group: str):
141
+ entry_points = importlib_metadata.entry_points()
142
+ if hasattr(entry_points, "select"):
143
+ return list(entry_points.select(group=group))
144
+ return list(entry_points.get(group, []))
145
+
146
+ def _upsert_plugin_meta(self, plugin_class: type[BasePlugin], module_path: str) -> str | None:
147
+ plugin_instance = plugin_class()
148
+ plugin_info = plugin_instance.get_info() or {}
149
+ plugin_name = plugin_info.get("Name", plugin_class.__name__)
150
+
151
+ meta = PluginMeta(
152
+ name=plugin_name,
153
+ module_path=module_path,
154
+ enabled=True,
155
+ description=str(plugin_info.get("Description", "") or ""),
156
+ license=str(plugin_info.get("License", "") or ""),
157
+ author=str(plugin_info.get("Author", "") or ""),
158
+ parameters=plugin_info.get("Parameters", None),
159
+ )
160
+ self._plugin_repo.upsert(meta)
161
+ logger.debug("[exploit_mgr] Upserted plugin meta: %s", plugin_name)
162
+ return plugin_name
163
+
164
+ def _discover_filesystem_plugins(self, plugins_dir: str | Path | None = None) -> set[str]:
165
+ discovered_plugins: set[str] = set()
166
+ plugins_dir = Path(plugins_dir) if plugins_dir is not None else self.plugins_dir
167
+
168
+ if plugins_dir is None:
169
+ return discovered_plugins
170
+ if not plugins_dir.exists():
171
+ logger.debug("[exploit_mgr] Legacy plugin directory does not exist: %s", plugins_dir)
172
+ return discovered_plugins
173
+
174
+ base_dir = os.path.abspath(str(plugins_dir))
175
+ plugin_files = glob.glob(f"{base_dir}/**/*.py", recursive=True)
176
+
177
+ for plugin_file in plugin_files:
178
+ try:
179
+ if "__init__.py" in plugin_file:
180
+ continue
181
+
182
+ abs_path = os.path.abspath(plugin_file)
183
+ temp_mod_name = f"exploit_autodiscover_{hash(abs_path)}"
184
+ spec = importlib.util.spec_from_file_location(temp_mod_name, abs_path)
185
+ module = importlib.util.module_from_spec(spec)
186
+ if spec is None or spec.loader is None:
187
+ logger.error("[exploit_mgr] Failed to create spec for %s", abs_path)
188
+ continue
189
+ spec.loader.exec_module(module)
190
+
191
+ plugin_class = None
192
+ for _, obj in module.__dict__.items():
193
+ if isinstance(obj, type) and issubclass(obj, BasePlugin) and obj != BasePlugin:
194
+ plugin_class = obj
195
+ break
196
+
197
+ if plugin_class is None:
198
+ continue
199
+
200
+ plugin_name = self._upsert_plugin_meta(
201
+ plugin_class,
202
+ module_path=f"file://{abs_path}::{plugin_class.__name__}",
203
+ )
204
+ if plugin_name:
205
+ discovered_plugins.add(plugin_name)
206
+ except Exception as e:
207
+ logger.error("[exploit_mgr] Error processing plugin file %s: %s", plugin_file, str(e))
208
+
209
+ return discovered_plugins
210
+
211
+ def _discover_entry_point_plugins(self) -> set[str]:
212
+ discovered_plugins: set[str] = set()
213
+ for entry_point in self._iter_entry_points("iotsploit.exploit_plugins"):
214
+ try:
215
+ plugin_class = entry_point.load()
216
+ if (
217
+ not isinstance(plugin_class, type)
218
+ or not issubclass(plugin_class, BasePlugin)
219
+ or plugin_class is BasePlugin
220
+ ):
221
+ logger.warning(
222
+ "[exploit_mgr] Ignoring invalid exploit entry point %s -> %s",
223
+ entry_point.name,
224
+ entry_point.value,
225
+ )
226
+ continue
227
+
228
+ plugin_name = self._upsert_plugin_meta(
229
+ plugin_class,
230
+ module_path=f"{plugin_class.__module__}.{plugin_class.__name__}",
231
+ )
232
+ if plugin_name:
233
+ discovered_plugins.add(plugin_name)
234
+ except Exception as e:
235
+ logger.error(
236
+ "[exploit_mgr] Error loading exploit entry point %s: %s",
237
+ entry_point.name,
238
+ str(e),
239
+ )
240
+
241
+ return discovered_plugins
242
+
243
+ def auto_discover_plugins(self, plugins_dir: str | Path | None = None) -> None:
244
+ """
245
+ Automatically discover plugins from the legacy filesystem and packaged entry points.
246
+ Entry points run last so their dotted module paths take priority over legacy file paths.
247
+ """
248
+ if plugins_dir is None:
249
+ plugins_dir = self.plugins_dir
250
+ logger.debug("[exploit_mgr] Starting plugin auto-discovery in %s", plugins_dir)
251
+
252
+ discovered_plugins = set()
253
+
254
+ try:
255
+ discovered_plugins |= self._discover_filesystem_plugins(plugins_dir)
256
+ discovered_plugins |= self._discover_entry_point_plugins()
257
+
258
+ try:
259
+ disabled = self._plugin_repo.disable_missing(discovered_plugins)
260
+ if disabled:
261
+ logger.debug("[exploit_mgr] Marked %d missing plugins as disabled", disabled)
262
+ except Exception as e:
263
+ logger.error("[exploit_mgr] Error disabling missing plugins: %s", str(e))
264
+
265
+ logger.debug("[exploit_mgr] Plugin auto-discovery completed")
266
+ except Exception as e:
267
+ logger.error("[exploit_mgr] Error during plugin discovery: %s", str(e))
268
+
269
+ def load_plugins(self):
270
+ """
271
+ Build registry from DB without importing modules. Instances load lazily on first use.
272
+ """
273
+ self.auto_discover_plugins(self.plugins_dir)
274
+ self.plugin_registry.clear()
275
+ self.plugins.clear()
276
+ for plugin_db in self._plugin_repo.list_enabled():
277
+ try:
278
+ module_path_field = plugin_db.module_path or ""
279
+ file_path = None
280
+ class_name = None
281
+
282
+ # Preferred: file path encoding "file://<abs_path>::<ClassName>"
283
+ if module_path_field.startswith("file://") and "::" in module_path_field:
284
+ file_path = module_path_field[len("file://"):].split("::", 1)[0]
285
+ class_name = module_path_field.split("::", 1)[1]
286
+ else:
287
+ # Backward compatibility: dotted path "pkg.mod.Class"
288
+ parts = module_path_field.rsplit('.', 1)
289
+ if len(parts) == 2:
290
+ # Try resolve to file by searching sys.modules/importlib; we still store it for fallback load
291
+ file_path = module_path_field # mark as dotted path
292
+ class_name = parts[1]
293
+ else:
294
+ logger.warning(
295
+ "[exploit_mgr] Invalid module path format for plugin %s: %s",
296
+ plugin_db.name,
297
+ module_path_field,
298
+ )
299
+ continue
300
+
301
+ self.plugin_registry[plugin_db.name] = {
302
+ 'file_or_module': file_path,
303
+ 'class_name': class_name,
304
+ }
305
+ except Exception as e:
306
+ logger.error("[exploit_mgr] Failed to index plugin %s: %s", plugin_db.name, str(e))
307
+
308
+ def load_plugin_groups(self) -> list[PluginGroupSpec]:
309
+ return self._group_repo.list_enabled_groups()
310
+
311
+ def initialize(self):
312
+ self.pm.hook.initialize()
313
+
314
+ def exploit(self):
315
+ self.pm.hook.execute()
316
+
317
+ def _load_plugin_instance(self, plugin_name: str) -> Optional[BasePlugin]:
318
+ """Load a plugin instance on demand using file-path import, then cache and register it."""
319
+ if plugin_name in self.plugins:
320
+ return self.plugins[plugin_name]
321
+
322
+ meta = self.plugin_registry.get(plugin_name)
323
+ if not meta:
324
+ logger.warning("[exploit_mgr] Plugin '%s' not found in registry or disabled.", plugin_name)
325
+ return None
326
+
327
+ file_or_module = meta.get('file_or_module')
328
+ class_name = meta.get('class_name')
329
+
330
+ try:
331
+ plugin_class = None
332
+ if file_or_module and os.path.isabs(file_or_module):
333
+ # File path import
334
+ temp_mod_name = f"exploit_runtime_{hash(file_or_module)}"
335
+ spec = importlib.util.spec_from_file_location(temp_mod_name, file_or_module)
336
+ if spec is None or spec.loader is None:
337
+ raise ImportError(f"Failed to create spec for {file_or_module}")
338
+ module = importlib.util.module_from_spec(spec)
339
+ spec.loader.exec_module(module)
340
+ if hasattr(module, class_name):
341
+ plugin_class = getattr(module, class_name)
342
+ else:
343
+ # Backward compatibility: dotted import
344
+ module_name = file_or_module.rsplit('.', 1)[0]
345
+ module = importlib.import_module(module_name)
346
+ if hasattr(module, class_name):
347
+ plugin_class = getattr(module, class_name)
348
+
349
+ if not plugin_class or not issubclass(plugin_class, BasePlugin):
350
+ raise TypeError(f"Plugin class invalid or not a BasePlugin: {class_name}")
351
+
352
+ plugin_instance = plugin_class()
353
+ # Register to pluggy. Do NOT call initialize() here.
354
+ # Initialization (and backend injection) happens right before execution via _ensure_context_injected().
355
+ self.pm.register(plugin_instance)
356
+ self.plugins[plugin_name] = plugin_instance
357
+ logger.debug("[exploit_mgr] Lazily loaded and registered plugin: %s", plugin_name)
358
+ return plugin_instance
359
+ except Exception as e:
360
+ logger.error("[exploit_mgr] Failed to lazily load plugin %s: %s", plugin_name, str(e))
361
+ return None
362
+
363
+ async def execute_plugin_async(self, plugin_name, target=None, parameters=None):
364
+ """Execute a plugin asynchronously if it supports it, otherwise fall back to sync execution"""
365
+ plugin_instance = self._load_plugin_instance(plugin_name)
366
+ if not plugin_instance:
367
+ logger.warning("[exploit_mgr] Plugin '%s' not found or disabled.", plugin_name)
368
+ return None
369
+
370
+ # Normalize target for plugin boundary
371
+ target = self._normalize_target_for_plugin(target)
372
+
373
+ # Inject backend context before execution
374
+ self._ensure_context_injected(plugin_instance)
375
+
376
+ # Check if plugin supports async execution
377
+ if hasattr(plugin_instance, 'execute_async'):
378
+ result = await plugin_instance.execute_async(target, parameters)
379
+ else:
380
+ # Fall back to synchronous execution
381
+ result = plugin_instance.execute(target, parameters)
382
+
383
+ return result
384
+
385
+ def execute_plugin(self, plugin_name, target=None, parameters=None):
386
+ """
387
+ Execute a plugin with the given parameters.
388
+ """
389
+ return self._execute_plugin(plugin_name, target=target, parameters=parameters, force_async=None)
390
+
391
+ def _execute_plugin(self, plugin_name, target=None, parameters=None, *, force_async: bool | None = None):
392
+ plugin_instance = self._load_plugin_instance(plugin_name)
393
+ if not plugin_instance:
394
+ logger.warning("[exploit_mgr] Plugin '%s' not found or disabled.", plugin_name)
395
+ return None
396
+
397
+ try:
398
+ parameters = parameters or {}
399
+ # Normalize target for plugin boundary (dict snapshot preferred)
400
+ target = self._normalize_target_for_plugin(target)
401
+
402
+ # Determine if we should execute asynchronously
403
+ if force_async is None:
404
+ should_execute_async = (
405
+ hasattr(plugin_instance, 'execute_async') or # Plugin supports async
406
+ parameters.get('duration', 0) > 5 or # Long running task
407
+ parameters.get('stream', False) or # Streaming data
408
+ parameters.get('async', False) # Explicitly requested async
409
+ )
410
+ else:
411
+ should_execute_async = bool(force_async)
412
+
413
+ if should_execute_async:
414
+ logger.info("[exploit_mgr] Starting async execution for plugin: %s", plugin_name)
415
+
416
+ return self._task_runner.submit(
417
+ plugin_name,
418
+ target=target,
419
+ parameters=parameters,
420
+ context=None,
421
+ )
422
+ else:
423
+ # Execute synchronously
424
+ logger.info("[exploit_mgr] Executing plugin synchronously: %s", plugin_name)
425
+
426
+ # Inject backend context before execution
427
+ self._ensure_context_injected(plugin_instance)
428
+
429
+ result = plugin_instance.execute(target, parameters)
430
+
431
+ # Format the result
432
+ if hasattr(result, 'success'):
433
+ return {
434
+ 'success': result.success,
435
+ 'message': result.message if hasattr(result, 'message') else str(result),
436
+ 'data': result.data if hasattr(result, 'data') else None
437
+ }
438
+ else:
439
+ return {
440
+ 'message': str(result)
441
+ }
442
+
443
+ except Exception as e:
444
+ logger.error("[exploit_mgr] Error executing plugin %s: %s", plugin_name, str(e))
445
+ raise ValueError(f"Error executing plugin {plugin_name}: {str(e)}")
446
+
447
+ def execute_selected_plugins(self, plugin_names, target=None):
448
+ results = {}
449
+ for plugin_name in plugin_names:
450
+ try:
451
+ result = self.execute_plugin(plugin_name, target)
452
+ results[plugin_name] = result
453
+ except ValueError as e:
454
+ logger.warning("[exploit_mgr] Error executing plugin '%s': %s", plugin_name, str(e))
455
+ return results
456
+
457
+ def list_plugins(self):
458
+ """
459
+ Returns a list of all available plugins.
460
+ """
461
+ # Prefer registry keys to avoid forcing loads
462
+ return list(self.plugin_registry.keys())
463
+
464
+ def list_plugin_info(self):
465
+ logger.debug("[exploit_mgr] Starting to list plugin information")
466
+ plugin_info = {}
467
+
468
+ # Use registry to avoid preloading all; load per plugin to get info
469
+ for plugin_name in self.plugin_registry.keys():
470
+ plugin_instance = self.plugins.get(plugin_name) or self._load_plugin_instance(plugin_name)
471
+ logger.debug("[exploit_mgr] Processing plugin: %s", plugin_name)
472
+ try:
473
+ if not plugin_instance:
474
+ plugin_info[plugin_name] = {"error": "failed to load plugin"}
475
+ continue
476
+ info = plugin_instance.get_info()
477
+ logger.debug("[exploit_mgr] Retrieved info for plugin %s: %s", plugin_name, info)
478
+ plugin_info[plugin_name] = info
479
+
480
+ except Exception as e:
481
+ logger.error("[exploit_mgr] Error processing plugin %s: %s", plugin_name, str(e))
482
+ plugin_info[plugin_name] = {"error": str(e)}
483
+
484
+ logger.debug("[exploit_mgr] Completed listing plugin information. Total plugins: %d", len(plugin_info))
485
+ return plugin_info
486
+
487
+ def get_plugin(self, plugin_name: str) -> Optional[BasePlugin]:
488
+ """
489
+ Get a plugin instance by name.
490
+
491
+ Args:
492
+ plugin_name (str): The name of the plugin to retrieve
493
+
494
+ Returns:
495
+ Optional[BasePlugin]: The plugin instance if found, None otherwise
496
+ """
497
+ plugin_instance = self.plugins.get(plugin_name) or self._load_plugin_instance(plugin_name)
498
+ if not plugin_instance:
499
+ logger.warning("[exploit_mgr] Plugin '%s' not found or disabled.", plugin_name)
500
+ return None
501
+ return plugin_instance
502
+
503
+ def get_plugin_info(self, plugin_name: str) -> Optional[dict]:
504
+ """
505
+ Get plugin information by name.
506
+
507
+ Args:
508
+ plugin_name (str): The name of the plugin to retrieve info for
509
+
510
+ Returns:
511
+ Optional[dict]: The plugin info dictionary if found, None otherwise
512
+ """
513
+ plugin_instance = self.plugins.get(plugin_name) or self._load_plugin_instance(plugin_name)
514
+ if not plugin_instance:
515
+ logger.warning("[exploit_mgr] Plugin '%s' not found or disabled.", plugin_name)
516
+ return None
517
+
518
+ try:
519
+ return plugin_instance.get_info()
520
+ except Exception as e:
521
+ logger.error("[exploit_mgr] Error getting info for plugin %s: %s", plugin_name, str(e))
522
+ return None
523
+
524
+ def execute_plugin_group(self, group_name, target=None, parameters=None, force_exec=True):
525
+ """
526
+ Execute the named plugin‑group *including* its nested groups and plugins.
527
+
528
+ The actual ordered execution and failure‑handling logic lives inside
529
+ PluginGroup.execute(), which honours:
530
+ • PluginSequence.sequence / ignore_fail
531
+ • PluginGroupTree.sequence / ignore_fail / force_exec
532
+ """
533
+ spec = self._group_repo.get_group(group_name)
534
+ if spec is None:
535
+ logger.error("[exploit_mgr] Plugin group '%s' does not exist", group_name)
536
+ return False
537
+
538
+ if not spec.enabled and not force_exec:
539
+ logger.warning("[exploit_mgr] Plugin group '%s' is disabled", group_name)
540
+ return False
541
+
542
+ logger.info("[exploit_mgr] Executing plugin group '%s'", group_name)
543
+ ok = self._execute_group_spec(spec, target=target, parameters=parameters, force_exec=force_exec, _stack=[])
544
+
545
+ if ok:
546
+ logger.info("[exploit_mgr] Plugin group '%s' executed successfully", group_name)
547
+ else:
548
+ logger.warning("[exploit_mgr] Plugin group '%s' finished with failures", group_name)
549
+ return ok
550
+
551
+ def _execute_group_spec(
552
+ self,
553
+ spec: PluginGroupSpec,
554
+ *,
555
+ target=None,
556
+ parameters=None,
557
+ force_exec: bool = True,
558
+ _stack: list[str],
559
+ ) -> bool:
560
+ if spec.name in _stack:
561
+ logger.error(
562
+ "[exploit_mgr] Detected cyclic PluginGroup reference: %s",
563
+ " -> ".join(_stack + [spec.name]),
564
+ )
565
+ return False
566
+
567
+ if not spec.enabled and not force_exec:
568
+ return True
569
+
570
+ overall_ok = True
571
+ stack2 = _stack + [spec.name]
572
+
573
+ # Merge and execute all steps in order.
574
+ steps: list[tuple[int, str, object]] = []
575
+ for s in spec.group_steps:
576
+ steps.append((s.sequence, "group", s))
577
+ for s in spec.plugin_steps:
578
+ steps.append((s.sequence, "plugin", s))
579
+ steps.sort(key=lambda t: t[0])
580
+
581
+ for _, kind, s in steps:
582
+ if kind == "group":
583
+ s = s # type: ignore[assignment]
584
+ assert isinstance(s, GroupStepSpec)
585
+ child = self._group_repo.get_group(s.group_name)
586
+ if child is None:
587
+ logger.error(
588
+ "[exploit_mgr] Missing child PluginGroup '%s' referenced by '%s'",
589
+ s.group_name,
590
+ spec.name,
591
+ )
592
+ if not s.ignore_fail:
593
+ return False
594
+ overall_ok = False
595
+ continue
596
+ ok = self._execute_group_spec(
597
+ child,
598
+ target=target,
599
+ parameters=parameters,
600
+ force_exec=s.force_exec,
601
+ _stack=stack2,
602
+ )
603
+ if not ok:
604
+ if not s.ignore_fail:
605
+ return False
606
+ overall_ok = False
607
+ else:
608
+ assert isinstance(s, PluginStepSpec)
609
+ try:
610
+ result = self._execute_plugin(s.plugin_name, target=target, parameters=parameters, force_async=None)
611
+ if isinstance(result, dict) and result.get("execution_type") == "async":
612
+ ok = True
613
+ elif isinstance(result, dict):
614
+ ok = bool(result.get("success", True))
615
+ else:
616
+ ok = True if result is None else bool(result)
617
+ except Exception as e:
618
+ logger.error("[exploit_mgr] Error executing plugin %s: %s", s.plugin_name, str(e))
619
+ ok = False
620
+
621
+ if not ok:
622
+ if not s.ignore_fail:
623
+ return False
624
+ overall_ok = False
625
+
626
+ return overall_ok
627
+
628
+ def cleanup_all_plugins(self):
629
+ """
630
+ Cleanup all registered plugins. This method is called automatically on program exit
631
+ and can also be called manually when needed.
632
+ """
633
+ logger.debug("[exploit_mgr] Starting cleanup of all plugins")
634
+ try:
635
+ # Guard: if __init__ failed part-way, destructor may still run.
636
+ if not hasattr(self, "pm"):
637
+ return
638
+ # Log registered plugins before cleanup using class names instead of name attribute
639
+ registered_plugins = [p.__class__.__name__ for p in self.pm.get_plugins()]
640
+ logger.debug("[exploit_mgr] Registered plugins for cleanup: %s", registered_plugins)
641
+
642
+ # Call cleanup hook on all registered plugins
643
+ self.pm.hook.cleanup()
644
+
645
+ # Clear plugin references
646
+ if hasattr(self, "plugins"):
647
+ self.plugins.clear()
648
+ logger.debug("[exploit_mgr] Successfully cleaned up all plugins")
649
+ except Exception as e:
650
+ logger.error("[exploit_mgr] Error during plugin cleanup: %s", str(e))
651
+
652
+ def __del__(self):
653
+ """
654
+ Destructor to ensure cleanup is called when the manager is destroyed
655
+ """
656
+ try:
657
+ # Avoid noisy errors during interpreter shutdown / partial init.
658
+ if hasattr(self, "pm"):
659
+ self.cleanup_all_plugins()
660
+ except Exception as e:
661
+ logger.error("[exploit_mgr] Error during ExploitPluginManager cleanup: %s", str(e))
662
+
663
+ def reload_plugin(self, plugin_name):
664
+ """
665
+ Reload a plugin after its code has been modified.
666
+
667
+ Args:
668
+ plugin_name (str): The name of the plugin to reload
669
+
670
+ Returns:
671
+ bool: True if the plugin was successfully reloaded, False otherwise
672
+ """
673
+ try:
674
+ # Refresh registry from repo (also re-discovers plugins on disk)
675
+ self.load_plugins()
676
+ meta = self.plugin_registry.get(plugin_name)
677
+ if not meta:
678
+ logger.error("[exploit_mgr] Plugin %s not found in registry", plugin_name)
679
+ return False
680
+
681
+ file_or_module = meta.get("file_or_module")
682
+ class_name = meta.get("class_name")
683
+
684
+ # Reload the module
685
+ try:
686
+ # First, unregister the existing plugin
687
+ if plugin_name in self.plugins:
688
+ try:
689
+ # best-effort cleanup
690
+ old_inst = self.plugins[plugin_name]
691
+ if hasattr(old_inst, 'cleanup'):
692
+ old_inst.cleanup()
693
+ except Exception:
694
+ pass
695
+ self.pm.unregister(self.plugins[plugin_name])
696
+ del self.plugins[plugin_name]
697
+
698
+ # Reload via file-path or dotted import
699
+ plugin_class = None
700
+ if file_or_module and os.path.isabs(file_or_module):
701
+ temp_mod_name = f"exploit_runtime_{hash(file_or_module)}"
702
+ spec = importlib.util.spec_from_file_location(temp_mod_name, file_or_module)
703
+ if spec is None or spec.loader is None:
704
+ raise ImportError(f"Failed to create spec for {file_or_module}")
705
+ module = importlib.util.module_from_spec(spec)
706
+ importlib.invalidate_caches()
707
+ spec.loader.exec_module(module)
708
+ plugin_class = getattr(module, class_name)
709
+ else:
710
+ module_name = file_or_module.rsplit(".", 1)[0]
711
+ module = importlib.import_module(module_name)
712
+ importlib.reload(module)
713
+ plugin_class = getattr(module, class_name)
714
+
715
+ plugin_instance = plugin_class() # type: ignore[operator]
716
+ self.pm.register(plugin_instance)
717
+ # Do NOT call initialize() here.
718
+ # Initialization (and backend injection) happens right before execution via _ensure_context_injected().
719
+ self.plugins[plugin_name] = plugin_instance
720
+
721
+ logger.debug("[exploit_mgr] Successfully reloaded plugin: %s", plugin_name)
722
+ return True
723
+ except Exception as e:
724
+ logger.error("[exploit_mgr] Error reloading plugin %s: %s", plugin_name, str(e))
725
+ return False
726
+ except Exception as e:
727
+ logger.error("[exploit_mgr] Error in reload_plugin: %s", str(e))
728
+ return False