souleyez 2.43.29__py3-none-any.whl → 2.43.34__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 (358) hide show
  1. souleyez/__init__.py +1 -2
  2. souleyez/ai/__init__.py +21 -15
  3. souleyez/ai/action_mapper.py +249 -150
  4. souleyez/ai/chain_advisor.py +116 -100
  5. souleyez/ai/claude_provider.py +29 -28
  6. souleyez/ai/context_builder.py +80 -62
  7. souleyez/ai/executor.py +158 -117
  8. souleyez/ai/feedback_handler.py +136 -121
  9. souleyez/ai/llm_factory.py +27 -20
  10. souleyez/ai/llm_provider.py +4 -2
  11. souleyez/ai/ollama_provider.py +6 -9
  12. souleyez/ai/ollama_service.py +44 -37
  13. souleyez/ai/path_scorer.py +91 -76
  14. souleyez/ai/recommender.py +176 -144
  15. souleyez/ai/report_context.py +74 -73
  16. souleyez/ai/report_service.py +84 -66
  17. souleyez/ai/result_parser.py +222 -229
  18. souleyez/ai/safety.py +67 -44
  19. souleyez/auth/__init__.py +23 -22
  20. souleyez/auth/audit.py +36 -26
  21. souleyez/auth/engagement_access.py +65 -48
  22. souleyez/auth/permissions.py +14 -3
  23. souleyez/auth/session_manager.py +54 -37
  24. souleyez/auth/user_manager.py +109 -64
  25. souleyez/commands/audit.py +40 -43
  26. souleyez/commands/auth.py +35 -15
  27. souleyez/commands/deliverables.py +55 -50
  28. souleyez/commands/engagement.py +47 -28
  29. souleyez/commands/license.py +32 -23
  30. souleyez/commands/screenshots.py +36 -32
  31. souleyez/commands/user.py +82 -36
  32. souleyez/config.py +52 -44
  33. souleyez/core/credential_tester.py +87 -81
  34. souleyez/core/cve_mappings.py +179 -192
  35. souleyez/core/cve_matcher.py +162 -148
  36. souleyez/core/msf_auto_mapper.py +100 -83
  37. souleyez/core/msf_chain_engine.py +294 -256
  38. souleyez/core/msf_database.py +153 -70
  39. souleyez/core/msf_integration.py +679 -673
  40. souleyez/core/msf_rpc_client.py +40 -42
  41. souleyez/core/msf_rpc_manager.py +77 -79
  42. souleyez/core/msf_sync_manager.py +241 -181
  43. souleyez/core/network_utils.py +22 -15
  44. souleyez/core/parser_handler.py +34 -25
  45. souleyez/core/pending_chains.py +114 -63
  46. souleyez/core/templates.py +158 -107
  47. souleyez/core/tool_chaining.py +9526 -2879
  48. souleyez/core/version_utils.py +79 -94
  49. souleyez/core/vuln_correlation.py +136 -89
  50. souleyez/core/web_utils.py +33 -32
  51. souleyez/data/wordlists/ad_users.txt +378 -0
  52. souleyez/data/wordlists/api_endpoints_large.txt +769 -0
  53. souleyez/data/wordlists/home_dir_sensitive.txt +39 -0
  54. souleyez/data/wordlists/lfi_payloads.txt +82 -0
  55. souleyez/data/wordlists/passwords_brute.txt +1548 -0
  56. souleyez/data/wordlists/passwords_crack.txt +2479 -0
  57. souleyez/data/wordlists/passwords_spray.txt +386 -0
  58. souleyez/data/wordlists/subdomains_large.txt +5057 -0
  59. souleyez/data/wordlists/usernames_common.txt +694 -0
  60. souleyez/data/wordlists/web_dirs_large.txt +4769 -0
  61. souleyez/detection/__init__.py +1 -1
  62. souleyez/detection/attack_signatures.py +12 -17
  63. souleyez/detection/mitre_mappings.py +61 -55
  64. souleyez/detection/validator.py +97 -86
  65. souleyez/devtools.py +23 -10
  66. souleyez/docs/README.md +4 -4
  67. souleyez/docs/api-reference/cli-commands.md +2 -2
  68. souleyez/docs/developer-guide/adding-new-tools.md +562 -0
  69. souleyez/docs/user-guide/auto-chaining.md +30 -8
  70. souleyez/docs/user-guide/getting-started.md +1 -1
  71. souleyez/docs/user-guide/installation.md +26 -3
  72. souleyez/docs/user-guide/metasploit-integration.md +2 -2
  73. souleyez/docs/user-guide/rbac.md +1 -1
  74. souleyez/docs/user-guide/scope-management.md +1 -1
  75. souleyez/docs/user-guide/siem-integration.md +1 -1
  76. souleyez/docs/user-guide/tools-reference.md +1 -8
  77. souleyez/docs/user-guide/worker-management.md +1 -1
  78. souleyez/engine/background.py +1239 -535
  79. souleyez/engine/base.py +4 -1
  80. souleyez/engine/job_status.py +17 -49
  81. souleyez/engine/log_sanitizer.py +103 -77
  82. souleyez/engine/manager.py +38 -7
  83. souleyez/engine/result_handler.py +2200 -1550
  84. souleyez/engine/worker_manager.py +50 -41
  85. souleyez/export/evidence_bundle.py +72 -62
  86. souleyez/feature_flags/features.py +16 -20
  87. souleyez/feature_flags.py +5 -9
  88. souleyez/handlers/__init__.py +11 -0
  89. souleyez/handlers/base.py +188 -0
  90. souleyez/handlers/bash_handler.py +277 -0
  91. souleyez/handlers/bloodhound_handler.py +243 -0
  92. souleyez/handlers/certipy_handler.py +311 -0
  93. souleyez/handlers/crackmapexec_handler.py +486 -0
  94. souleyez/handlers/dnsrecon_handler.py +344 -0
  95. souleyez/handlers/enum4linux_handler.py +400 -0
  96. souleyez/handlers/evil_winrm_handler.py +493 -0
  97. souleyez/handlers/ffuf_handler.py +815 -0
  98. souleyez/handlers/gobuster_handler.py +1114 -0
  99. souleyez/handlers/gpp_extract_handler.py +334 -0
  100. souleyez/handlers/hashcat_handler.py +444 -0
  101. souleyez/handlers/hydra_handler.py +563 -0
  102. souleyez/handlers/impacket_getuserspns_handler.py +343 -0
  103. souleyez/handlers/impacket_psexec_handler.py +222 -0
  104. souleyez/handlers/impacket_secretsdump_handler.py +426 -0
  105. souleyez/handlers/john_handler.py +286 -0
  106. souleyez/handlers/katana_handler.py +425 -0
  107. souleyez/handlers/kerbrute_handler.py +298 -0
  108. souleyez/handlers/ldapsearch_handler.py +636 -0
  109. souleyez/handlers/lfi_extract_handler.py +464 -0
  110. souleyez/handlers/msf_auxiliary_handler.py +408 -0
  111. souleyez/handlers/msf_exploit_handler.py +380 -0
  112. souleyez/handlers/nikto_handler.py +413 -0
  113. souleyez/handlers/nmap_handler.py +821 -0
  114. souleyez/handlers/nuclei_handler.py +359 -0
  115. souleyez/handlers/nxc_handler.py +371 -0
  116. souleyez/handlers/rdp_sec_check_handler.py +353 -0
  117. souleyez/handlers/registry.py +292 -0
  118. souleyez/handlers/responder_handler.py +232 -0
  119. souleyez/handlers/service_explorer_handler.py +434 -0
  120. souleyez/handlers/smbclient_handler.py +344 -0
  121. souleyez/handlers/smbmap_handler.py +510 -0
  122. souleyez/handlers/smbpasswd_handler.py +296 -0
  123. souleyez/handlers/sqlmap_handler.py +1116 -0
  124. souleyez/handlers/theharvester_handler.py +601 -0
  125. souleyez/handlers/web_login_test_handler.py +327 -0
  126. souleyez/handlers/whois_handler.py +277 -0
  127. souleyez/handlers/wpscan_handler.py +554 -0
  128. souleyez/history.py +32 -16
  129. souleyez/importers/msf_importer.py +106 -75
  130. souleyez/importers/smart_importer.py +208 -147
  131. souleyez/integrations/siem/__init__.py +10 -10
  132. souleyez/integrations/siem/base.py +17 -18
  133. souleyez/integrations/siem/elastic.py +108 -122
  134. souleyez/integrations/siem/factory.py +207 -80
  135. souleyez/integrations/siem/googlesecops.py +146 -154
  136. souleyez/integrations/siem/rule_mappings/__init__.py +1 -1
  137. souleyez/integrations/siem/rule_mappings/wazuh_rules.py +8 -5
  138. souleyez/integrations/siem/sentinel.py +107 -109
  139. souleyez/integrations/siem/splunk.py +246 -212
  140. souleyez/integrations/siem/wazuh.py +65 -71
  141. souleyez/integrations/wazuh/__init__.py +5 -5
  142. souleyez/integrations/wazuh/client.py +70 -93
  143. souleyez/integrations/wazuh/config.py +85 -57
  144. souleyez/integrations/wazuh/host_mapper.py +28 -36
  145. souleyez/integrations/wazuh/sync.py +78 -68
  146. souleyez/intelligence/__init__.py +4 -5
  147. souleyez/intelligence/correlation_analyzer.py +309 -295
  148. souleyez/intelligence/exploit_knowledge.py +661 -623
  149. souleyez/intelligence/exploit_suggestions.py +159 -139
  150. souleyez/intelligence/gap_analyzer.py +132 -97
  151. souleyez/intelligence/gap_detector.py +251 -214
  152. souleyez/intelligence/sensitive_tables.py +266 -129
  153. souleyez/intelligence/service_parser.py +137 -123
  154. souleyez/intelligence/surface_analyzer.py +407 -268
  155. souleyez/intelligence/target_parser.py +159 -162
  156. souleyez/licensing/__init__.py +6 -6
  157. souleyez/licensing/validator.py +17 -19
  158. souleyez/log_config.py +79 -54
  159. souleyez/main.py +1505 -687
  160. souleyez/migrations/fix_job_counter.py +16 -14
  161. souleyez/parsers/bloodhound_parser.py +41 -39
  162. souleyez/parsers/crackmapexec_parser.py +178 -111
  163. souleyez/parsers/dalfox_parser.py +72 -77
  164. souleyez/parsers/dnsrecon_parser.py +103 -91
  165. souleyez/parsers/enum4linux_parser.py +183 -153
  166. souleyez/parsers/ffuf_parser.py +29 -25
  167. souleyez/parsers/gobuster_parser.py +301 -41
  168. souleyez/parsers/hashcat_parser.py +324 -79
  169. souleyez/parsers/http_fingerprint_parser.py +350 -103
  170. souleyez/parsers/hydra_parser.py +131 -111
  171. souleyez/parsers/impacket_parser.py +231 -178
  172. souleyez/parsers/john_parser.py +98 -86
  173. souleyez/parsers/katana_parser.py +316 -0
  174. souleyez/parsers/msf_parser.py +943 -498
  175. souleyez/parsers/nikto_parser.py +346 -65
  176. souleyez/parsers/nmap_parser.py +262 -174
  177. souleyez/parsers/nuclei_parser.py +40 -44
  178. souleyez/parsers/responder_parser.py +26 -26
  179. souleyez/parsers/searchsploit_parser.py +74 -74
  180. souleyez/parsers/service_explorer_parser.py +279 -0
  181. souleyez/parsers/smbmap_parser.py +180 -124
  182. souleyez/parsers/sqlmap_parser.py +434 -308
  183. souleyez/parsers/theharvester_parser.py +75 -57
  184. souleyez/parsers/whois_parser.py +135 -94
  185. souleyez/parsers/wpscan_parser.py +278 -190
  186. souleyez/plugins/afp.py +44 -36
  187. souleyez/plugins/afp_brute.py +114 -46
  188. souleyez/plugins/ard.py +48 -37
  189. souleyez/plugins/bloodhound.py +95 -61
  190. souleyez/plugins/certipy.py +303 -0
  191. souleyez/plugins/crackmapexec.py +186 -85
  192. souleyez/plugins/dalfox.py +120 -59
  193. souleyez/plugins/dns_hijack.py +146 -41
  194. souleyez/plugins/dnsrecon.py +97 -61
  195. souleyez/plugins/enum4linux.py +91 -66
  196. souleyez/plugins/evil_winrm.py +291 -0
  197. souleyez/plugins/ffuf.py +166 -90
  198. souleyez/plugins/firmware_extract.py +133 -29
  199. souleyez/plugins/gobuster.py +387 -190
  200. souleyez/plugins/gpp_extract.py +393 -0
  201. souleyez/plugins/hashcat.py +100 -73
  202. souleyez/plugins/http_fingerprint.py +854 -267
  203. souleyez/plugins/hydra.py +566 -200
  204. souleyez/plugins/impacket_getnpusers.py +117 -69
  205. souleyez/plugins/impacket_psexec.py +84 -64
  206. souleyez/plugins/impacket_secretsdump.py +103 -69
  207. souleyez/plugins/impacket_smbclient.py +89 -75
  208. souleyez/plugins/john.py +86 -69
  209. souleyez/plugins/katana.py +313 -0
  210. souleyez/plugins/kerbrute.py +237 -0
  211. souleyez/plugins/lfi_extract.py +541 -0
  212. souleyez/plugins/macos_ssh.py +117 -48
  213. souleyez/plugins/mdns.py +35 -30
  214. souleyez/plugins/msf_auxiliary.py +253 -130
  215. souleyez/plugins/msf_exploit.py +239 -161
  216. souleyez/plugins/nikto.py +134 -78
  217. souleyez/plugins/nmap.py +275 -91
  218. souleyez/plugins/nuclei.py +180 -89
  219. souleyez/plugins/nxc.py +285 -0
  220. souleyez/plugins/plugin_base.py +35 -36
  221. souleyez/plugins/plugin_template.py +13 -5
  222. souleyez/plugins/rdp_sec_check.py +130 -0
  223. souleyez/plugins/responder.py +112 -71
  224. souleyez/plugins/router_http_brute.py +76 -65
  225. souleyez/plugins/router_ssh_brute.py +118 -41
  226. souleyez/plugins/router_telnet_brute.py +124 -42
  227. souleyez/plugins/routersploit.py +91 -59
  228. souleyez/plugins/routersploit_exploit.py +77 -55
  229. souleyez/plugins/searchsploit.py +91 -77
  230. souleyez/plugins/service_explorer.py +1160 -0
  231. souleyez/plugins/smbmap.py +122 -72
  232. souleyez/plugins/smbpasswd.py +215 -0
  233. souleyez/plugins/sqlmap.py +301 -113
  234. souleyez/plugins/theharvester.py +127 -75
  235. souleyez/plugins/tr069.py +79 -57
  236. souleyez/plugins/upnp.py +65 -47
  237. souleyez/plugins/upnp_abuse.py +73 -55
  238. souleyez/plugins/vnc_access.py +129 -42
  239. souleyez/plugins/vnc_brute.py +109 -38
  240. souleyez/plugins/web_login_test.py +417 -0
  241. souleyez/plugins/whois.py +77 -58
  242. souleyez/plugins/wpscan.py +173 -69
  243. souleyez/reporting/__init__.py +2 -1
  244. souleyez/reporting/attack_chain.py +411 -346
  245. souleyez/reporting/charts.py +436 -501
  246. souleyez/reporting/compliance_mappings.py +334 -201
  247. souleyez/reporting/detection_report.py +126 -125
  248. souleyez/reporting/formatters.py +828 -591
  249. souleyez/reporting/generator.py +386 -302
  250. souleyez/reporting/metrics.py +72 -75
  251. souleyez/scanner.py +35 -29
  252. souleyez/security/__init__.py +37 -11
  253. souleyez/security/scope_validator.py +175 -106
  254. souleyez/security/validation.py +223 -149
  255. souleyez/security.py +22 -6
  256. souleyez/storage/credentials.py +247 -186
  257. souleyez/storage/crypto.py +296 -129
  258. souleyez/storage/database.py +73 -50
  259. souleyez/storage/db.py +58 -36
  260. souleyez/storage/deliverable_evidence.py +177 -128
  261. souleyez/storage/deliverable_exporter.py +282 -246
  262. souleyez/storage/deliverable_templates.py +134 -116
  263. souleyez/storage/deliverables.py +135 -130
  264. souleyez/storage/engagements.py +109 -56
  265. souleyez/storage/evidence.py +181 -152
  266. souleyez/storage/execution_log.py +31 -17
  267. souleyez/storage/exploit_attempts.py +93 -57
  268. souleyez/storage/exploits.py +67 -36
  269. souleyez/storage/findings.py +48 -61
  270. souleyez/storage/hosts.py +176 -144
  271. souleyez/storage/migrate_to_engagements.py +43 -19
  272. souleyez/storage/migrations/_001_add_credential_enhancements.py +22 -12
  273. souleyez/storage/migrations/_002_add_status_tracking.py +10 -7
  274. souleyez/storage/migrations/_003_add_execution_log.py +14 -8
  275. souleyez/storage/migrations/_005_screenshots.py +13 -5
  276. souleyez/storage/migrations/_006_deliverables.py +13 -5
  277. souleyez/storage/migrations/_007_deliverable_templates.py +12 -7
  278. souleyez/storage/migrations/_008_add_nuclei_table.py +10 -4
  279. souleyez/storage/migrations/_010_evidence_linking.py +17 -10
  280. souleyez/storage/migrations/_011_timeline_tracking.py +20 -13
  281. souleyez/storage/migrations/_012_team_collaboration.py +34 -21
  282. souleyez/storage/migrations/_013_add_host_tags.py +12 -6
  283. souleyez/storage/migrations/_014_exploit_attempts.py +22 -10
  284. souleyez/storage/migrations/_015_add_mac_os_fields.py +15 -7
  285. souleyez/storage/migrations/_016_add_domain_field.py +10 -4
  286. souleyez/storage/migrations/_017_msf_sessions.py +16 -8
  287. souleyez/storage/migrations/_018_add_osint_target.py +10 -6
  288. souleyez/storage/migrations/_019_add_engagement_type.py +10 -6
  289. souleyez/storage/migrations/_020_add_rbac.py +36 -15
  290. souleyez/storage/migrations/_021_wazuh_integration.py +20 -8
  291. souleyez/storage/migrations/_022_wazuh_indexer_columns.py +6 -4
  292. souleyez/storage/migrations/_023_fix_detection_results_fk.py +16 -6
  293. souleyez/storage/migrations/_024_wazuh_vulnerabilities.py +26 -10
  294. souleyez/storage/migrations/_025_multi_siem_support.py +3 -5
  295. souleyez/storage/migrations/_026_add_engagement_scope.py +31 -12
  296. souleyez/storage/migrations/_027_multi_siem_persistence.py +32 -15
  297. souleyez/storage/migrations/__init__.py +26 -26
  298. souleyez/storage/migrations/migration_manager.py +19 -19
  299. souleyez/storage/msf_sessions.py +100 -65
  300. souleyez/storage/osint.py +17 -24
  301. souleyez/storage/recommendation_engine.py +269 -235
  302. souleyez/storage/screenshots.py +33 -32
  303. souleyez/storage/smb_shares.py +136 -92
  304. souleyez/storage/sqlmap_data.py +183 -128
  305. souleyez/storage/team_collaboration.py +135 -141
  306. souleyez/storage/timeline_tracker.py +122 -94
  307. souleyez/storage/wazuh_vulns.py +64 -66
  308. souleyez/storage/web_paths.py +33 -37
  309. souleyez/testing/credential_tester.py +221 -205
  310. souleyez/ui/__init__.py +1 -1
  311. souleyez/ui/ai_quotes.py +12 -12
  312. souleyez/ui/attack_surface.py +2439 -1516
  313. souleyez/ui/chain_rules_view.py +914 -382
  314. souleyez/ui/correlation_view.py +312 -230
  315. souleyez/ui/dashboard.py +2382 -1130
  316. souleyez/ui/deliverables_view.py +148 -62
  317. souleyez/ui/design_system.py +13 -13
  318. souleyez/ui/errors.py +49 -49
  319. souleyez/ui/evidence_linking_view.py +284 -179
  320. souleyez/ui/evidence_vault.py +393 -285
  321. souleyez/ui/exploit_suggestions_view.py +555 -349
  322. souleyez/ui/export_view.py +100 -66
  323. souleyez/ui/gap_analysis_view.py +315 -171
  324. souleyez/ui/help_system.py +105 -97
  325. souleyez/ui/intelligence_view.py +436 -293
  326. souleyez/ui/interactive.py +22827 -10678
  327. souleyez/ui/interactive_selector.py +75 -68
  328. souleyez/ui/log_formatter.py +47 -39
  329. souleyez/ui/menu_components.py +22 -13
  330. souleyez/ui/msf_auxiliary_menu.py +184 -133
  331. souleyez/ui/pending_chains_view.py +336 -172
  332. souleyez/ui/progress_indicators.py +5 -3
  333. souleyez/ui/recommendations_view.py +195 -137
  334. souleyez/ui/rule_builder.py +343 -225
  335. souleyez/ui/setup_wizard.py +678 -284
  336. souleyez/ui/shortcuts.py +217 -165
  337. souleyez/ui/splunk_gap_analysis_view.py +452 -270
  338. souleyez/ui/splunk_vulns_view.py +139 -86
  339. souleyez/ui/team_dashboard.py +498 -335
  340. souleyez/ui/template_selector.py +196 -105
  341. souleyez/ui/terminal.py +6 -6
  342. souleyez/ui/timeline_view.py +198 -127
  343. souleyez/ui/tool_setup.py +264 -164
  344. souleyez/ui/tutorial.py +202 -72
  345. souleyez/ui/tutorial_state.py +40 -40
  346. souleyez/ui/wazuh_vulns_view.py +235 -141
  347. souleyez/ui/wordlist_browser.py +260 -107
  348. souleyez/ui.py +464 -312
  349. souleyez/utils/tool_checker.py +427 -367
  350. souleyez/utils.py +33 -29
  351. souleyez/wordlists.py +134 -167
  352. {souleyez-2.43.29.dist-info → souleyez-2.43.34.dist-info}/METADATA +1 -1
  353. souleyez-2.43.34.dist-info/RECORD +443 -0
  354. {souleyez-2.43.29.dist-info → souleyez-2.43.34.dist-info}/WHEEL +1 -1
  355. souleyez-2.43.29.dist-info/RECORD +0 -379
  356. {souleyez-2.43.29.dist-info → souleyez-2.43.34.dist-info}/entry_points.txt +0 -0
  357. {souleyez-2.43.29.dist-info → souleyez-2.43.34.dist-info}/licenses/LICENSE +0 -0
  358. {souleyez-2.43.29.dist-info → souleyez-2.43.34.dist-info}/top_level.txt +0 -0
souleyez/main.py CHANGED
@@ -16,7 +16,13 @@ from rich.console import Console
16
16
  from rich.table import Table
17
17
 
18
18
  try:
19
- from souleyez.engine.background import enqueue_job, list_jobs, get_job, start_worker, worker_loop
19
+ from souleyez.engine.background import (
20
+ enqueue_job,
21
+ list_jobs,
22
+ get_job,
23
+ start_worker,
24
+ worker_loop,
25
+ )
20
26
  from souleyez.storage.engagements import EngagementManager
21
27
  from souleyez.ui.interactive import run_interactive_menu
22
28
  from souleyez.ui.dashboard import run_dashboard
@@ -29,10 +35,10 @@ def _check_prerequisites():
29
35
  """Check for required system tools and warn if missing."""
30
36
  missing = []
31
37
 
32
- if not shutil.which('curl'):
33
- missing.append('curl')
34
- if not shutil.which('pip3'):
35
- missing.append('python3-pip')
38
+ if not shutil.which("curl"):
39
+ missing.append("curl")
40
+ if not shutil.which("pip3"):
41
+ missing.append("python3-pip")
36
42
 
37
43
  if missing:
38
44
  click.secho("⚠️ Missing prerequisites:", fg="yellow", bold=True)
@@ -54,11 +60,12 @@ def _check_first_run_setup():
54
60
  return
55
61
 
56
62
  # Skip if running the setup command itself
57
- if len(sys.argv) > 1 and sys.argv[1] in ('setup', '--version', '--help'):
63
+ if len(sys.argv) > 1 and sys.argv[1] in ("setup", "--version", "--help"):
58
64
  return
59
65
 
60
66
  try:
61
67
  from souleyez.utils.tool_checker import get_tool_stats
68
+
62
69
  installed, total = get_tool_stats()
63
70
 
64
71
  # If less than 3 tools installed, prompt user
@@ -68,7 +75,9 @@ def _check_first_run_setup():
68
75
  click.secho(" FIRST RUN DETECTED", fg="cyan", bold=True)
69
76
  click.secho("=" * 60, fg="cyan")
70
77
  click.echo()
71
- click.echo(f" SoulEyez wraps 40+ pentesting tools, but only {installed}/{total}")
78
+ click.echo(
79
+ f" SoulEyez wraps 40+ pentesting tools, but only {installed}/{total}"
80
+ )
72
81
  click.echo(" tools are currently installed on your system.")
73
82
  click.echo()
74
83
  click.echo(" Run the setup wizard to install tools like nmap, sqlmap,")
@@ -83,6 +92,7 @@ def _check_first_run_setup():
83
92
 
84
93
  # Import and run setup
85
94
  from souleyez.ui.tool_setup import run_tool_setup
95
+
86
96
  run_tool_setup(check_only=False, install_all=False)
87
97
  sys.exit(0)
88
98
  else:
@@ -104,7 +114,7 @@ def _check_privileged_tools():
104
114
  import subprocess
105
115
 
106
116
  # Skip if running setup or help commands
107
- if len(sys.argv) > 1 and sys.argv[1] in ('setup', '--version', '--help'):
117
+ if len(sys.argv) > 1 and sys.argv[1] in ("setup", "--version", "--help"):
108
118
  return
109
119
 
110
120
  marker_file = Path.home() / ".souleyez" / ".sudoers_declined"
@@ -114,18 +124,16 @@ def _check_privileged_tools():
114
124
  return
115
125
 
116
126
  # Check if nmap is installed and needs configuration
117
- nmap_path = shutil.which('nmap')
127
+ nmap_path = shutil.which("nmap")
118
128
  if not nmap_path:
119
129
  return
120
130
 
121
131
  # Check if passwordless sudo already works
122
132
  # First clear any cached sudo credentials so we test the actual config
123
133
  try:
124
- subprocess.run(['sudo', '-k'], capture_output=True, timeout=5)
134
+ subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
125
135
  result = subprocess.run(
126
- ['sudo', '-n', nmap_path, '--version'],
127
- capture_output=True,
128
- timeout=5
136
+ ["sudo", "-n", nmap_path, "--version"], capture_output=True, timeout=5
129
137
  )
130
138
  if result.returncode == 0:
131
139
  return # Already configured
@@ -151,6 +159,7 @@ def _check_privileged_tools():
151
159
 
152
160
  try:
153
161
  import subprocess
162
+
154
163
  # Use printf to ensure newline at end (required by sudoers parser)
155
164
  # echo should add newline but some environments strip it
156
165
  cmd = f"printf '%s\\n' '{sudoers_line}' | sudo tee {sudoers_file} > /dev/null && sudo chmod 0440 {sudoers_file}"
@@ -159,7 +168,10 @@ def _check_privileged_tools():
159
168
  if proc.returncode == 0:
160
169
  click.secho(" ✓ Configured! Privileged scans now work.", fg="green")
161
170
  else:
162
- click.secho(" ✗ Failed to configure. Run 'souleyez setup --fix-permissions'", fg="red")
171
+ click.secho(
172
+ " ✗ Failed to configure. Run 'souleyez setup --fix-permissions'",
173
+ fg="red",
174
+ )
163
175
  except Exception as e:
164
176
  click.secho(f" ✗ Error: {e}", fg="red")
165
177
  click.echo()
@@ -173,16 +185,18 @@ def _check_privileged_tools():
173
185
 
174
186
 
175
187
  @click.group()
176
- @click.version_option(version='2.43.29')
188
+ @click.version_option(version="2.43.34")
177
189
  def cli():
178
190
  """SoulEyez - AI-Powered Pentesting Platform by CyberSoul Security"""
179
191
  from souleyez.log_config import init_logging
192
+
180
193
  init_logging()
181
194
 
182
195
  # Initialize auth system for CLI commands
183
196
  try:
184
197
  from souleyez.auth import init_auth
185
198
  from souleyez.storage.database import get_db
199
+
186
200
  init_auth(get_db().db_path)
187
201
  except Exception:
188
202
  pass # Auth not required for all commands (e.g., --help, --version)
@@ -199,6 +213,7 @@ def cli():
199
213
  # Ensure user has local copy of wordlists
200
214
  try:
201
215
  from souleyez.wordlists import ensure_user_wordlists
216
+
202
217
  ensure_user_wordlists()
203
218
  except ImportError:
204
219
  pass
@@ -211,9 +226,17 @@ def interactive():
211
226
 
212
227
 
213
228
  @cli.command()
214
- @click.option("--check", "-c", is_flag=True, help="Only check tool status, don't install")
215
- @click.option("--install-all", "-a", is_flag=True, help="Install all missing tools automatically")
216
- @click.option("--fix-permissions", is_flag=True, help="Configure tools for privileged operations (nmap, responder)")
229
+ @click.option(
230
+ "--check", "-c", is_flag=True, help="Only check tool status, don't install"
231
+ )
232
+ @click.option(
233
+ "--install-all", "-a", is_flag=True, help="Install all missing tools automatically"
234
+ )
235
+ @click.option(
236
+ "--fix-permissions",
237
+ is_flag=True,
238
+ help="Configure tools for privileged operations (nmap, responder)",
239
+ )
217
240
  def setup(check, install_all, fix_permissions):
218
241
  """Install and configure pentesting tools for your system.
219
242
 
@@ -236,22 +259,23 @@ def setup(check, install_all, fix_permissions):
236
259
  _fix_tool_permissions()
237
260
  return
238
261
  from souleyez.ui.tool_setup import run_tool_setup
262
+
239
263
  run_tool_setup(check_only=check, install_all=install_all)
240
264
 
241
265
 
242
266
  # Privileged tools configuration - binary tools
243
- PRIVILEGED_BINARY_TOOLS = ['nmap']
267
+ PRIVILEGED_BINARY_TOOLS = ["nmap"]
244
268
 
245
269
  # Privileged tools configuration - script-based tools
246
270
  PRIVILEGED_SCRIPT_TOOLS = {
247
- 'responder': {
248
- 'interpreter': '/usr/bin/python3',
249
- 'script_paths': [
250
- '/usr/share/responder/Responder.py',
251
- '/opt/Responder/Responder.py',
252
- str(Path.home() / 'tools/Responder/Responder.py'),
271
+ "responder": {
272
+ "interpreter": "/usr/bin/python3",
273
+ "script_paths": [
274
+ "/usr/share/responder/Responder.py",
275
+ "/opt/Responder/Responder.py",
276
+ str(Path.home() / "tools/Responder/Responder.py"),
253
277
  ],
254
- 'description': 'LLMNR/NBT-NS credential capture'
278
+ "description": "LLMNR/NBT-NS credential capture",
255
279
  }
256
280
  }
257
281
 
@@ -269,7 +293,7 @@ def _fix_tool_permissions():
269
293
  import subprocess
270
294
 
271
295
  click.echo()
272
- click.echo(click.style(" PRIVILEGED TOOL SETUP", bold=True, fg='cyan'))
296
+ click.echo(click.style(" PRIVILEGED TOOL SETUP", bold=True, fg="cyan"))
273
297
  click.echo(" " + "─" * 50)
274
298
  click.echo()
275
299
  click.echo(" Configuring passwordless sudo for security tools.")
@@ -293,11 +317,9 @@ def _fix_tool_permissions():
293
317
 
294
318
  # Check if passwordless sudo already works
295
319
  try:
296
- subprocess.run(['sudo', '-k'], capture_output=True, timeout=5)
320
+ subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
297
321
  result = subprocess.run(
298
- ['sudo', '-n', tool_path, '--version'],
299
- capture_output=True,
300
- timeout=5
322
+ ["sudo", "-n", tool_path, "--version"], capture_output=True, timeout=5
301
323
  )
302
324
  if result.returncode == 0:
303
325
  binary_tools_already_fixed.append(tool_name)
@@ -308,25 +330,29 @@ def _fix_tool_permissions():
308
330
 
309
331
  # Check script-based tools
310
332
  for tool_name, tool_info in PRIVILEGED_SCRIPT_TOOLS.items():
311
- script_path = _find_script_path(tool_info['script_paths'])
333
+ script_path = _find_script_path(tool_info["script_paths"])
312
334
  if not script_path:
313
335
  script_tools_not_installed.append(tool_name)
314
336
  continue
315
337
 
316
- interpreter = tool_info['interpreter']
338
+ interpreter = tool_info["interpreter"]
317
339
  try:
318
- subprocess.run(['sudo', '-k'], capture_output=True, timeout=5)
340
+ subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
319
341
  result = subprocess.run(
320
- ['sudo', '-n', interpreter, script_path, '--help'],
342
+ ["sudo", "-n", interpreter, script_path, "--help"],
321
343
  capture_output=True,
322
- timeout=5
344
+ timeout=5,
323
345
  )
324
346
  if result.returncode == 0:
325
347
  script_tools_already_fixed.append(tool_name)
326
348
  else:
327
- script_tools_to_fix.append((tool_name, interpreter, script_path, tool_info['description']))
349
+ script_tools_to_fix.append(
350
+ (tool_name, interpreter, script_path, tool_info["description"])
351
+ )
328
352
  except Exception:
329
- script_tools_to_fix.append((tool_name, interpreter, script_path, tool_info['description']))
353
+ script_tools_to_fix.append(
354
+ (tool_name, interpreter, script_path, tool_info["description"])
355
+ )
330
356
 
331
357
  # Show status
332
358
  click.echo(" " + click.style("STATUS:", bold=True))
@@ -339,10 +365,14 @@ def _fix_tool_permissions():
339
365
  click.echo(f" {click.style('✓', fg='green')} {name} - already configured")
340
366
 
341
367
  for name in binary_tools_not_installed:
342
- click.echo(f" {click.style('○', fg='yellow')} {name} - not installed (skipping)")
368
+ click.echo(
369
+ f" {click.style('○', fg='yellow')} {name} - not installed (skipping)"
370
+ )
343
371
 
344
372
  for name in script_tools_not_installed:
345
- click.echo(f" {click.style('○', fg='yellow')} {name} - not installed (skipping)")
373
+ click.echo(
374
+ f" {click.style('○', fg='yellow')} {name} - not installed (skipping)"
375
+ )
346
376
 
347
377
  for name, _ in binary_tools_to_fix:
348
378
  click.echo(f" {click.style('✗', fg='red')} {name} - needs configuration")
@@ -353,7 +383,9 @@ def _fix_tool_permissions():
353
383
  click.echo()
354
384
 
355
385
  if not binary_tools_to_fix and not script_tools_to_fix:
356
- click.echo(click.style(" All installed tools are already configured!", fg='green'))
386
+ click.echo(
387
+ click.style(" All installed tools are already configured!", fg="green")
388
+ )
357
389
  return
358
390
 
359
391
  click.echo(" This requires sudo to configure /etc/sudoers.d/")
@@ -406,12 +438,22 @@ def _fix_tool_permissions():
406
438
  click.echo(f" {click.style('✗', fg='red')} Error: {e}")
407
439
 
408
440
  click.echo()
409
- click.echo(click.style(" Done! Privileged scans now work automatically.", fg='green'))
441
+ click.echo(
442
+ click.style(" Done! Privileged scans now work automatically.", fg="green")
443
+ )
410
444
 
411
445
 
412
446
  @cli.command()
413
- @click.option("--follow", "-f", type=int, default=None, help="Follow live output of job ID")
414
- @click.option("--refresh", "-r", type=int, default=15, help="Refresh interval in seconds (default: 15)")
447
+ @click.option(
448
+ "--follow", "-f", type=int, default=None, help="Follow live output of job ID"
449
+ )
450
+ @click.option(
451
+ "--refresh",
452
+ "-r",
453
+ type=int,
454
+ default=15,
455
+ help="Refresh interval in seconds (default: 15)",
456
+ )
415
457
  @require_password
416
458
  def dashboard(follow, refresh):
417
459
  """Launch live dashboard with real-time job status and findings."""
@@ -443,22 +485,26 @@ def engagement_list():
443
485
  em = EngagementManager()
444
486
  engagements = em.list()
445
487
  current = em.get_current()
446
-
488
+
447
489
  if not engagements:
448
- click.echo("No engagements found. Create one with: souleyez engagement create <name>")
490
+ click.echo(
491
+ "No engagements found. Create one with: souleyez engagement create <name>"
492
+ )
449
493
  return
450
-
494
+
451
495
  click.echo("\n" + "=" * 80)
452
496
  click.echo("ENGAGEMENTS")
453
497
  click.echo("=" * 80)
454
-
498
+
455
499
  for eng in engagements:
456
- marker = "* " if current and eng['id'] == current['id'] else " "
457
- stats = em.stats(eng['id'])
458
- click.echo(f"{marker}{eng['name']:<20} | Hosts: {stats['hosts']:>3} | Services: {stats['services']:>3} | Findings: {stats['findings']:>3}")
459
- if eng.get('description'):
500
+ marker = "* " if current and eng["id"] == current["id"] else " "
501
+ stats = em.stats(eng["id"])
502
+ click.echo(
503
+ f"{marker}{eng['name']:<20} | Hosts: {stats['hosts']:>3} | Services: {stats['services']:>3} | Findings: {stats['findings']:>3}"
504
+ )
505
+ if eng.get("description"):
460
506
  click.echo(f" └─ {eng['description']}")
461
-
507
+
462
508
  click.echo("=" * 80)
463
509
  if current:
464
510
  click.echo(f"Current: {current['name']}")
@@ -484,13 +530,13 @@ def engagement_current():
484
530
  """Show current engagement."""
485
531
  em = EngagementManager()
486
532
  current = em.get_current()
487
-
533
+
488
534
  if not current:
489
535
  click.echo("No engagement selected")
490
536
  return
491
-
492
- stats = em.stats(current['id'])
493
-
537
+
538
+ stats = em.stats(current["id"])
539
+
494
540
  click.echo("\n" + "=" * 60)
495
541
  click.echo(f"Current Engagement: {current['name']}")
496
542
  click.echo("=" * 60)
@@ -511,22 +557,22 @@ def engagement_delete(name, force):
511
557
  """Delete an engagement and all its data."""
512
558
  em = EngagementManager()
513
559
  eng = em.get(name)
514
-
560
+
515
561
  if not eng:
516
562
  click.echo(f"✗ Workspace '{name}' not found", err=True)
517
563
  return
518
-
564
+
519
565
  if not force:
520
- stats = em.stats(eng['id'])
566
+ stats = em.stats(eng["id"])
521
567
  click.echo(f"\nWarning: This will delete engagement '{name}' and:")
522
568
  click.echo(f" - {stats['hosts']} hosts")
523
569
  click.echo(f" - {stats['services']} services")
524
570
  click.echo(f" - {stats['findings']} findings")
525
-
571
+
526
572
  if not click.confirm("\nAre you sure?"):
527
573
  click.echo("Cancelled")
528
574
  return
529
-
575
+
530
576
  if em.delete(name):
531
577
  click.echo(f"✓ Deleted workspace '{name}'")
532
578
  else:
@@ -535,60 +581,62 @@ def engagement_delete(name, force):
535
581
 
536
582
  @engagement.command("delete-all")
537
583
  @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
538
- @click.option("--keep-current", is_flag=True, help="Keep the currently active engagement")
584
+ @click.option(
585
+ "--keep-current", is_flag=True, help="Keep the currently active engagement"
586
+ )
539
587
  def engagement_delete_all(force, keep_current):
540
588
  """Delete all engagements (except optionally the current one)."""
541
589
  em = EngagementManager()
542
590
  all_engagements = em.list()
543
-
591
+
544
592
  if not all_engagements:
545
593
  click.echo("No engagements to delete")
546
594
  return
547
-
595
+
548
596
  # Get current engagement if we're keeping it
549
597
  current = em.get_current() if keep_current else None
550
-
598
+
551
599
  # Filter engagements to delete
552
600
  to_delete = []
553
601
  for eng in all_engagements:
554
- if keep_current and current and eng['id'] == current['id']:
602
+ if keep_current and current and eng["id"] == current["id"]:
555
603
  continue
556
604
  to_delete.append(eng)
557
-
605
+
558
606
  if not to_delete:
559
607
  click.echo("No engagements to delete")
560
608
  return
561
-
609
+
562
610
  # Show what will be deleted
563
611
  click.echo(f"\n⚠️ Warning: This will delete {len(to_delete)} engagement(s):")
564
612
  for eng in to_delete[:10]: # Show first 10
565
613
  click.echo(f" - {eng['name']}")
566
-
614
+
567
615
  if len(to_delete) > 10:
568
616
  click.echo(f" ... and {len(to_delete) - 10} more")
569
-
617
+
570
618
  if keep_current and current:
571
619
  click.echo(f"\n✓ Will keep current engagement: {current['name']}")
572
-
620
+
573
621
  # Confirmation
574
622
  if not force:
575
623
  click.echo()
576
624
  if not click.confirm(f"Delete {len(to_delete)} engagement(s)?", default=False):
577
625
  click.echo("Cancelled")
578
626
  return
579
-
627
+
580
628
  # Delete engagements
581
629
  deleted = 0
582
630
  failed = 0
583
-
631
+
584
632
  click.echo()
585
- with click.progressbar(to_delete, label='Deleting engagements') as bar:
633
+ with click.progressbar(to_delete, label="Deleting engagements") as bar:
586
634
  for eng in bar:
587
- if em.delete(eng['name']):
635
+ if em.delete(eng["name"]):
588
636
  deleted += 1
589
637
  else:
590
638
  failed += 1
591
-
639
+
592
640
  click.echo()
593
641
  click.echo(f"✓ Deleted {deleted} engagement(s)")
594
642
  if failed > 0:
@@ -597,10 +645,12 @@ def engagement_delete_all(force, keep_current):
597
645
 
598
646
  # Register engagement team subcommand
599
647
  from souleyez.commands.engagement import team
648
+
600
649
  engagement.add_command(team)
601
650
 
602
651
  # Register audit commands
603
652
  from souleyez.commands.audit import audit
653
+
604
654
  cli.add_command(audit)
605
655
 
606
656
 
@@ -608,6 +658,7 @@ cli.add_command(audit)
608
658
  # SCOPE MANAGEMENT
609
659
  # ============================================================================
610
660
 
661
+
611
662
  @cli.group()
612
663
  def scope():
613
664
  """Engagement scope management - define and enforce target boundaries."""
@@ -621,7 +672,9 @@ def scope():
621
672
  @click.option("--url", help="Add URL (e.g., https://app.example.com)")
622
673
  @click.option("--hostname", help="Add specific hostname or IP")
623
674
  @click.option("--exclude", is_flag=True, help="Add as exclusion (deny rule)")
624
- @click.option("--description", "-d", default="", help="Description for this scope entry")
675
+ @click.option(
676
+ "--description", "-d", default="", help="Description for this scope entry"
677
+ )
625
678
  def scope_add(engagement_name, cidr, domain, url, hostname, exclude, description):
626
679
  """Add a scope entry to an engagement."""
627
680
  from souleyez.security.scope_validator import ScopeManager
@@ -636,24 +689,27 @@ def scope_add(engagement_name, cidr, domain, url, hostname, exclude, description
636
689
 
637
690
  # Determine scope type and value
638
691
  if cidr:
639
- scope_type, value = 'cidr', cidr
692
+ scope_type, value = "cidr", cidr
640
693
  elif domain:
641
- scope_type, value = 'domain', domain
694
+ scope_type, value = "domain", domain
642
695
  elif url:
643
- scope_type, value = 'url', url
696
+ scope_type, value = "url", url
644
697
  elif hostname:
645
- scope_type, value = 'hostname', hostname
698
+ scope_type, value = "hostname", hostname
646
699
  else:
647
- click.echo("Error: Must specify one of --cidr, --domain, --url, or --hostname", err=True)
700
+ click.echo(
701
+ "Error: Must specify one of --cidr, --domain, --url, or --hostname",
702
+ err=True,
703
+ )
648
704
  return
649
705
 
650
706
  try:
651
707
  scope_id = manager.add_scope(
652
- engagement_id=eng['id'],
708
+ engagement_id=eng["id"],
653
709
  scope_type=scope_type,
654
710
  value=value,
655
711
  is_excluded=exclude,
656
- description=description
712
+ description=description,
657
713
  )
658
714
  action = "exclusion" if exclude else "scope entry"
659
715
  click.echo(f"Added {action}: {scope_type}={value} (id={scope_id})")
@@ -674,8 +730,8 @@ def scope_list(engagement_name):
674
730
  return
675
731
 
676
732
  manager = ScopeManager()
677
- validator = ScopeValidator(eng['id'])
678
- entries = manager.list_scope(eng['id'])
733
+ validator = ScopeValidator(eng["id"])
734
+ entries = manager.list_scope(eng["id"])
679
735
  enforcement = validator.get_enforcement_mode()
680
736
 
681
737
  click.echo(f"\nScope for '{engagement_name}' (enforcement: {enforcement})")
@@ -689,9 +745,11 @@ def scope_list(engagement_name):
689
745
  click.echo("-" * 70)
690
746
 
691
747
  for entry in entries:
692
- excluded = "EXCLUDE" if entry.get('is_excluded') else ""
693
- click.echo(f"{entry['id']:<5} {entry['scope_type']:<10} {entry['value']:<35} {excluded:<10}")
694
- if entry.get('description'):
748
+ excluded = "EXCLUDE" if entry.get("is_excluded") else ""
749
+ click.echo(
750
+ f"{entry['id']:<5} {entry['scope_type']:<10} {entry['value']:<35} {excluded:<10}"
751
+ )
752
+ if entry.get("description"):
695
753
  click.echo(f" {entry['description']}")
696
754
 
697
755
  click.echo()
@@ -719,7 +777,7 @@ def scope_remove(engagement_name, scope_id):
719
777
 
720
778
  @scope.command("enforcement")
721
779
  @click.argument("engagement_name")
722
- @click.argument("mode", type=click.Choice(['off', 'warn', 'block']))
780
+ @click.argument("mode", type=click.Choice(["off", "warn", "block"]))
723
781
  def scope_enforcement(engagement_name, mode):
724
782
  """Set enforcement mode for an engagement.
725
783
 
@@ -737,7 +795,7 @@ def scope_enforcement(engagement_name, mode):
737
795
  return
738
796
 
739
797
  manager = ScopeManager()
740
- if manager.set_enforcement(eng['id'], mode):
798
+ if manager.set_enforcement(eng["id"], mode):
741
799
  click.echo(f"Enforcement mode set to '{mode}' for '{engagement_name}'")
742
800
  else:
743
801
  click.echo("Error: Failed to set enforcement mode", err=True)
@@ -756,7 +814,7 @@ def scope_validate(engagement_name, target):
756
814
  click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
757
815
  return
758
816
 
759
- validator = ScopeValidator(eng['id'])
817
+ validator = ScopeValidator(eng["id"])
760
818
  result = validator.validate_target(target)
761
819
 
762
820
  if result.is_in_scope:
@@ -783,7 +841,7 @@ def scope_revalidate(engagement_name):
783
841
  return
784
842
 
785
843
  hm = HostManager()
786
- result = hm.revalidate_scope_status(eng['id'])
844
+ result = hm.revalidate_scope_status(eng["id"])
787
845
 
788
846
  click.echo(f"Revalidated hosts for '{engagement_name}':")
789
847
  click.echo(f" Updated: {result['updated']}")
@@ -805,7 +863,7 @@ def scope_log(engagement_name, limit):
805
863
  return
806
864
 
807
865
  manager = ScopeManager()
808
- log_entries = manager.get_validation_log(eng['id'], limit)
866
+ log_entries = manager.get_validation_log(eng["id"], limit)
809
867
 
810
868
  click.echo(f"\nScope validation log for '{engagement_name}' (last {limit})")
811
869
  click.echo("=" * 80)
@@ -818,10 +876,10 @@ def scope_log(engagement_name, limit):
818
876
  click.echo("-" * 80)
819
877
 
820
878
  for entry in log_entries:
821
- timestamp = entry.get('created_at', '')[:19] # Trim to datetime
822
- target = entry.get('target', '')[:24]
823
- result = entry.get('validation_result', '')
824
- action = entry.get('action_taken', '')
879
+ timestamp = entry.get("created_at", "")[:19] # Trim to datetime
880
+ target = entry.get("target", "")[:24]
881
+ result = entry.get("validation_result", "")
882
+ action = entry.get("action_taken", "")
825
883
  click.echo(f"{timestamp:<20} {target:<25} {result:<12} {action:<10}")
826
884
 
827
885
 
@@ -839,7 +897,7 @@ def jobs():
839
897
  def jobs_enqueue(tool, target, args, label):
840
898
  """Enqueue a background job."""
841
899
  args_list = args.split() if args else []
842
-
900
+
843
901
  try:
844
902
  job_id = enqueue_job(tool, target, args_list, label)
845
903
  click.echo(f"✓ Enqueued job {job_id}: {tool} {target}")
@@ -855,30 +913,32 @@ def jobs_enqueue(tool, target, args, label):
855
913
  def jobs_list(limit, status):
856
914
  """List background jobs."""
857
915
  jobs_data = list_jobs(limit=limit)
858
-
916
+
859
917
  if status:
860
- jobs_data = [j for j in jobs_data if j.get('status') == status]
861
-
918
+ jobs_data = [j for j in jobs_data if j.get("status") == status]
919
+
862
920
  if not jobs_data:
863
921
  click.echo("No jobs found")
864
922
  return
865
-
923
+
866
924
  click.echo("\n" + "=" * 100)
867
- click.echo(f"{'ID':<5} {'Tool':<12} {'Target':<25} {'Status':<10} {'Label':<20} {'Created':<20}")
925
+ click.echo(
926
+ f"{'ID':<5} {'Tool':<12} {'Target':<25} {'Status':<10} {'Label':<20} {'Created':<20}"
927
+ )
868
928
  click.echo("=" * 100)
869
-
929
+
870
930
  for job in jobs_data:
871
- status_val = job.get('status', 'N/A')
931
+ status_val = job.get("status", "N/A")
872
932
 
873
933
  # Color code status
874
- if status_val == 'done':
875
- status_str = click.style(f"{status_val:<10}", fg='green')
876
- elif status_val == 'running':
877
- status_str = click.style(f"{status_val:<10}", fg='yellow')
878
- elif status_val in ('error', 'failed'):
879
- status_str = click.style(f"{status_val:<10}", fg='red')
880
- elif status_val == 'killed':
881
- status_str = click.style(f"{status_val:<10}", fg='magenta')
934
+ if status_val == "done":
935
+ status_str = click.style(f"{status_val:<10}", fg="green")
936
+ elif status_val == "running":
937
+ status_str = click.style(f"{status_val:<10}", fg="yellow")
938
+ elif status_val in ("error", "failed"):
939
+ status_str = click.style(f"{status_val:<10}", fg="red")
940
+ elif status_val == "killed":
941
+ status_str = click.style(f"{status_val:<10}", fg="magenta")
882
942
  else:
883
943
  status_str = f"{status_val:<10}"
884
944
 
@@ -890,7 +950,7 @@ def jobs_list(limit, status):
890
950
  f"{job.get('label', '')[:19]:<20} "
891
951
  f"{job.get('created_at', 'N/A'):<20}"
892
952
  )
893
-
953
+
894
954
  click.echo("=" * 100 + "\n")
895
955
 
896
956
 
@@ -899,11 +959,11 @@ def jobs_list(limit, status):
899
959
  def jobs_get(job_id):
900
960
  """Get job details."""
901
961
  job = get_job(job_id)
902
-
962
+
903
963
  if not job:
904
964
  click.echo(f"✗ Job {job_id} not found", err=True)
905
965
  return
906
-
966
+
907
967
  click.echo("\n" + "=" * 60)
908
968
  click.echo(f"Job {job_id}")
909
969
  click.echo("=" * 60)
@@ -916,10 +976,10 @@ def jobs_get(job_id):
916
976
  click.echo(f"Started: {job.get('started_at', 'N/A')}")
917
977
  click.echo(f"Finished: {job.get('finished_at', 'N/A')}")
918
978
  click.echo(f"Log: {job.get('log', 'N/A')}")
919
-
920
- if job.get('error'):
979
+
980
+ if job.get("error"):
921
981
  click.echo(f"Error: {job['error']}")
922
-
982
+
923
983
  click.echo("=" * 60 + "\n")
924
984
 
925
985
 
@@ -942,32 +1002,32 @@ def jobs_show(job_id):
942
1002
  click.echo(f"Tool: {job.get('tool', 'N/A')}")
943
1003
  click.echo(f"Target: {job.get('target', 'N/A')}")
944
1004
  click.echo(f"Args: {' '.join(job.get('args', []))}")
945
- if job.get('label'):
1005
+ if job.get("label"):
946
1006
  click.echo(f"Label: {job['label']}")
947
1007
  click.echo(f"Status: {job.get('status', 'N/A')}")
948
1008
  click.echo(f"Created: {job.get('created_at', 'N/A')}")
949
- if job.get('started_at'):
1009
+ if job.get("started_at"):
950
1010
  click.echo(f"Started: {job['started_at']}")
951
- if job.get('finished_at'):
1011
+ if job.get("finished_at"):
952
1012
  click.echo(f"Finished: {job['finished_at']}")
953
1013
 
954
- if job.get('error'):
1014
+ if job.get("error"):
955
1015
  click.echo(f"Error: {job['error']}")
956
1016
 
957
1017
  click.echo()
958
1018
 
959
1019
  # Show log output
960
- log_path = job.get('log')
1020
+ log_path = job.get("log")
961
1021
  if log_path and os.path.exists(log_path):
962
- click.echo(click.style("LOG OUTPUT:", bold=True, fg='cyan'))
1022
+ click.echo(click.style("LOG OUTPUT:", bold=True, fg="cyan"))
963
1023
  click.echo("-" * 70)
964
1024
 
965
1025
  try:
966
- with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
1026
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
967
1027
  content = f.read()
968
1028
 
969
1029
  # Show last 100 lines
970
- lines = content.split('\n')
1030
+ lines = content.split("\n")
971
1031
  if len(lines) > 100:
972
1032
  click.echo(f"... (showing last 100 of {len(lines)} lines)\n")
973
1033
  lines = lines[-100:]
@@ -976,9 +1036,9 @@ def jobs_show(job_id):
976
1036
  click.echo(line)
977
1037
 
978
1038
  except Exception as e:
979
- click.echo(click.style(f"Error reading log: {e}", fg='red'))
1039
+ click.echo(click.style(f"Error reading log: {e}", fg="red"))
980
1040
  else:
981
- click.echo(click.style("No log file available", fg='yellow'))
1041
+ click.echo(click.style("No log file available", fg="yellow"))
982
1042
 
983
1043
  click.echo("\n" + "=" * 70 + "\n")
984
1044
 
@@ -989,19 +1049,19 @@ def jobs_show(job_id):
989
1049
  def jobs_tail(job_id, follow):
990
1050
  """Tail job log file."""
991
1051
  import subprocess
992
-
1052
+
993
1053
  job = get_job(job_id)
994
-
1054
+
995
1055
  if not job:
996
1056
  click.echo(f"✗ Job {job_id} not found", err=True)
997
1057
  return
998
-
999
- log_path = job.get('log')
1000
-
1058
+
1059
+ log_path = job.get("log")
1060
+
1001
1061
  if not log_path or not os.path.exists(log_path):
1002
1062
  click.echo(f"✗ Log file not found: {log_path}", err=True)
1003
1063
  return
1004
-
1064
+
1005
1065
  try:
1006
1066
  if follow:
1007
1067
  subprocess.run(["tail", "-f", log_path])
@@ -1026,20 +1086,20 @@ def jobs_kill(job_id, force):
1026
1086
  click.echo(f"✗ Job {job_id} not found", err=True)
1027
1087
  return
1028
1088
 
1029
- status = job.get('status')
1030
-
1089
+ status = job.get("status")
1090
+
1031
1091
  # Allow killing queued, running, and error jobs
1032
- if status not in ['queued', 'running', 'error']:
1092
+ if status not in ["queued", "running", "error"]:
1033
1093
  click.echo(f"✗ Job {job_id} cannot be killed (status: {status})", err=True)
1034
1094
  return
1035
1095
 
1036
1096
  if kill_job(job_id):
1037
- if status == 'queued':
1038
- click.secho(f"✓ Job {job_id} removed from queue", fg='green')
1039
- elif status == 'error':
1040
- click.secho(f"✓ Job {job_id} marked as killed", fg='green')
1097
+ if status == "queued":
1098
+ click.secho(f"✓ Job {job_id} removed from queue", fg="green")
1099
+ elif status == "error":
1100
+ click.secho(f"✓ Job {job_id} marked as killed", fg="green")
1041
1101
  else:
1042
- click.secho(f"✓ Job {job_id} killed successfully", fg='green')
1102
+ click.secho(f"✓ Job {job_id} killed successfully", fg="green")
1043
1103
  else:
1044
1104
  click.echo(f"✗ Failed to kill job {job_id}", err=True)
1045
1105
 
@@ -1047,29 +1107,35 @@ def jobs_kill(job_id, force):
1047
1107
  @jobs.command("sanitize")
1048
1108
  @click.argument("job_id", type=int, required=False)
1049
1109
  @click.option("--all", is_flag=True, help="Sanitize all job logs")
1050
- @click.option("--dry-run", is_flag=True, help="Show what would be redacted without modifying logs")
1110
+ @click.option(
1111
+ "--dry-run", is_flag=True, help="Show what would be redacted without modifying logs"
1112
+ )
1051
1113
  def jobs_sanitize(job_id, all, dry_run):
1052
1114
  """Sanitize job logs by redacting credentials."""
1053
1115
  from souleyez.engine.log_sanitizer import LogSanitizer
1054
1116
  from souleyez.storage.crypto import CryptoManager
1055
-
1117
+
1056
1118
  crypto_mgr = CryptoManager()
1057
1119
  if not crypto_mgr.is_enabled():
1058
- click.secho("⚠️ Warning: Encryption is not enabled.", fg='yellow')
1059
- click.echo(" Sanitization is primarily useful when encryption is enabled to prevent")
1120
+ click.secho("⚠️ Warning: Encryption is not enabled.", fg="yellow")
1121
+ click.echo(
1122
+ " Sanitization is primarily useful when encryption is enabled to prevent"
1123
+ )
1060
1124
  click.echo(" plaintext credentials in logs while encrypted in database.")
1061
1125
  if not click.confirm("Continue anyway?"):
1062
1126
  return
1063
-
1127
+
1064
1128
  if all:
1065
1129
  jobs_to_sanitize = list_jobs(limit=10000)
1066
1130
  if not jobs_to_sanitize:
1067
1131
  click.echo("No jobs found")
1068
1132
  return
1069
-
1133
+
1070
1134
  click.echo(f"Found {len(jobs_to_sanitize)} job(s)")
1071
-
1072
- if not dry_run and not click.confirm(f"Sanitize logs for all {len(jobs_to_sanitize)} jobs?"):
1135
+
1136
+ if not dry_run and not click.confirm(
1137
+ f"Sanitize logs for all {len(jobs_to_sanitize)} jobs?"
1138
+ ):
1073
1139
  return
1074
1140
  elif job_id:
1075
1141
  job = get_job(job_id)
@@ -1082,60 +1148,62 @@ def jobs_sanitize(job_id, all, dry_run):
1082
1148
  click.echo("Usage: souleyez jobs sanitize <job_id>")
1083
1149
  click.echo(" souleyez jobs sanitize --all")
1084
1150
  return
1085
-
1151
+
1086
1152
  sanitized_count = 0
1087
1153
  redacted_count = 0
1088
-
1154
+
1089
1155
  for job in jobs_to_sanitize:
1090
- jid = job['id']
1091
- log_path = job.get('log')
1092
-
1156
+ jid = job["id"]
1157
+ log_path = job.get("log")
1158
+
1093
1159
  if not log_path or not os.path.exists(log_path):
1094
1160
  continue
1095
-
1161
+
1096
1162
  try:
1097
- with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
1163
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
1098
1164
  original = f.read()
1099
-
1165
+
1100
1166
  if not LogSanitizer.contains_credentials(original):
1101
1167
  continue
1102
-
1168
+
1103
1169
  sanitized = LogSanitizer.sanitize(original)
1104
-
1170
+
1105
1171
  if original == sanitized:
1106
1172
  continue
1107
-
1173
+
1108
1174
  summary = LogSanitizer.get_redaction_summary(original, sanitized)
1109
-
1175
+
1110
1176
  if dry_run:
1111
1177
  click.echo(f"Job {jid}: {summary}")
1112
1178
  redacted_count += 1
1113
1179
  else:
1114
- with open(log_path, 'w', encoding='utf-8') as f:
1180
+ with open(log_path, "w", encoding="utf-8") as f:
1115
1181
  f.write(sanitized)
1116
- click.secho(f"✓ Job {jid}: {summary}", fg='green')
1182
+ click.secho(f"✓ Job {jid}: {summary}", fg="green")
1117
1183
  sanitized_count += 1
1118
-
1184
+
1119
1185
  except Exception as e:
1120
1186
  click.echo(f"✗ Job {jid}: Failed - {e}", err=True)
1121
-
1187
+
1122
1188
  if dry_run:
1123
1189
  click.echo(f"\n{redacted_count} job log(s) contain credentials (dry-run)")
1124
1190
  else:
1125
- click.secho(f"\n✓ Sanitized {sanitized_count} job log(s)", fg='green')
1191
+ click.secho(f"\n✓ Sanitized {sanitized_count} job log(s)", fg="green")
1126
1192
 
1127
1193
 
1128
1194
  @jobs.command("reparse")
1129
1195
  @click.argument("job_id", type=int, required=False)
1130
1196
  @click.option("--all", is_flag=True, help="Reparse all completed jobs")
1131
1197
  @click.option("--tool", "-t", default=None, help="Filter by tool type")
1132
- def jobs_reparse(job_id, all, tool):
1198
+ @click.option("--chain", is_flag=True, help="Re-evaluate chain rules after reparsing")
1199
+ def jobs_reparse(job_id, all, tool, chain):
1133
1200
  """Reparse completed job results to update database and status.
1134
1201
 
1135
1202
  Useful for applying new parsing logic to old jobs. This will:
1136
1203
  - Re-run the parser on existing log files
1137
1204
  - Update findings/credentials in database
1138
1205
  - Update job status (e.g., no_results -> done if data found)
1206
+ - Optionally re-trigger chain rules with --chain
1139
1207
  """
1140
1208
  from souleyez.engine.result_handler import reparse_job
1141
1209
 
@@ -1147,9 +1215,13 @@ def jobs_reparse(job_id, all, tool):
1147
1215
 
1148
1216
  # Filter by status and optionally by tool
1149
1217
  # Include no_results since that's what we're trying to fix
1150
- jobs_to_reparse = [j for j in jobs_to_reparse if j.get('status') in ('done', 'error', 'no_results')]
1218
+ jobs_to_reparse = [
1219
+ j
1220
+ for j in jobs_to_reparse
1221
+ if j.get("status") in ("done", "error", "no_results")
1222
+ ]
1151
1223
  if tool:
1152
- jobs_to_reparse = [j for j in jobs_to_reparse if j.get('tool') == tool]
1224
+ jobs_to_reparse = [j for j in jobs_to_reparse if j.get("tool") == tool]
1153
1225
 
1154
1226
  if not jobs_to_reparse:
1155
1227
  filter_msg = f" for tool '{tool}'" if tool else ""
@@ -1166,8 +1238,11 @@ def jobs_reparse(job_id, all, tool):
1166
1238
  click.echo(f"✗ Job {job_id} not found", err=True)
1167
1239
  return
1168
1240
 
1169
- if job.get('status') not in ('done', 'error', 'no_results'):
1170
- click.echo(f"✗ Job {job_id} is not completed (status: {job.get('status')})", err=True)
1241
+ if job.get("status") not in ("done", "error", "no_results"):
1242
+ click.echo(
1243
+ f"✗ Job {job_id} is not completed (status: {job.get('status')})",
1244
+ err=True,
1245
+ )
1171
1246
  return
1172
1247
 
1173
1248
  jobs_to_reparse = [job]
@@ -1184,62 +1259,82 @@ def jobs_reparse(job_id, all, tool):
1184
1259
  error_count = 0
1185
1260
 
1186
1261
  for job in jobs_to_reparse:
1187
- jid = job['id']
1188
- tool_name = job.get('tool', 'unknown')
1189
- old_status = job.get('status')
1262
+ jid = job["id"]
1263
+ tool_name = job.get("tool", "unknown")
1264
+ old_status = job.get("status")
1190
1265
 
1191
1266
  try:
1192
1267
  result = reparse_job(jid)
1193
1268
 
1194
- if not result.get('success'):
1195
- msg = result.get('message', 'Unknown error')
1196
- if 'No parser' in msg:
1269
+ if not result.get("success"):
1270
+ msg = result.get("message", "Unknown error")
1271
+ if "No parser" in msg:
1197
1272
  click.echo(f" Job {jid} ({tool_name}): No parser available")
1198
1273
  skipped_count += 1
1199
1274
  else:
1200
- click.secho(f"✗ Job {jid} ({tool_name}): {msg}", fg='red')
1275
+ click.secho(f"✗ Job {jid} ({tool_name}): {msg}", fg="red")
1201
1276
  error_count += 1
1202
1277
  else:
1203
1278
  # Show what was parsed
1204
- parse_result = result.get('parse_result', {})
1205
- new_status = result.get('new_status')
1279
+ parse_result = result.get("parse_result", {})
1280
+ new_status = result.get("new_status")
1206
1281
  summary = []
1207
1282
 
1208
- if parse_result.get('hosts_added', 0) > 0:
1283
+ if parse_result.get("hosts_added", 0) > 0:
1209
1284
  summary.append(f"{parse_result['hosts_added']} hosts")
1210
- if parse_result.get('osint_added', 0) > 0:
1285
+ if parse_result.get("osint_added", 0) > 0:
1211
1286
  summary.append(f"{parse_result['osint_added']} OSINT records")
1212
- if parse_result.get('findings_added', 0) > 0:
1287
+ if parse_result.get("findings_added", 0) > 0:
1213
1288
  summary.append(f"{parse_result['findings_added']} findings")
1214
- if parse_result.get('credentials_added', 0) > 0:
1289
+ if parse_result.get("credentials_added", 0) > 0:
1215
1290
  summary.append(f"{parse_result['credentials_added']} credentials")
1216
- if parse_result.get('users_found', 0) > 0:
1291
+ if parse_result.get("users_found", 0) > 0:
1217
1292
  summary.append(f"{parse_result['users_found']} users")
1218
- if parse_result.get('shares_found', 0) > 0:
1293
+ if parse_result.get("shares_found", 0) > 0:
1219
1294
  summary.append(f"{parse_result['shares_found']} shares")
1220
1295
 
1221
1296
  summary_str = ", ".join(summary) if summary else "parsed"
1222
1297
 
1223
1298
  # Highlight status changes
1224
1299
  if old_status != new_status:
1225
- click.secho(f"✓ Job {jid} ({tool_name}): {summary_str} [{old_status} → {new_status}]", fg='green')
1300
+ click.secho(
1301
+ f"✓ Job {jid} ({tool_name}): {summary_str} [{old_status} → {new_status}]",
1302
+ fg="green",
1303
+ )
1226
1304
  updated_count += 1
1227
1305
  else:
1228
- click.secho(f"✓ Job {jid} ({tool_name}): {summary_str}", fg='green')
1306
+ click.secho(f"✓ Job {jid} ({tool_name}): {summary_str}", fg="green")
1229
1307
  parsed_count += 1
1230
1308
 
1309
+ # Re-evaluate chain rules if requested
1310
+ if chain and parse_result:
1311
+ try:
1312
+ from souleyez.core.tool_chaining import ToolChaining
1313
+
1314
+ crm = ToolChaining()
1315
+ chain_job_ids = crm.auto_chain(
1316
+ job=job, parse_results=parse_result
1317
+ )
1318
+ if chain_job_ids:
1319
+ click.secho(
1320
+ f" → Chained {len(chain_job_ids)} job(s): {chain_job_ids}",
1321
+ fg="cyan",
1322
+ )
1323
+ except Exception as chain_err:
1324
+ click.secho(f" → Chain error: {chain_err}", fg="yellow")
1325
+
1231
1326
  except Exception as e:
1232
- click.secho(f"✗ Job {jid} ({tool_name}): Failed - {e}", fg='red')
1327
+ click.secho(f"✗ Job {jid} ({tool_name}): Failed - {e}", fg="red")
1233
1328
  error_count += 1
1234
1329
 
1235
1330
  click.echo(f"\n{'=' * 60}")
1236
- click.secho(f"✓ Reparsed: {parsed_count}", fg='green')
1331
+ click.secho(f"✓ Reparsed: {parsed_count}", fg="green")
1237
1332
  if updated_count > 0:
1238
- click.secho(f" Status updated: {updated_count}", fg='cyan')
1333
+ click.secho(f" Status updated: {updated_count}", fg="cyan")
1239
1334
  if skipped_count > 0:
1240
1335
  click.echo(f" Skipped: {skipped_count}")
1241
1336
  if error_count > 0:
1242
- click.secho(f"✗ Errors: {error_count}", fg='red')
1337
+ click.secho(f"✗ Errors: {error_count}", fg="red")
1243
1338
  click.echo("=" * 60)
1244
1339
 
1245
1340
 
@@ -1269,19 +1364,20 @@ def worker_start(fg):
1269
1364
  def worker_status():
1270
1365
  """Check worker status."""
1271
1366
  import subprocess
1272
-
1367
+
1273
1368
  try:
1274
1369
  # Use pgrep to find worker_loop processes (more reliable than ps + grep)
1275
1370
  result = subprocess.run(
1276
- ["pgrep", "-f", "worker_loop"],
1277
- capture_output=True,
1278
- text=True
1371
+ ["pgrep", "-f", "worker_loop"], capture_output=True, text=True
1279
1372
  )
1280
-
1373
+
1281
1374
  if result.returncode == 0 and result.stdout.strip():
1282
1375
  # Found worker processes
1283
- pids = result.stdout.strip().split('\n')
1284
- click.secho(f"✓ Worker is running ({len(pids)} process{'es' if len(pids) > 1 else ''}):", fg='green')
1376
+ pids = result.stdout.strip().split("\n")
1377
+ click.secho(
1378
+ f"✓ Worker is running ({len(pids)} process{'es' if len(pids) > 1 else ''}):",
1379
+ fg="green",
1380
+ )
1285
1381
  for pid in pids:
1286
1382
  click.echo(f" PID {pid}: background worker")
1287
1383
  else:
@@ -1292,12 +1388,12 @@ def worker_status():
1292
1388
  try:
1293
1389
  result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
1294
1390
  worker_procs = []
1295
- for line in result.stdout.split('\n'):
1296
- if 'worker_loop' in line and 'grep' not in line:
1391
+ for line in result.stdout.split("\n"):
1392
+ if "worker_loop" in line and "grep" not in line:
1297
1393
  worker_procs.append(line)
1298
-
1394
+
1299
1395
  if worker_procs:
1300
- click.secho("✓ Worker is running:", fg='green')
1396
+ click.secho("✓ Worker is running:", fg="green")
1301
1397
  for proc in worker_procs:
1302
1398
  parts = proc.split()
1303
1399
  if len(parts) >= 2:
@@ -1316,22 +1412,22 @@ def list_plugins():
1316
1412
  """List available plugins."""
1317
1413
  try:
1318
1414
  from souleyez.engine.loader import discover_plugins
1319
-
1415
+
1320
1416
  plugins = discover_plugins()
1321
-
1417
+
1322
1418
  if not plugins:
1323
1419
  click.echo("No plugins found")
1324
1420
  return
1325
-
1421
+
1326
1422
  click.echo("\n" + "=" * 80)
1327
1423
  click.echo("AVAILABLE PLUGINS")
1328
1424
  click.echo("=" * 80)
1329
-
1425
+
1330
1426
  for key, plugin in sorted(plugins.items()):
1331
- name = getattr(plugin, 'name', 'Unknown')
1332
- category = getattr(plugin, 'category', 'misc')
1427
+ name = getattr(plugin, "name", "Unknown")
1428
+ category = getattr(plugin, "category", "misc")
1333
1429
  click.echo(f"{key:<15} | {name:<30} | {category}")
1334
-
1430
+
1335
1431
  click.echo("=" * 80)
1336
1432
  click.echo(f"Total: {len(plugins)} plugins\n")
1337
1433
  except Exception as e:
@@ -1340,6 +1436,7 @@ def list_plugins():
1340
1436
 
1341
1437
  # ==================== DATABASE COMMANDS ====================
1342
1438
 
1439
+
1343
1440
  @cli.group()
1344
1441
  def db():
1345
1442
  """Database management commands."""
@@ -1351,19 +1448,19 @@ def db_migrate():
1351
1448
  """Apply pending database migrations."""
1352
1449
  from souleyez.storage.migrations.migration_manager import MigrationManager
1353
1450
  from souleyez.storage.database import get_db
1354
-
1451
+
1355
1452
  click.echo()
1356
- click.echo(click.style("🔄 DATABASE MIGRATION", bold=True, fg='cyan'))
1453
+ click.echo(click.style("🔄 DATABASE MIGRATION", bold=True, fg="cyan"))
1357
1454
  click.echo("=" * 80)
1358
-
1455
+
1359
1456
  # Get database path
1360
1457
  db_instance = get_db()
1361
1458
  db_path = db_instance.db_path
1362
-
1459
+
1363
1460
  # Run migrations
1364
1461
  manager = MigrationManager(db_path)
1365
1462
  applied = manager.migrate()
1366
-
1463
+
1367
1464
  click.echo()
1368
1465
 
1369
1466
 
@@ -1372,11 +1469,11 @@ def db_status():
1372
1469
  """Show database migration status."""
1373
1470
  from souleyez.storage.migrations.migration_manager import MigrationManager
1374
1471
  from souleyez.storage.database import get_db
1375
-
1472
+
1376
1473
  # Get database path
1377
1474
  db_instance = get_db()
1378
1475
  db_path = db_instance.db_path
1379
-
1476
+
1380
1477
  # Show status
1381
1478
  manager = MigrationManager(db_path)
1382
1479
  manager.status()
@@ -1388,36 +1485,36 @@ def db_encrypt():
1388
1485
  import getpass
1389
1486
  from souleyez.storage.credentials import CredentialsManager
1390
1487
  from souleyez.storage.crypto import get_crypto_manager
1391
-
1488
+
1392
1489
  crypto = get_crypto_manager()
1393
-
1490
+
1394
1491
  # Check if encryption is enabled
1395
1492
  if not crypto.is_encryption_enabled():
1396
- click.echo(click.style("✗ Encryption is not enabled", fg='red'))
1493
+ click.echo(click.style("✗ Encryption is not enabled", fg="red"))
1397
1494
  click.echo("Run 'souleyez interactive' to set up encryption first")
1398
1495
  return
1399
-
1496
+
1400
1497
  # Unlock crypto if needed
1401
1498
  if not crypto.is_unlocked():
1402
- click.echo(click.style("🔐 Master password required", fg='cyan'))
1499
+ click.echo(click.style("🔐 Master password required", fg="cyan"))
1403
1500
  password = getpass.getpass("Enter master password: ")
1404
1501
  try:
1405
1502
  crypto.unlock(password)
1406
- click.echo(click.style("✓ Unlocked", fg='green'))
1503
+ click.echo(click.style("✓ Unlocked", fg="green"))
1407
1504
  except Exception as e:
1408
- click.echo(click.style(f"✗ Failed to unlock: {e}", fg='red'))
1505
+ click.echo(click.style(f"✗ Failed to unlock: {e}", fg="red"))
1409
1506
  return
1410
-
1507
+
1411
1508
  # Encrypt all credentials
1412
1509
  cm = CredentialsManager()
1413
1510
  result = cm.encrypt_all_unencrypted()
1414
-
1415
- if 'error' in result:
1416
- click.echo(click.style(f"✗ {result['error']}", fg='red'))
1511
+
1512
+ if "error" in result:
1513
+ click.echo(click.style(f"✗ {result['error']}", fg="red"))
1417
1514
  return
1418
-
1515
+
1419
1516
  click.echo()
1420
- click.echo(click.style("✓ Encryption complete", fg='green', bold=True))
1517
+ click.echo(click.style("✓ Encryption complete", fg="green", bold=True))
1421
1518
  click.echo(f" • Encrypted: {result.get('encrypted', 0)} credentials")
1422
1519
  click.echo(f" • Already encrypted: {result.get('skipped', 0)} credentials")
1423
1520
  click.echo(f" • Total: {result.get('total', 0)} credentials")
@@ -1430,35 +1527,40 @@ def _run_doctor(fix=False, verbose=False):
1430
1527
  import sqlite3
1431
1528
 
1432
1529
  click.echo()
1433
- click.echo(click.style("🩺 SoulEyez Doctor", fg='cyan', bold=True))
1434
- click.echo(click.style("=" * 50, fg='cyan'))
1530
+ click.echo(click.style("🩺 SoulEyez Doctor", fg="cyan", bold=True))
1531
+ click.echo(click.style("=" * 50, fg="cyan"))
1435
1532
  click.echo()
1436
1533
 
1437
1534
  issues = []
1438
1535
  warnings = []
1439
1536
 
1440
1537
  def check_pass(msg):
1441
- click.echo(click.style(f" ✓ {msg}", fg='green'))
1538
+ click.echo(click.style(f" ✓ {msg}", fg="green"))
1442
1539
 
1443
1540
  def check_fail(msg, fix_cmd=None):
1444
- click.echo(click.style(f" ✗ {msg}", fg='red'))
1541
+ click.echo(click.style(f" ✗ {msg}", fg="red"))
1445
1542
  issues.append((msg, fix_cmd))
1446
1543
 
1447
1544
  def check_warn(msg, suggestion=None):
1448
- click.echo(click.style(f" ⚠ {msg}", fg='yellow'))
1545
+ click.echo(click.style(f" ⚠ {msg}", fg="yellow"))
1449
1546
  warnings.append((msg, suggestion))
1450
1547
 
1451
1548
  # Section 1: Python Environment
1452
1549
  click.echo(click.style("Python Environment", bold=True))
1453
1550
  import sys
1454
- python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
1551
+
1552
+ python_version = (
1553
+ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
1554
+ )
1455
1555
  if sys.version_info >= (3, 8):
1456
1556
  check_pass(f"Python {python_version}")
1457
1557
  else:
1458
- check_fail(f"Python {python_version} (need 3.8+)", "Install Python 3.8 or newer")
1558
+ check_fail(
1559
+ f"Python {python_version} (need 3.8+)", "Install Python 3.8 or newer"
1560
+ )
1459
1561
 
1460
1562
  # Check required packages
1461
- required_packages = ['click', 'rich', 'wcwidth']
1563
+ required_packages = ["click", "rich", "wcwidth"]
1462
1564
  for pkg in required_packages:
1463
1565
  try:
1464
1566
  __import__(pkg)
@@ -1471,14 +1573,16 @@ def _run_doctor(fix=False, verbose=False):
1471
1573
 
1472
1574
  # Section 2: Data Directory
1473
1575
  click.echo(click.style("Data Directory", bold=True))
1474
- data_dir = Path.home() / '.souleyez'
1576
+ data_dir = Path.home() / ".souleyez"
1475
1577
  if data_dir.exists():
1476
1578
  check_pass(f"Data directory: {data_dir}")
1477
1579
  else:
1478
- check_warn(f"Data directory missing: {data_dir}", "Will be created on first run")
1580
+ check_warn(
1581
+ f"Data directory missing: {data_dir}", "Will be created on first run"
1582
+ )
1479
1583
 
1480
1584
  # Check database
1481
- db_path = data_dir / 'souleyez.db'
1585
+ db_path = data_dir / "souleyez.db"
1482
1586
  if db_path.exists():
1483
1587
  try:
1484
1588
  conn = sqlite3.connect(str(db_path))
@@ -1488,7 +1592,9 @@ def _run_doctor(fix=False, verbose=False):
1488
1592
  conn.close()
1489
1593
  check_pass(f"Database OK ({count} engagements)")
1490
1594
  except Exception as e:
1491
- check_fail(f"Database error: {str(e)[:40]}", "Run: souleyez setup --repair-db")
1595
+ check_fail(
1596
+ f"Database error: {str(e)[:40]}", "Run: souleyez setup --repair-db"
1597
+ )
1492
1598
  else:
1493
1599
  check_warn("No database yet", "Will be created on first run")
1494
1600
 
@@ -1497,9 +1603,9 @@ def _run_doctor(fix=False, verbose=False):
1497
1603
  # Section 3: Essential Tools
1498
1604
  click.echo(click.style("Essential Tools", bold=True))
1499
1605
  essential_tools = {
1500
- 'nmap': 'sudo apt install nmap',
1501
- 'git': 'sudo apt install git',
1502
- 'curl': 'sudo apt install curl',
1606
+ "nmap": "sudo apt install nmap",
1607
+ "git": "sudo apt install git",
1608
+ "curl": "sudo apt install curl",
1503
1609
  }
1504
1610
 
1505
1611
  for tool, install_cmd in essential_tools.items():
@@ -1514,6 +1620,7 @@ def _run_doctor(fix=False, verbose=False):
1514
1620
  click.echo(click.style("Pentesting Tools", bold=True))
1515
1621
  # Use the same tool checker as the setup wizard for consistency
1516
1622
  from souleyez.utils.tool_checker import get_tool_stats
1623
+
1517
1624
  installed, total = get_tool_stats()
1518
1625
 
1519
1626
  if installed == total:
@@ -1527,22 +1634,27 @@ def _run_doctor(fix=False, verbose=False):
1527
1634
 
1528
1635
  # Section 5: Configuration
1529
1636
  click.echo(click.style("Configuration", bold=True))
1530
- config_file = data_dir / 'config.json'
1637
+ config_file = data_dir / "config.json"
1531
1638
  if config_file.exists():
1532
1639
  try:
1533
1640
  import json
1641
+
1534
1642
  with open(config_file) as f:
1535
1643
  config = json.load(f)
1536
1644
  check_pass("Config file valid")
1537
1645
 
1538
1646
  # Check AI providers
1539
- ai_config = config.get('ai', {})
1540
- provider = ai_config.get('provider', '')
1541
- has_ollama = provider == 'ollama' or ai_config.get('ollama_model')
1542
- has_claude = ai_config.get('claude_api_key') or ai_config.get('anthropic_api_key')
1543
- has_openai = ai_config.get('openai_api_key')
1647
+ ai_config = config.get("ai", {})
1648
+ provider = ai_config.get("provider", "")
1649
+ has_ollama = provider == "ollama" or ai_config.get("ollama_model")
1650
+ has_claude = ai_config.get("claude_api_key") or ai_config.get(
1651
+ "anthropic_api_key"
1652
+ )
1653
+ has_openai = ai_config.get("openai_api_key")
1544
1654
  if has_ollama or has_claude or has_openai:
1545
- provider_name = provider or ('ollama' if has_ollama else 'claude' if has_claude else 'openai')
1655
+ provider_name = provider or (
1656
+ "ollama" if has_ollama else "claude" if has_claude else "openai"
1657
+ )
1546
1658
  check_pass(f"AI provider: {provider_name}")
1547
1659
  except Exception as e:
1548
1660
  check_fail(f"Config error: {str(e)[:30]}", "Check ~/.souleyez/config.json")
@@ -1555,45 +1667,58 @@ def _run_doctor(fix=False, verbose=False):
1555
1667
  click.echo(click.style("System Configuration", bold=True))
1556
1668
 
1557
1669
  # Check sudoers files for proper format (trailing newline)
1558
- sudoers_dir = Path('/etc/sudoers.d')
1559
- sudoers_tools = ['nmap', 'souleyez-responder'] # Tools we configure in sudoers
1670
+ sudoers_dir = Path("/etc/sudoers.d")
1671
+ sudoers_tools = ["nmap", "souleyez-responder"] # Tools we configure in sudoers
1560
1672
  for tool in sudoers_tools:
1561
1673
  sudoers_file = sudoers_dir / tool
1562
1674
  if sudoers_file.exists():
1563
1675
  try:
1564
1676
  # Check file size vs expected content
1565
1677
  content = sudoers_file.read_bytes()
1566
- if content and not content.endswith(b'\n'):
1567
- check_fail(f"Sudoers {tool}: missing newline",
1568
- f"echo '' | sudo tee -a /etc/sudoers.d/{tool}")
1678
+ if content and not content.endswith(b"\n"):
1679
+ check_fail(
1680
+ f"Sudoers {tool}: missing newline",
1681
+ f"echo '' | sudo tee -a /etc/sudoers.d/{tool}",
1682
+ )
1569
1683
  elif verbose:
1570
1684
  check_pass(f"Sudoers {tool}: OK")
1571
1685
  except PermissionError:
1572
1686
  if verbose:
1573
- click.echo(click.style(f" - Sudoers {tool}: need sudo to verify", fg='bright_black'))
1687
+ click.echo(
1688
+ click.style(
1689
+ f" - Sudoers {tool}: need sudo to verify",
1690
+ fg="bright_black",
1691
+ )
1692
+ )
1574
1693
 
1575
1694
  # Check PATH for common tool directories
1576
- path_dirs = os.environ.get('PATH', '').split(':')
1577
- pipx_bin = str(Path.home() / '.local' / 'bin')
1578
- go_bin = str(Path.home() / 'go' / 'bin')
1695
+ path_dirs = os.environ.get("PATH", "").split(":")
1696
+ pipx_bin = str(Path.home() / ".local" / "bin")
1697
+ go_bin = str(Path.home() / "go" / "bin")
1579
1698
 
1580
1699
  # Detect shell config file (zsh for Kali, bash for others)
1581
- shell = os.environ.get('SHELL', '/bin/bash')
1582
- shell_rc = '~/.zshrc' if 'zsh' in shell else '~/.bashrc'
1700
+ shell = os.environ.get("SHELL", "/bin/bash")
1701
+ shell_rc = "~/.zshrc" if "zsh" in shell else "~/.bashrc"
1583
1702
 
1584
1703
  if pipx_bin in path_dirs:
1585
1704
  if verbose:
1586
1705
  check_pass("PATH includes ~/.local/bin (pipx)")
1587
1706
  else:
1588
1707
  if Path(pipx_bin).exists() and any(Path(pipx_bin).iterdir()):
1589
- check_warn("~/.local/bin not in PATH", f"Add to {shell_rc}: export PATH=\"$HOME/.local/bin:$PATH\"")
1708
+ check_warn(
1709
+ "~/.local/bin not in PATH",
1710
+ f'Add to {shell_rc}: export PATH="$HOME/.local/bin:$PATH"',
1711
+ )
1590
1712
 
1591
1713
  if go_bin in path_dirs:
1592
1714
  if verbose:
1593
1715
  check_pass("PATH includes ~/go/bin")
1594
1716
  else:
1595
1717
  if Path(go_bin).exists() and any(Path(go_bin).iterdir()):
1596
- check_warn("~/go/bin not in PATH", f"Add to {shell_rc}: export PATH=\"$HOME/go/bin:$PATH\"")
1718
+ check_warn(
1719
+ "~/go/bin not in PATH",
1720
+ f'Add to {shell_rc}: export PATH="$HOME/go/bin:$PATH"',
1721
+ )
1597
1722
 
1598
1723
  # Check database is writable
1599
1724
  if db_path.exists():
@@ -1609,69 +1734,183 @@ def _run_doctor(fix=False, verbose=False):
1609
1734
  # Check background worker
1610
1735
  try:
1611
1736
  import subprocess
1612
- result = subprocess.run(['pgrep', '-f', 'souleyez worker'],
1613
- capture_output=True, timeout=5)
1737
+
1738
+ result = subprocess.run(
1739
+ ["pgrep", "-f", "souleyez worker"], capture_output=True, timeout=5
1740
+ )
1614
1741
  if result.returncode == 0:
1615
1742
  check_pass("Background worker running")
1616
1743
  else:
1617
- check_warn("Background worker not running", "souleyez dashboard (starts automatically)")
1744
+ check_warn(
1745
+ "Background worker not running",
1746
+ "souleyez dashboard (starts automatically)",
1747
+ )
1618
1748
  except Exception:
1619
1749
  pass
1620
1750
 
1751
+ # Check for orphaned pending chains
1752
+ try:
1753
+ from souleyez.core.pending_chains import _read_chains, purge_orphaned_chains
1754
+
1755
+ chains = _read_chains()
1756
+ if chains:
1757
+ # Check which are orphaned
1758
+ db_path = os.path.join(data_dir, "souleyez.db")
1759
+ conn = sqlite3.connect(db_path)
1760
+ cursor = conn.execute("SELECT id FROM engagements")
1761
+ valid_ids = {row[0] for row in cursor.fetchall()}
1762
+ conn.close()
1763
+
1764
+ orphaned = [
1765
+ c
1766
+ for c in chains
1767
+ if c.get("engagement_id") not in valid_ids
1768
+ and c.get("engagement_id") is not None
1769
+ ]
1770
+ if orphaned:
1771
+ check_warn(
1772
+ f"Orphaned pending chains: {len(orphaned)}",
1773
+ "Run with --fix to clean up",
1774
+ )
1775
+ if fix:
1776
+ purged = purge_orphaned_chains()
1777
+ if purged > 0:
1778
+ check_pass(f"Cleaned up {purged} orphaned chains")
1779
+ elif verbose:
1780
+ check_pass(f"Pending chains OK ({len(chains)} active)")
1781
+ elif verbose:
1782
+ check_pass("No pending chains")
1783
+ except Exception:
1784
+ pass
1785
+
1786
+ # Check dashboard cache status
1787
+ if verbose:
1788
+ try:
1789
+ from souleyez.ui.dashboard import _header_cache, _HEADER_CACHE_TTL
1790
+
1791
+ cache_entries = len(_header_cache)
1792
+ if cache_entries > 0:
1793
+ check_pass(
1794
+ f"Dashboard cache: {cache_entries} entries (TTL: {_HEADER_CACHE_TTL}s)"
1795
+ )
1796
+ else:
1797
+ check_pass("Dashboard cache: empty (will populate on first load)")
1798
+ except Exception:
1799
+ pass
1800
+
1801
+ try:
1802
+ from souleyez.intelligence.exploit_suggestions import (
1803
+ _SUGGESTION_CACHE,
1804
+ _CACHE_TIMEOUT,
1805
+ )
1806
+
1807
+ cache_entries = len(_SUGGESTION_CACHE)
1808
+ if cache_entries > 0:
1809
+ check_pass(
1810
+ f"Exploit suggestions cache: {cache_entries} entries (TTL: {_CACHE_TIMEOUT}s)"
1811
+ )
1812
+ else:
1813
+ check_pass(
1814
+ "Exploit suggestions cache: empty (will populate on first load)"
1815
+ )
1816
+ except Exception:
1817
+ pass
1818
+
1621
1819
  click.echo()
1622
1820
 
1623
1821
  # Section 7: MSF Database (if msfconsole available)
1624
- if shutil.which('msfconsole'):
1822
+ if shutil.which("msfconsole"):
1625
1823
  click.echo(click.style("Metasploit", bold=True))
1626
- # Check user config first, then system-wide config (Kali uses system-wide)
1627
- msf_db = Path.home() / '.msf4' / 'database.yml'
1628
- system_msf_db = Path('/usr/share/metasploit-framework/config/database.yml')
1629
- if msf_db.exists() or system_msf_db.exists():
1630
- check_pass("MSF database configured")
1631
- else:
1632
- check_fail("MSF database not initialized", "msfdb init")
1824
+
1825
+ # Use comprehensive msfdb status check
1826
+ from souleyez.utils.tool_checker import check_msfdb_status
1827
+
1828
+ db_status = check_msfdb_status()
1829
+
1830
+ if db_status["initialized"] and db_status["running"]:
1831
+ check_pass("MSF database initialized and running")
1832
+ elif db_status["initialized"] and not db_status["running"]:
1833
+ check_warn(
1834
+ "MSF database initialized but PostgreSQL not running",
1835
+ "sudo systemctl start postgresql",
1836
+ )
1837
+ elif not db_status["initialized"]:
1838
+ # Fall back to file check in case msfdb status failed
1839
+ msf_db = Path.home() / ".msf4" / "database.yml"
1840
+ system_msf_db = Path("/usr/share/metasploit-framework/config/database.yml")
1841
+ if msf_db.exists() or system_msf_db.exists():
1842
+ check_warn(
1843
+ "MSF database config exists but status unclear",
1844
+ db_status.get("message", "Run: sudo msfdb status"),
1845
+ )
1846
+ else:
1847
+ check_fail("MSF database not initialized", "msfdb init")
1848
+
1849
+ if verbose:
1850
+ click.echo(
1851
+ click.style(
1852
+ f" Status: {db_status.get('message', 'Unknown')}",
1853
+ fg="bright_black",
1854
+ )
1855
+ )
1633
1856
 
1634
1857
  # Check if root has access (for sudo msfconsole)
1635
1858
  # Skip if we can't access /root (not an error, just needs sudo to check)
1636
- root_msf_db = Path('/root/.msf4/database.yml')
1859
+ root_msf_db = Path("/root/.msf4/database.yml")
1637
1860
  try:
1638
1861
  if root_msf_db.exists():
1639
1862
  check_pass("Sudo MSF access configured")
1640
1863
  else:
1641
- check_warn("Sudo MSF may not connect to DB",
1642
- "sudo cp ~/.msf4/database.yml /root/.msf4/")
1864
+ check_warn(
1865
+ "Sudo MSF may not connect to DB",
1866
+ "sudo cp ~/.msf4/database.yml /root/.msf4/",
1867
+ )
1643
1868
  except PermissionError:
1644
1869
  # Can't check without sudo - not a problem, just skip
1645
1870
  if verbose:
1646
- click.echo(click.style(" - Sudo MSF config: need sudo to verify", fg='bright_black'))
1871
+ click.echo(
1872
+ click.style(
1873
+ " - Sudo MSF config: need sudo to verify", fg="bright_black"
1874
+ )
1875
+ )
1647
1876
 
1648
1877
  click.echo()
1649
1878
 
1650
1879
  # Summary
1651
- click.echo(click.style("=" * 50, fg='cyan'))
1880
+ click.echo(click.style("=" * 50, fg="cyan"))
1652
1881
  if not issues and not warnings:
1653
- click.echo(click.style("✓ All checks passed! SoulEyez is healthy.", fg='green', bold=True))
1882
+ click.echo(
1883
+ click.style(
1884
+ "✓ All checks passed! SoulEyez is healthy.", fg="green", bold=True
1885
+ )
1886
+ )
1654
1887
  else:
1655
1888
  if issues:
1656
- click.echo(click.style(f"\n{len(issues)} issue(s) found:", fg='red', bold=True))
1889
+ click.echo(
1890
+ click.style(f"\n{len(issues)} issue(s) found:", fg="red", bold=True)
1891
+ )
1657
1892
  for issue, fix_cmd in issues:
1658
1893
  click.echo(f" • {issue}")
1659
1894
  if fix_cmd:
1660
- click.echo(click.style(f" Fix: {fix_cmd}", fg='cyan'))
1895
+ click.echo(click.style(f" Fix: {fix_cmd}", fg="cyan"))
1661
1896
 
1662
1897
  if warnings:
1663
- click.echo(click.style(f"\n{len(warnings)} warning(s):", fg='yellow', bold=True))
1898
+ click.echo(
1899
+ click.style(f"\n{len(warnings)} warning(s):", fg="yellow", bold=True)
1900
+ )
1664
1901
  for warning, suggestion in warnings:
1665
1902
  click.echo(f" • {warning}")
1666
1903
  if suggestion:
1667
- click.echo(click.style(f" Suggestion: {suggestion}", fg='cyan'))
1904
+ click.echo(click.style(f" Suggestion: {suggestion}", fg="cyan"))
1668
1905
 
1669
1906
  # Auto-fix if requested
1670
1907
  if fix and issues:
1671
1908
  click.echo()
1672
- click.echo(click.style("Attempting auto-fix...", fg='cyan', bold=True))
1909
+ click.echo(click.style("Attempting auto-fix...", fg="cyan", bold=True))
1673
1910
  # TODO: Implement auto-fix for common issues
1674
- click.echo("Auto-fix not yet implemented. Please run the suggested commands manually.")
1911
+ click.echo(
1912
+ "Auto-fix not yet implemented. Please run the suggested commands manually."
1913
+ )
1675
1914
 
1676
1915
  click.echo()
1677
1916
 
@@ -1679,8 +1918,8 @@ def _run_doctor(fix=False, verbose=False):
1679
1918
 
1680
1919
 
1681
1920
  @cli.command()
1682
- @click.option('--fix', is_flag=True, help='Attempt to automatically fix issues')
1683
- @click.option('--verbose', '-v', is_flag=True, help='Show detailed diagnostic info')
1921
+ @click.option("--fix", is_flag=True, help="Attempt to automatically fix issues")
1922
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed diagnostic info")
1684
1923
  def doctor(fix, verbose):
1685
1924
  """Diagnose and fix common SoulEyez issues.
1686
1925
 
@@ -1705,11 +1944,12 @@ def tutorial():
1705
1944
  - Understanding the dashboard
1706
1945
  """
1707
1946
  from souleyez.ui.tutorial import run_tutorial
1947
+
1708
1948
  run_tutorial()
1709
1949
 
1710
1950
 
1711
- @cli.command('install-desktop')
1712
- @click.option('--remove', is_flag=True, help='Remove the desktop shortcut')
1951
+ @cli.command("install-desktop")
1952
+ @click.option("--remove", is_flag=True, help="Remove the desktop shortcut")
1713
1953
  def install_desktop(remove):
1714
1954
  """Install SoulEyez desktop shortcut in Applications menu.
1715
1955
 
@@ -1719,29 +1959,33 @@ def install_desktop(remove):
1719
1959
  import shutil
1720
1960
  from importlib import resources
1721
1961
 
1722
- applications_dir = Path.home() / '.local' / 'share' / 'applications'
1723
- icons_dir = Path.home() / '.local' / 'share' / 'icons'
1724
- desktop_file = applications_dir / 'souleyez.desktop'
1725
- icon_dest = icons_dir / 'souleyez.png'
1962
+ applications_dir = Path.home() / ".local" / "share" / "applications"
1963
+ icons_dir = Path.home() / ".local" / "share" / "icons"
1964
+ desktop_file = applications_dir / "souleyez.desktop"
1965
+ icon_dest = icons_dir / "souleyez.png"
1726
1966
 
1727
1967
  if remove:
1728
1968
  # Remove desktop shortcut
1729
1969
  removed = False
1730
1970
  if desktop_file.exists():
1731
1971
  desktop_file.unlink()
1732
- click.echo(click.style(" Removed desktop shortcut", fg='green'))
1972
+ click.echo(click.style(" Removed desktop shortcut", fg="green"))
1733
1973
  removed = True
1734
1974
  if icon_dest.exists():
1735
1975
  icon_dest.unlink()
1736
- click.echo(click.style(" Removed icon", fg='green'))
1976
+ click.echo(click.style(" Removed icon", fg="green"))
1737
1977
  removed = True
1738
1978
  if removed:
1739
- click.echo(click.style("\nSoulEyez removed from Applications menu.", fg='cyan'))
1979
+ click.echo(
1980
+ click.style("\nSoulEyez removed from Applications menu.", fg="cyan")
1981
+ )
1740
1982
  else:
1741
- click.echo(click.style("No desktop shortcut found.", fg='yellow'))
1983
+ click.echo(click.style("No desktop shortcut found.", fg="yellow"))
1742
1984
  return
1743
1985
 
1744
- click.echo(click.style("\nInstalling SoulEyez desktop shortcut...\n", fg='cyan', bold=True))
1986
+ click.echo(
1987
+ click.style("\nInstalling SoulEyez desktop shortcut...\n", fg="cyan", bold=True)
1988
+ )
1745
1989
 
1746
1990
  # Create directories
1747
1991
  applications_dir.mkdir(parents=True, exist_ok=True)
@@ -1752,20 +1996,21 @@ def install_desktop(remove):
1752
1996
  # Try importlib.resources first (Python 3.9+)
1753
1997
  try:
1754
1998
  from importlib.resources import files
1755
- icon_source = files('souleyez.assets').joinpath('souleyez-icon.png')
1756
- with open(icon_source, 'rb') as src:
1999
+
2000
+ icon_source = files("souleyez.assets").joinpath("souleyez-icon.png")
2001
+ with open(icon_source, "rb") as src:
1757
2002
  icon_data = src.read()
1758
2003
  except (ImportError, TypeError, FileNotFoundError):
1759
2004
  # Fallback: find icon relative to this file
1760
- icon_source = Path(__file__).parent / 'assets' / 'souleyez-icon.png'
1761
- with open(icon_source, 'rb') as src:
2005
+ icon_source = Path(__file__).parent / "assets" / "souleyez-icon.png"
2006
+ with open(icon_source, "rb") as src:
1762
2007
  icon_data = src.read()
1763
2008
 
1764
- with open(icon_dest, 'wb') as dst:
2009
+ with open(icon_dest, "wb") as dst:
1765
2010
  dst.write(icon_data)
1766
- click.echo(click.style(" Installed icon", fg='green'))
2011
+ click.echo(click.style(" Installed icon", fg="green"))
1767
2012
  except Exception as e:
1768
- click.echo(click.style(f" Warning: Could not copy icon: {e}", fg='yellow'))
2013
+ click.echo(click.style(f" Warning: Could not copy icon: {e}", fg="yellow"))
1769
2014
  icon_dest = "utilities-terminal" # Fallback to system icon
1770
2015
 
1771
2016
  # Create .desktop file
@@ -1781,21 +2026,27 @@ Keywords=pentest;security;hacking;nmap;metasploit;
1781
2026
  """
1782
2027
 
1783
2028
  desktop_file.write_text(desktop_content)
1784
- click.echo(click.style(" Created desktop entry", fg='green'))
2029
+ click.echo(click.style(" Created desktop entry", fg="green"))
1785
2030
 
1786
2031
  # Update desktop database (optional, may not be available)
1787
2032
  try:
1788
2033
  import subprocess
1789
- subprocess.run(['update-desktop-database', str(applications_dir)],
1790
- capture_output=True, check=False)
2034
+
2035
+ subprocess.run(
2036
+ ["update-desktop-database", str(applications_dir)],
2037
+ capture_output=True,
2038
+ check=False,
2039
+ )
1791
2040
  except Exception:
1792
2041
  pass # Not critical if this fails
1793
2042
 
1794
2043
  click.echo()
1795
- click.echo(click.style("SoulEyez added to Applications menu!", fg='green', bold=True))
2044
+ click.echo(
2045
+ click.style("SoulEyez added to Applications menu!", fg="green", bold=True)
2046
+ )
1796
2047
  click.echo()
1797
2048
  click.echo("You can find it under:")
1798
- click.echo(click.style(" Applications > Security > SoulEyez", fg='cyan'))
2049
+ click.echo(click.style(" Applications > Security > SoulEyez", fg="cyan"))
1799
2050
  click.echo()
1800
2051
  click.echo("To remove: souleyez install-desktop --remove")
1801
2052
 
@@ -1805,106 +2056,129 @@ def main():
1805
2056
  cli()
1806
2057
 
1807
2058
 
1808
-
1809
2059
  @cli.command()
1810
- @click.option('--purge-data', is_flag=True, help='Remove all user data (~/.souleyez)')
1811
- @click.confirmation_option(prompt='Are you sure you want to uninstall SoulEyez?')
2060
+ @click.option("--purge-data", is_flag=True, help="Remove all user data (~/.souleyez)")
2061
+ @click.confirmation_option(prompt="Are you sure you want to uninstall SoulEyez?")
1812
2062
  def uninstall(purge_data):
1813
2063
  """Uninstall SoulEyez and optionally remove all user data."""
1814
2064
  import subprocess
1815
2065
  import shutil
1816
2066
  import signal
1817
-
1818
- click.echo(click.style("\n🗑️ Uninstalling SoulEyez...", fg='yellow', bold=True))
2067
+
2068
+ click.echo(click.style("\n🗑️ Uninstalling SoulEyez...", fg="yellow", bold=True))
1819
2069
  click.echo()
1820
-
2070
+
1821
2071
  # Stop background worker (both dev mode and CLI mode)
1822
2072
  click.echo("1. Stopping background worker...")
1823
2073
  try:
1824
- subprocess.run(['pkill', '-f', 'souleyez.engine.background'],
1825
- capture_output=True, check=False)
1826
- subprocess.run(['pkill', '-f', 'souleyez worker'],
1827
- capture_output=True, check=False)
1828
- click.echo(click.style(" ✓ Worker stopped", fg='green'))
2074
+ subprocess.run(
2075
+ ["pkill", "-f", "souleyez.engine.background"],
2076
+ capture_output=True,
2077
+ check=False,
2078
+ )
2079
+ subprocess.run(
2080
+ ["pkill", "-f", "souleyez worker"], capture_output=True, check=False
2081
+ )
2082
+ click.echo(click.style(" ✓ Worker stopped", fg="green"))
1829
2083
  except Exception as e:
1830
- click.echo(click.style(f" ⚠ Could not stop worker: {e}", fg='yellow'))
1831
-
2084
+ click.echo(click.style(f" ⚠ Could not stop worker: {e}", fg="yellow"))
2085
+
1832
2086
  # Remove user data if requested
1833
2087
  if purge_data:
1834
2088
  click.echo("\n2. Removing user data...")
1835
- data_dir = Path.home() / '.souleyez'
2089
+ data_dir = Path.home() / ".souleyez"
1836
2090
  if data_dir.exists():
1837
- click.echo(click.style(f" ⚠ WARNING: This will delete:", fg='yellow', bold=True))
2091
+ click.echo(
2092
+ click.style(f" ⚠ WARNING: This will delete:", fg="yellow", bold=True)
2093
+ )
1838
2094
  click.echo(f" • Database: {data_dir / 'souleyez.db'}")
1839
2095
  click.echo(f" • Crypto keys: {data_dir / 'crypto.json'}")
1840
2096
  click.echo(f" • Logs: {data_dir / 'souleyez.log'}")
1841
2097
  click.echo(f" • All engagement data")
1842
2098
  click.echo()
1843
-
1844
- if click.confirm(click.style(" Delete ALL user data?", fg='red', bold=True)):
2099
+
2100
+ if click.confirm(
2101
+ click.style(" Delete ALL user data?", fg="red", bold=True)
2102
+ ):
1845
2103
  try:
1846
2104
  shutil.rmtree(data_dir)
1847
- click.echo(click.style(f" ✓ Removed {data_dir}", fg='green'))
2105
+ click.echo(click.style(f" ✓ Removed {data_dir}", fg="green"))
1848
2106
  except Exception as e:
1849
- click.echo(click.style(f" ✗ Error: {e}", fg='red'))
2107
+ click.echo(click.style(f" ✗ Error: {e}", fg="red"))
1850
2108
  else:
1851
- click.echo(click.style(" → User data preserved", fg='cyan'))
2109
+ click.echo(click.style(" → User data preserved", fg="cyan"))
1852
2110
  else:
1853
- click.echo(click.style(" ℹ No user data found", fg='cyan'))
2111
+ click.echo(click.style(" ℹ No user data found", fg="cyan"))
1854
2112
  else:
1855
- click.echo(click.style("\n2. User data preserved in ~/.souleyez", fg='cyan'))
2113
+ click.echo(click.style("\n2. User data preserved in ~/.souleyez", fg="cyan"))
1856
2114
  click.echo(" • Your engagements and database are safe")
1857
2115
  click.echo(" • Reinstalling will restore access to your data")
1858
2116
  click.echo(" • To remove data: souleyez uninstall --purge-data")
1859
-
2117
+
1860
2118
  # Remove application with pipx
1861
2119
  click.echo("\n3. Removing application...")
1862
2120
  click.echo(" Run: pipx uninstall souleyez")
1863
2121
  click.echo()
1864
-
2122
+
1865
2123
  try:
1866
- result = subprocess.run(['pipx', 'uninstall', 'souleyez'],
1867
- capture_output=True, text=True, check=False)
2124
+ result = subprocess.run(
2125
+ ["pipx", "uninstall", "souleyez"],
2126
+ capture_output=True,
2127
+ text=True,
2128
+ check=False,
2129
+ )
1868
2130
  if result.returncode == 0:
1869
- click.echo(click.style(" ✓ SoulEyez uninstalled successfully", fg='green'))
2131
+ click.echo(
2132
+ click.style(" ✓ SoulEyez uninstalled successfully", fg="green")
2133
+ )
1870
2134
  else:
1871
- click.echo(click.style(f" ⚠ pipx uninstall returned: {result.stderr}", fg='yellow'))
2135
+ click.echo(
2136
+ click.style(
2137
+ f" ⚠ pipx uninstall returned: {result.stderr}", fg="yellow"
2138
+ )
2139
+ )
1872
2140
  click.echo(" You may need to run manually: pipx uninstall souleyez")
1873
2141
  except FileNotFoundError:
1874
- click.echo(click.style(" ℹ pipx not found - uninstall manually", fg='cyan'))
2142
+ click.echo(click.style(" ℹ pipx not found - uninstall manually", fg="cyan"))
1875
2143
  click.echo(" Run: pip uninstall souleyez")
1876
2144
  except Exception as e:
1877
- click.echo(click.style(f" ✗ Error: {e}", fg='red'))
1878
-
2145
+ click.echo(click.style(f" ✗ Error: {e}", fg="red"))
2146
+
1879
2147
  click.echo()
1880
2148
  if purge_data:
1881
- click.echo(click.style("✓ SoulEyez completely removed", fg='green', bold=True))
2149
+ click.echo(click.style("✓ SoulEyez completely removed", fg="green", bold=True))
1882
2150
  else:
1883
- click.echo(click.style("✓ SoulEyez removed (data preserved)", fg='green', bold=True))
2151
+ click.echo(
2152
+ click.style("✓ SoulEyez removed (data preserved)", fg="green", bold=True)
2153
+ )
1884
2154
  click.echo()
1885
2155
 
1886
2156
 
1887
2157
  # Import and register auth commands (must be before if __name__ block)
1888
2158
  from souleyez.commands.auth import login, logout, whoami
2159
+
1889
2160
  cli.add_command(login)
1890
2161
  cli.add_command(logout)
1891
2162
  cli.add_command(whoami)
1892
2163
 
1893
2164
  # Import and register user management commands
1894
2165
  from souleyez.commands.user import user
2166
+
1895
2167
  cli.add_command(user)
1896
2168
 
1897
2169
  # Import and register license management commands
1898
2170
  from souleyez.commands.license import license
2171
+
1899
2172
  cli.add_command(license)
1900
2173
 
1901
2174
 
1902
- if __name__ == '__main__':
2175
+ if __name__ == "__main__":
1903
2176
  main()
1904
2177
 
1905
2178
 
1906
2179
  # ==================== HOST COMMANDS ====================
1907
2180
 
2181
+
1908
2182
  @cli.group()
1909
2183
  def hosts():
1910
2184
  """Host management commands."""
@@ -1912,7 +2186,9 @@ def hosts():
1912
2186
 
1913
2187
 
1914
2188
  @hosts.command("list")
1915
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2189
+ @click.option(
2190
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2191
+ )
1916
2192
  @click.option("--all", "-a", is_flag=True, help="Show all hosts (including down hosts)")
1917
2193
  @click.option("--status", "-s", default=None, help="Filter by status (up/down/unknown)")
1918
2194
  def hosts_list(engagement, all, status):
@@ -1929,23 +2205,28 @@ def hosts_list(engagement, all, status):
1929
2205
  else:
1930
2206
  eng = em.get_current()
1931
2207
  if not eng:
1932
- click.echo("✗ No engagement selected. Use: souleyez engagement use <name>", err=True)
2208
+ click.echo(
2209
+ "✗ No engagement selected. Use: souleyez engagement use <name>",
2210
+ err=True,
2211
+ )
1933
2212
  return
1934
2213
 
1935
2214
  hm = HostManager()
1936
- all_hosts = hm.list_hosts(eng['id'])
2215
+ all_hosts = hm.list_hosts(eng["id"])
1937
2216
 
1938
2217
  # Filter hosts
1939
2218
  if status:
1940
- hosts = [h for h in all_hosts if h.get('status', 'unknown') == status]
2219
+ hosts = [h for h in all_hosts if h.get("status", "unknown") == status]
1941
2220
  elif not all:
1942
2221
  # Default: only show 'up' hosts
1943
- hosts = [h for h in all_hosts if h.get('status', 'unknown') == 'up']
2222
+ hosts = [h for h in all_hosts if h.get("status", "unknown") == "up"]
1944
2223
  else:
1945
2224
  hosts = all_hosts
1946
2225
 
1947
2226
  if not hosts:
1948
- filter_msg = f" with status='{status}'" if status else " (live only)" if not all else ""
2227
+ filter_msg = (
2228
+ f" with status='{status}'" if status else " (live only)" if not all else ""
2229
+ )
1949
2230
  click.echo(f"No hosts found in workspace '{eng['name']}'{filter_msg}")
1950
2231
  return
1951
2232
 
@@ -1978,8 +2259,16 @@ def hosts_list(engagement, all, status):
1978
2259
  @click.argument("ip_address")
1979
2260
  @click.option("--hostname", "-n", default=None, help="Hostname")
1980
2261
  @click.option("--os", default=None, help="Operating system")
1981
- @click.option("--status", "-s", default="up", type=click.Choice(['up', 'down', 'unknown']), help="Host status")
1982
- @click.option("--engagement", "-e", default=None, help="Engagement name (default: current)")
2262
+ @click.option(
2263
+ "--status",
2264
+ "-s",
2265
+ default="up",
2266
+ type=click.Choice(["up", "down", "unknown"]),
2267
+ help="Host status",
2268
+ )
2269
+ @click.option(
2270
+ "--engagement", "-e", default=None, help="Engagement name (default: current)"
2271
+ )
1983
2272
  def hosts_add(ip_address, hostname, os, status, engagement):
1984
2273
  """Manually add a host to engagement."""
1985
2274
  from souleyez.storage.hosts import HostManager
@@ -1994,20 +2283,20 @@ def hosts_add(ip_address, hostname, os, status, engagement):
1994
2283
  else:
1995
2284
  eng = em.get_current()
1996
2285
  if not eng:
1997
- click.echo("✗ No engagement selected. Use: souleyez engagement use <name>", err=True)
2286
+ click.echo(
2287
+ "✗ No engagement selected. Use: souleyez engagement use <name>",
2288
+ err=True,
2289
+ )
1998
2290
  return
1999
2291
 
2000
2292
  hm = HostManager()
2001
2293
 
2002
2294
  try:
2003
- host_data = {
2004
- 'ip': ip_address,
2005
- 'hostname': hostname,
2006
- 'os': os,
2007
- 'status': status
2008
- }
2009
- host_id = hm.add_or_update_host(eng['id'], host_data)
2010
- click.echo(f"✓ Added host {ip_address} to engagement '{eng['name']}' (id={host_id})")
2295
+ host_data = {"ip": ip_address, "hostname": hostname, "os": os, "status": status}
2296
+ host_id = hm.add_or_update_host(eng["id"], host_data)
2297
+ click.echo(
2298
+ f"✓ Added host {ip_address} to engagement '{eng['name']}' (id={host_id})"
2299
+ )
2011
2300
  if hostname:
2012
2301
  click.echo(f" Hostname: {hostname}")
2013
2302
  if os:
@@ -2019,11 +2308,13 @@ def hosts_add(ip_address, hostname, os, status, engagement):
2019
2308
 
2020
2309
  @hosts.command("show")
2021
2310
  @click.argument("ip_address")
2022
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2311
+ @click.option(
2312
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2313
+ )
2023
2314
  def hosts_show(ip_address, engagement):
2024
2315
  """Show detailed host information."""
2025
2316
  from souleyez.storage.hosts import HostManager
2026
-
2317
+
2027
2318
  em = EngagementManager()
2028
2319
 
2029
2320
  if engagement:
@@ -2036,16 +2327,18 @@ def hosts_show(ip_address, engagement):
2036
2327
  if not eng:
2037
2328
  click.echo("✗ No engagement selected", err=True)
2038
2329
  return
2039
-
2330
+
2040
2331
  hm = HostManager()
2041
- host = hm.get_host_by_ip(eng['id'], ip_address)
2042
-
2332
+ host = hm.get_host_by_ip(eng["id"], ip_address)
2333
+
2043
2334
  if not host:
2044
- click.echo(f"✗ Host {ip_address} not found in workspace '{eng['name']}'", err=True)
2335
+ click.echo(
2336
+ f"✗ Host {ip_address} not found in workspace '{eng['name']}'", err=True
2337
+ )
2045
2338
  return
2046
-
2047
- services = hm.get_host_services(host['id'])
2048
-
2339
+
2340
+ services = hm.get_host_services(host["id"])
2341
+
2049
2342
  click.echo("\n" + "=" * 80)
2050
2343
  click.echo(f"HOST: {host['ip_address']}")
2051
2344
  click.echo("=" * 80)
@@ -2055,13 +2348,15 @@ def hosts_show(ip_address, engagement):
2055
2348
  click.echo(f"MAC: {host.get('mac_address') or 'N/A'}")
2056
2349
  click.echo(f"First seen: {host.get('created_at', 'N/A')}")
2057
2350
  click.echo(f"Last updated: {host.get('updated_at', 'N/A')}")
2058
-
2351
+
2059
2352
  click.echo("\n" + "-" * 80)
2060
2353
  click.echo(f"SERVICES ({len(services)})")
2061
2354
  click.echo("-" * 80)
2062
-
2355
+
2063
2356
  if services:
2064
- click.echo(f"{'Port':<10} {'Protocol':<10} {'State':<10} {'Service':<20} {'Version':<30}")
2357
+ click.echo(
2358
+ f"{'Port':<10} {'Protocol':<10} {'State':<10} {'Service':<20} {'Version':<30}"
2359
+ )
2065
2360
  click.echo("-" * 80)
2066
2361
  for svc in services:
2067
2362
  click.echo(
@@ -2073,24 +2368,37 @@ def hosts_show(ip_address, engagement):
2073
2368
  )
2074
2369
  else:
2075
2370
  click.echo("No services found")
2076
-
2371
+
2077
2372
  click.echo("=" * 80 + "\n")
2078
2373
 
2079
2374
 
2080
2375
  @hosts.command("update")
2081
2376
  @click.argument("ip_address")
2082
- @click.option("--status", type=click.Choice(['active', 'compromised', 'offline', 'up', 'down']), help="Host status")
2083
- @click.option("--access-level", type=click.Choice(['none', 'user', 'admin', 'root']), help="Access level gained")
2377
+ @click.option(
2378
+ "--status",
2379
+ type=click.Choice(["active", "compromised", "offline", "up", "down"]),
2380
+ help="Host status",
2381
+ )
2382
+ @click.option(
2383
+ "--access-level",
2384
+ type=click.Choice(["none", "user", "admin", "root"]),
2385
+ help="Access level gained",
2386
+ )
2084
2387
  @click.option("--notes", help="Additional notes about the host")
2085
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2388
+ @click.option(
2389
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2390
+ )
2086
2391
  def hosts_update(ip_address, status, access_level, notes, engagement):
2087
2392
  """Update host status, access level, and notes."""
2088
2393
  from souleyez.storage.hosts import HostManager
2089
-
2394
+
2090
2395
  if not status and not access_level and notes is None:
2091
- click.echo("✗ Must provide at least one of --status, --access-level, or --notes", err=True)
2396
+ click.echo(
2397
+ "✗ Must provide at least one of --status, --access-level, or --notes",
2398
+ err=True,
2399
+ )
2092
2400
  return
2093
-
2401
+
2094
2402
  em = EngagementManager()
2095
2403
 
2096
2404
  if engagement:
@@ -2103,18 +2411,22 @@ def hosts_update(ip_address, status, access_level, notes, engagement):
2103
2411
  if not eng:
2104
2412
  click.echo("✗ No engagement selected", err=True)
2105
2413
  return
2106
-
2414
+
2107
2415
  hm = HostManager()
2108
- host = hm.get_host_by_ip(eng['id'], ip_address)
2109
-
2416
+ host = hm.get_host_by_ip(eng["id"], ip_address)
2417
+
2110
2418
  if not host:
2111
- click.echo(f"✗ Host {ip_address} not found in workspace '{eng['name']}'", err=True)
2419
+ click.echo(
2420
+ f"✗ Host {ip_address} not found in workspace '{eng['name']}'", err=True
2421
+ )
2112
2422
  return
2113
-
2114
- success = hm.update_host_status(host['id'], status=status, access_level=access_level, notes=notes)
2115
-
2423
+
2424
+ success = hm.update_host_status(
2425
+ host["id"], status=status, access_level=access_level, notes=notes
2426
+ )
2427
+
2116
2428
  if success:
2117
- click.echo(click.style(f"✓ Host {ip_address} updated", fg='green'))
2429
+ click.echo(click.style(f"✓ Host {ip_address} updated", fg="green"))
2118
2430
  if status:
2119
2431
  click.echo(f" Status: {status}")
2120
2432
  if access_level:
@@ -2122,11 +2434,12 @@ def hosts_update(ip_address, status, access_level, notes, engagement):
2122
2434
  if notes:
2123
2435
  click.echo(f" Notes: {notes}")
2124
2436
  else:
2125
- click.echo(click.style(f"✗ Failed to update host {ip_address}", fg='red'))
2437
+ click.echo(click.style(f"✗ Failed to update host {ip_address}", fg="red"))
2126
2438
 
2127
2439
 
2128
2440
  # ==================== SERVICE COMMANDS ====================
2129
2441
 
2442
+
2130
2443
  @cli.group()
2131
2444
  def services():
2132
2445
  """Service management commands."""
@@ -2136,11 +2449,18 @@ def services():
2136
2449
  @services.command("add")
2137
2450
  @click.argument("ip_address")
2138
2451
  @click.argument("port", type=int)
2139
- @click.argument("protocol", type=click.Choice(['tcp', 'udp']))
2452
+ @click.argument("protocol", type=click.Choice(["tcp", "udp"]))
2140
2453
  @click.option("--service", "-s", default=None, help="Service name (e.g., ssh, http)")
2141
2454
  @click.option("--version", "-v", default=None, help="Service version")
2142
- @click.option("--state", default="open", type=click.Choice(['open', 'closed', 'filtered']), help="Service state")
2143
- @click.option("--engagement", "-e", default=None, help="Engagement name (default: current)")
2455
+ @click.option(
2456
+ "--state",
2457
+ default="open",
2458
+ type=click.Choice(["open", "closed", "filtered"]),
2459
+ help="Service state",
2460
+ )
2461
+ @click.option(
2462
+ "--engagement", "-e", default=None, help="Engagement name (default: current)"
2463
+ )
2144
2464
  def services_add(ip_address, port, protocol, service, version, state, engagement):
2145
2465
  """Manually add a service to a host."""
2146
2466
  from souleyez.storage.hosts import HostManager
@@ -2155,35 +2475,37 @@ def services_add(ip_address, port, protocol, service, version, state, engagement
2155
2475
  else:
2156
2476
  eng = em.get_current()
2157
2477
  if not eng:
2158
- click.echo("✗ No engagement selected. Use: souleyez engagement use <name>", err=True)
2478
+ click.echo(
2479
+ "✗ No engagement selected. Use: souleyez engagement use <name>",
2480
+ err=True,
2481
+ )
2159
2482
  return
2160
2483
 
2161
2484
  hm = HostManager()
2162
2485
 
2163
2486
  # Get or create host
2164
- host = hm.get_host_by_ip(eng['id'], ip_address)
2487
+ host = hm.get_host_by_ip(eng["id"], ip_address)
2165
2488
  if not host:
2166
2489
  # Create host first
2167
2490
  click.echo(f"Host {ip_address} not found, creating it...")
2168
- host_data = {
2169
- 'ip': ip_address,
2170
- 'status': 'up'
2171
- }
2172
- host_id = hm.add_or_update_host(eng['id'], host_data)
2491
+ host_data = {"ip": ip_address, "status": "up"}
2492
+ host_id = hm.add_or_update_host(eng["id"], host_data)
2173
2493
  else:
2174
- host_id = host['id']
2494
+ host_id = host["id"]
2175
2495
 
2176
2496
  # Add service
2177
2497
  try:
2178
2498
  service_data = {
2179
- 'port': port,
2180
- 'protocol': protocol,
2181
- 'state': state,
2182
- 'service': service,
2183
- 'version': version
2499
+ "port": port,
2500
+ "protocol": protocol,
2501
+ "state": state,
2502
+ "service": service,
2503
+ "version": version,
2184
2504
  }
2185
2505
  service_id = hm.add_service(host_id, service_data)
2186
- click.echo(f"✓ Added service {ip_address}:{port}/{protocol} to engagement '{eng['name']}' (id={service_id})")
2506
+ click.echo(
2507
+ f"✓ Added service {ip_address}:{port}/{protocol} to engagement '{eng['name']}' (id={service_id})"
2508
+ )
2187
2509
  if service:
2188
2510
  click.echo(f" Service: {service}")
2189
2511
  if version:
@@ -2194,14 +2516,16 @@ def services_add(ip_address, port, protocol, service, version, state, engagement
2194
2516
 
2195
2517
 
2196
2518
  @services.command("list")
2197
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2519
+ @click.option(
2520
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2521
+ )
2198
2522
  @click.option("--port", "-p", type=int, default=None, help="Filter by port")
2199
2523
  def services_list(engagement, port):
2200
2524
  """List all services across all hosts."""
2201
2525
  from souleyez.storage.hosts import HostManager
2202
-
2526
+
2203
2527
  em = EngagementManager()
2204
-
2528
+
2205
2529
  if engagement:
2206
2530
  eng = em.get(engagement)
2207
2531
  if not eng:
@@ -2212,36 +2536,40 @@ def services_list(engagement, port):
2212
2536
  if not eng:
2213
2537
  click.echo("✗ No engagement selected", err=True)
2214
2538
  return
2215
-
2539
+
2216
2540
  hm = HostManager()
2217
-
2541
+
2218
2542
  # Get all hosts and their services
2219
- hosts = hm.list_hosts(eng['id'])
2220
-
2543
+ hosts = hm.list_hosts(eng["id"])
2544
+
2221
2545
  all_services = []
2222
2546
  for host in hosts:
2223
- services = hm.get_host_services(host['id'])
2547
+ services = hm.get_host_services(host["id"])
2224
2548
  for svc in services:
2225
- if port is None or svc['port'] == port:
2226
- all_services.append({
2227
- 'host_ip': host['ip_address'],
2228
- 'host_name': host.get('hostname'),
2229
- **svc
2230
- })
2231
-
2549
+ if port is None or svc["port"] == port:
2550
+ all_services.append(
2551
+ {
2552
+ "host_ip": host["ip_address"],
2553
+ "host_name": host.get("hostname"),
2554
+ **svc,
2555
+ }
2556
+ )
2557
+
2232
2558
  if not all_services:
2233
2559
  click.echo(f"No services found in workspace '{eng['name']}'")
2234
2560
  return
2235
-
2561
+
2236
2562
  click.echo("\n" + "=" * 120)
2237
2563
  click.echo(f"SERVICES - Engagement: {eng['name']}")
2238
2564
  if port:
2239
2565
  click.echo(f"Filtered by port: {port}")
2240
2566
  click.echo("=" * 120)
2241
- click.echo(f"{'Host':<18} {'Port':<8} {'Proto':<8} {'State':<10} {'Service':<20} {'Version':<40}")
2567
+ click.echo(
2568
+ f"{'Host':<18} {'Port':<8} {'Proto':<8} {'State':<10} {'Service':<20} {'Version':<40}"
2569
+ )
2242
2570
  click.echo("=" * 120)
2243
-
2244
- for svc in sorted(all_services, key=lambda x: (x['host_ip'], x['port'])):
2571
+
2572
+ for svc in sorted(all_services, key=lambda x: (x["host_ip"], x["port"])):
2245
2573
  click.echo(
2246
2574
  f"{svc['host_ip']:<18} "
2247
2575
  f"{svc['port']:<8} "
@@ -2250,13 +2578,14 @@ def services_list(engagement, port):
2250
2578
  f"{(svc.get('service_name') or 'unknown')[:19]:<20} "
2251
2579
  f"{(svc.get('service_version') or 'N/A')[:39]:<40}"
2252
2580
  )
2253
-
2581
+
2254
2582
  click.echo("=" * 120)
2255
2583
  click.echo(f"Total: {len(all_services)} services\n")
2256
2584
 
2257
2585
 
2258
2586
  # ==================== FINDINGS COMMANDS ====================
2259
2587
 
2588
+
2260
2589
  @cli.group()
2261
2590
  @require_password
2262
2591
  def findings():
@@ -2265,8 +2594,15 @@ def findings():
2265
2594
 
2266
2595
 
2267
2596
  @findings.command("list")
2268
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2269
- @click.option("--severity", "-s", default=None, help="Filter by severity (critical, high, medium, low, info)")
2597
+ @click.option(
2598
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2599
+ )
2600
+ @click.option(
2601
+ "--severity",
2602
+ "-s",
2603
+ default=None,
2604
+ help="Filter by severity (critical, high, medium, low, info)",
2605
+ )
2270
2606
  @click.option("--tool", "-t", default=None, help="Filter by tool")
2271
2607
  @click.option("--host", "-h", default=None, help="Filter by host IP")
2272
2608
  def findings_list(engagement, severity, tool, host):
@@ -2292,14 +2628,17 @@ def findings_list(engagement, severity, tool, host):
2292
2628
  host_id = None
2293
2629
  if host:
2294
2630
  from souleyez.storage.hosts import HostManager
2631
+
2295
2632
  hm = HostManager()
2296
- host_obj = hm.get_host_by_ip(eng['id'], host)
2633
+ host_obj = hm.get_host_by_ip(eng["id"], host)
2297
2634
  if not host_obj:
2298
2635
  click.echo(f"✗ Host {host} not found", err=True)
2299
2636
  return
2300
- host_id = host_obj['id']
2637
+ host_id = host_obj["id"]
2301
2638
 
2302
- findings = fm.list_findings(eng['id'], host_id=host_id, severity=severity, tool=tool)
2639
+ findings = fm.list_findings(
2640
+ eng["id"], host_id=host_id, severity=severity, tool=tool
2641
+ )
2303
2642
 
2304
2643
  if not findings:
2305
2644
  click.echo(f"No findings found in workspace '{eng['name']}'")
@@ -2307,11 +2646,11 @@ def findings_list(engagement, severity, tool, host):
2307
2646
 
2308
2647
  # Get severity color mapping
2309
2648
  severity_colors = {
2310
- 'critical': 'red',
2311
- 'high': 'red',
2312
- 'medium': 'yellow',
2313
- 'low': 'blue',
2314
- 'info': 'white'
2649
+ "critical": "red",
2650
+ "high": "red",
2651
+ "medium": "yellow",
2652
+ "low": "blue",
2653
+ "info": "white",
2315
2654
  }
2316
2655
 
2317
2656
  click.echo("\n" + "=" * 140)
@@ -2321,11 +2660,13 @@ def findings_list(engagement, severity, tool, host):
2321
2660
  if tool:
2322
2661
  click.echo(f"Filtered by tool: {tool}")
2323
2662
  click.echo("=" * 140)
2324
- click.echo(f"{'ID':<6} {'Severity':<10} {'Host':<18} {'Port':<6} {'Tool':<10} {'Title':<80}")
2663
+ click.echo(
2664
+ f"{'ID':<6} {'Severity':<10} {'Host':<18} {'Port':<6} {'Tool':<10} {'Title':<80}"
2665
+ )
2325
2666
  click.echo("=" * 140)
2326
2667
 
2327
2668
  for finding in findings:
2328
- sev_color = severity_colors.get(finding.get('severity', 'info'), 'white')
2669
+ sev_color = severity_colors.get(finding.get("severity", "info"), "white")
2329
2670
  click.echo(
2330
2671
  f"{finding['id']:<6} "
2331
2672
  f"{click.style(finding.get('severity', 'info').upper()[:9], fg=sev_color):<19} "
@@ -2362,13 +2703,13 @@ def findings_show(finding_id):
2362
2703
  click.echo(f"\nDescription:")
2363
2704
  click.echo(f" {finding.get('description', 'N/A')}")
2364
2705
 
2365
- if finding.get('path'):
2706
+ if finding.get("path"):
2366
2707
  click.echo(f"\nPath: {finding['path']}")
2367
2708
 
2368
- if finding.get('port'):
2709
+ if finding.get("port"):
2369
2710
  click.echo(f"Port: {finding['port']}")
2370
2711
 
2371
- if finding.get('refs'):
2712
+ if finding.get("refs"):
2372
2713
  click.echo(f"\nReference: {finding['refs']}")
2373
2714
 
2374
2715
  click.echo(f"\nDiscovered: {finding.get('created_at', 'N/A')}")
@@ -2376,7 +2717,9 @@ def findings_show(finding_id):
2376
2717
 
2377
2718
 
2378
2719
  @findings.command("summary")
2379
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2720
+ @click.option(
2721
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2722
+ )
2380
2723
  def findings_summary(engagement):
2381
2724
  """Show findings summary by severity."""
2382
2725
  from souleyez.storage.findings import FindingsManager
@@ -2395,7 +2738,7 @@ def findings_summary(engagement):
2395
2738
  return
2396
2739
 
2397
2740
  fm = FindingsManager()
2398
- summary = fm.get_findings_summary(eng['id'])
2741
+ summary = fm.get_findings_summary(eng["id"])
2399
2742
 
2400
2743
  total = sum(summary.values())
2401
2744
 
@@ -2405,17 +2748,17 @@ def findings_summary(engagement):
2405
2748
  click.echo(f"{'Severity':<15} {'Count':<10} {'Percentage':<15}")
2406
2749
  click.echo("=" * 60)
2407
2750
 
2408
- for severity in ['critical', 'high', 'medium', 'low', 'info']:
2751
+ for severity in ["critical", "high", "medium", "low", "info"]:
2409
2752
  count = summary.get(severity, 0)
2410
2753
  pct = (count / total * 100) if total > 0 else 0
2411
2754
 
2412
2755
  color = {
2413
- 'critical': 'red',
2414
- 'high': 'red',
2415
- 'medium': 'yellow',
2416
- 'low': 'blue',
2417
- 'info': 'white'
2418
- }.get(severity, 'white')
2756
+ "critical": "red",
2757
+ "high": "red",
2758
+ "medium": "yellow",
2759
+ "low": "blue",
2760
+ "info": "white",
2761
+ }.get(severity, "white")
2419
2762
 
2420
2763
  click.echo(
2421
2764
  f"{click.style(severity.upper(), fg=color):<24} "
@@ -2430,6 +2773,7 @@ def findings_summary(engagement):
2430
2773
 
2431
2774
  # ==================== OSINT COMMANDS ====================
2432
2775
 
2776
+
2433
2777
  @cli.group()
2434
2778
  @require_password
2435
2779
  def osint():
@@ -2438,8 +2782,12 @@ def osint():
2438
2782
 
2439
2783
 
2440
2784
  @osint.command("list")
2441
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2442
- @click.option("--type", "-t", default=None, help="Filter by data type (email, host, ip, url, asn)")
2785
+ @click.option(
2786
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2787
+ )
2788
+ @click.option(
2789
+ "--type", "-t", default=None, help="Filter by data type (email, host, ip, url, asn)"
2790
+ )
2443
2791
  @click.option("--source", "-s", default=None, help="Filter by source tool")
2444
2792
  def osint_list(engagement, type, source):
2445
2793
  """List all OSINT data in engagement."""
@@ -2459,7 +2807,7 @@ def osint_list(engagement, type, source):
2459
2807
  return
2460
2808
 
2461
2809
  om = OsintManager()
2462
- osint_data = om.list_osint_data(eng['id'], data_type=type, source=source)
2810
+ osint_data = om.list_osint_data(eng["id"], data_type=type, source=source)
2463
2811
 
2464
2812
  if not osint_data:
2465
2813
  click.echo(f"No OSINT data found in workspace '{eng['name']}'")
@@ -2472,7 +2820,9 @@ def osint_list(engagement, type, source):
2472
2820
  if source:
2473
2821
  click.echo(f"Filtered by source: {source}")
2474
2822
  click.echo("=" * 120)
2475
- click.echo(f"{'ID':<6} {'Type':<12} {'Source':<15} {'Value':<70} {'Discovered':<20}")
2823
+ click.echo(
2824
+ f"{'ID':<6} {'Type':<12} {'Source':<15} {'Value':<70} {'Discovered':<20}"
2825
+ )
2476
2826
  click.echo("=" * 120)
2477
2827
 
2478
2828
  for item in osint_data:
@@ -2489,7 +2839,9 @@ def osint_list(engagement, type, source):
2489
2839
 
2490
2840
 
2491
2841
  @osint.command("summary")
2492
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2842
+ @click.option(
2843
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2844
+ )
2493
2845
  def osint_summary(engagement):
2494
2846
  """Show OSINT data summary by type."""
2495
2847
  from souleyez.storage.osint import OsintManager
@@ -2508,7 +2860,7 @@ def osint_summary(engagement):
2508
2860
  return
2509
2861
 
2510
2862
  om = OsintManager()
2511
- summary = om.get_osint_summary(eng['id'])
2863
+ summary = om.get_osint_summary(eng["id"])
2512
2864
 
2513
2865
  total = sum(summary.values())
2514
2866
 
@@ -2526,20 +2878,16 @@ def osint_summary(engagement):
2526
2878
  count = summary[data_type]
2527
2879
  pct = (count / total * 100) if total > 0 else 0
2528
2880
 
2529
- click.echo(
2530
- f"{data_type:<15} "
2531
- f"{count:<10} "
2532
- f"{pct:.1f}%"
2533
- )
2881
+ click.echo(f"{data_type:<15} " f"{count:<10} " f"{pct:.1f}%")
2534
2882
 
2535
2883
  click.echo("=" * 60)
2536
2884
  click.echo(f"{'TOTAL':<15} {total}")
2537
2885
  click.echo("=" * 60 + "\n")
2538
2886
 
2539
2887
 
2540
-
2541
2888
  # ==================== WEB PATHS COMMANDS ====================
2542
2889
 
2890
+
2543
2891
  @cli.group()
2544
2892
  def paths():
2545
2893
  """Web paths/directories management commands."""
@@ -2547,8 +2895,12 @@ def paths():
2547
2895
 
2548
2896
 
2549
2897
  @paths.command("list")
2550
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2551
- @click.option("--status", "-s", type=int, default=None, help="Filter by HTTP status code")
2898
+ @click.option(
2899
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2900
+ )
2901
+ @click.option(
2902
+ "--status", "-s", type=int, default=None, help="Filter by HTTP status code"
2903
+ )
2552
2904
  @click.option("--host", "-h", default=None, help="Filter by host IP or hostname")
2553
2905
  def paths_list(engagement, status, host):
2554
2906
  """List discovered web paths."""
@@ -2574,10 +2926,10 @@ def paths_list(engagement, status, host):
2574
2926
  host_id = None
2575
2927
  if host:
2576
2928
  hm = HostManager()
2577
- hosts = hm.list_hosts(eng['id'])
2929
+ hosts = hm.list_hosts(eng["id"])
2578
2930
  for h in hosts:
2579
- if h.get('hostname') == host or h.get('ip_address') == host:
2580
- host_id = h['id']
2931
+ if h.get("hostname") == host or h.get("ip_address") == host:
2932
+ host_id = h["id"]
2581
2933
  break
2582
2934
  if not host_id:
2583
2935
  click.echo(f"✗ Host {host} not found", err=True)
@@ -2587,7 +2939,7 @@ def paths_list(engagement, status, host):
2587
2939
  if host_id:
2588
2940
  paths = wpm.list_web_paths(host_id=host_id, status_code=status)
2589
2941
  else:
2590
- paths = wpm.list_web_paths(engagement_id=eng['id'], status_code=status)
2942
+ paths = wpm.list_web_paths(engagement_id=eng["id"], status_code=status)
2591
2943
 
2592
2944
  if not paths:
2593
2945
  click.echo(f"No web paths found in workspace '{eng['name']}'")
@@ -2604,18 +2956,18 @@ def paths_list(engagement, status, host):
2604
2956
  click.echo("=" * 140)
2605
2957
 
2606
2958
  for path in paths:
2607
- status_code = path.get('status_code', 'N/A')
2959
+ status_code = path.get("status_code", "N/A")
2608
2960
  # Color code status
2609
2961
  if status_code == 200:
2610
- status_str = click.style(str(status_code), fg='green')
2962
+ status_str = click.style(str(status_code), fg="green")
2611
2963
  elif 300 <= status_code < 400:
2612
- status_str = click.style(str(status_code), fg='yellow')
2964
+ status_str = click.style(str(status_code), fg="yellow")
2613
2965
  elif 400 <= status_code < 500:
2614
- status_str = click.style(str(status_code), fg='red')
2966
+ status_str = click.style(str(status_code), fg="red")
2615
2967
  else:
2616
2968
  status_str = str(status_code)
2617
2969
 
2618
- host_info = path.get('hostname') or path.get('ip_address') or 'N/A'
2970
+ host_info = path.get("hostname") or path.get("ip_address") or "N/A"
2619
2971
 
2620
2972
  click.echo(
2621
2973
  f"{path['id']:<6} "
@@ -2630,7 +2982,9 @@ def paths_list(engagement, status, host):
2630
2982
 
2631
2983
 
2632
2984
  @paths.command("summary")
2633
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2985
+ @click.option(
2986
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
2987
+ )
2634
2988
  def paths_summary(engagement):
2635
2989
  """Show web paths summary by status code."""
2636
2990
  from souleyez.storage.web_paths import WebPathsManager
@@ -2649,7 +3003,7 @@ def paths_summary(engagement):
2649
3003
  return
2650
3004
 
2651
3005
  wpm = WebPathsManager()
2652
- summary = wpm.get_paths_summary(eng['id'])
3006
+ summary = wpm.get_paths_summary(eng["id"])
2653
3007
 
2654
3008
  total = sum(summary.values())
2655
3009
 
@@ -2663,18 +3017,20 @@ def paths_summary(engagement):
2663
3017
  click.echo(f"{'Status Code':<15} {'Count':<10} {'Percentage':<15}")
2664
3018
  click.echo("=" * 60)
2665
3019
 
2666
- for status_code in sorted(summary.keys(), key=lambda x: int(x) if x.isdigit() else 999):
3020
+ for status_code in sorted(
3021
+ summary.keys(), key=lambda x: int(x) if x.isdigit() else 999
3022
+ ):
2667
3023
  count = summary[status_code]
2668
3024
  pct = (count / total * 100) if total > 0 else 0
2669
3025
 
2670
3026
  # Color code
2671
3027
  status_int = int(status_code) if status_code.isdigit() else 0
2672
3028
  if status_int == 200:
2673
- status_display = click.style(status_code, fg='green')
3029
+ status_display = click.style(status_code, fg="green")
2674
3030
  elif 300 <= status_int < 400:
2675
- status_display = click.style(status_code, fg='yellow')
3031
+ status_display = click.style(status_code, fg="yellow")
2676
3032
  elif 400 <= status_int < 500:
2677
- status_display = click.style(status_code, fg='red')
3033
+ status_display = click.style(status_code, fg="red")
2678
3034
  else:
2679
3035
  status_display = status_code
2680
3036
 
@@ -2691,6 +3047,7 @@ def paths_summary(engagement):
2691
3047
 
2692
3048
  # ==================== CREDENTIALS COMMANDS ====================
2693
3049
 
3050
+
2694
3051
  @cli.group()
2695
3052
  def creds():
2696
3053
  """Credentials management - similar to MSF's creds command."""
@@ -2700,11 +3057,20 @@ def creds():
2700
3057
  @creds.command("add")
2701
3058
  @click.argument("username")
2702
3059
  @click.argument("password")
2703
- @click.option("--service", "-s", default=None, help="Service type (ssh, smb, mysql, etc.)")
3060
+ @click.option(
3061
+ "--service", "-s", default=None, help="Service type (ssh, smb, mysql, etc.)"
3062
+ )
2704
3063
  @click.option("--host", "-h", default=None, help="Host IP address")
2705
3064
  @click.option("--port", "-p", type=int, default=None, help="Port number")
2706
- @click.option("--status", default="untested", type=click.Choice(['valid', 'invalid', 'untested']), help="Credential status")
2707
- @click.option("--engagement", "-e", default=None, help="Engagement name (default: current)")
3065
+ @click.option(
3066
+ "--status",
3067
+ default="untested",
3068
+ type=click.Choice(["valid", "invalid", "untested"]),
3069
+ help="Credential status",
3070
+ )
3071
+ @click.option(
3072
+ "--engagement", "-e", default=None, help="Engagement name (default: current)"
3073
+ )
2708
3074
  def creds_add(username, password, service, host, port, status, engagement):
2709
3075
  """Manually add credentials to engagement."""
2710
3076
  from souleyez.storage.credentials import CredentialsManager
@@ -2720,34 +3086,37 @@ def creds_add(username, password, service, host, port, status, engagement):
2720
3086
  else:
2721
3087
  eng = em.get_current()
2722
3088
  if not eng:
2723
- click.echo("✗ No engagement selected. Use: souleyez engagement use <name>", err=True)
3089
+ click.echo(
3090
+ "✗ No engagement selected. Use: souleyez engagement use <name>",
3091
+ err=True,
3092
+ )
2724
3093
  return
2725
3094
 
2726
3095
  # Get host_id if host specified
2727
3096
  host_id = None
2728
3097
  if host:
2729
3098
  hm = HostManager()
2730
- host_obj = hm.get_host_by_ip(eng['id'], host)
3099
+ host_obj = hm.get_host_by_ip(eng["id"], host)
2731
3100
  if not host_obj:
2732
3101
  # Create host if it doesn't exist
2733
3102
  click.echo(f"Host {host} not found, creating it...")
2734
- host_data = {'ip': host, 'status': 'up'}
2735
- host_id = hm.add_or_update_host(eng['id'], host_data)
3103
+ host_data = {"ip": host, "status": "up"}
3104
+ host_id = hm.add_or_update_host(eng["id"], host_data)
2736
3105
  else:
2737
- host_id = host_obj['id']
3106
+ host_id = host_obj["id"]
2738
3107
 
2739
3108
  cm = CredentialsManager()
2740
3109
 
2741
3110
  try:
2742
3111
  cred_id = cm.add_credential(
2743
- engagement_id=eng['id'],
3112
+ engagement_id=eng["id"],
2744
3113
  host_id=host_id,
2745
3114
  username=username,
2746
3115
  password=password,
2747
3116
  service=service,
2748
3117
  port=port,
2749
3118
  status=status,
2750
- tool='manual'
3119
+ tool="manual",
2751
3120
  )
2752
3121
  click.echo(f"✓ Added credential to engagement '{eng['name']}' (id={cred_id})")
2753
3122
  click.echo(f" Username: {username}")
@@ -2764,8 +3133,12 @@ def creds_add(username, password, service, host, port, status, engagement):
2764
3133
 
2765
3134
 
2766
3135
  @creds.command("list")
2767
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
2768
- @click.option("--service", "-s", default=None, help="Filter by service (ssh, smb, mysql, etc.)")
3136
+ @click.option(
3137
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
3138
+ )
3139
+ @click.option(
3140
+ "--service", "-s", default=None, help="Filter by service (ssh, smb, mysql, etc.)"
3141
+ )
2769
3142
  @click.option("--status", "-t", default=None, help="Filter by status (valid, untested)")
2770
3143
  @click.option("--host", "-h", default=None, help="Filter by host IP")
2771
3144
  def creds_list(engagement, service, status, host):
@@ -2796,13 +3169,15 @@ def creds_list(engagement, service, status, host):
2796
3169
  host_id = None
2797
3170
  if host:
2798
3171
  hm = HostManager()
2799
- host_obj = hm.get_host_by_ip(eng['id'], host)
3172
+ host_obj = hm.get_host_by_ip(eng["id"], host)
2800
3173
  if not host_obj:
2801
3174
  click.echo(f"✗ Host {host} not found", err=True)
2802
3175
  return
2803
- host_id = host_obj['id']
3176
+ host_id = host_obj["id"]
2804
3177
 
2805
- creds = cm.list_credentials(eng['id'], host_id=host_id, service=service, status=status)
3178
+ creds = cm.list_credentials(
3179
+ eng["id"], host_id=host_id, service=service, status=status
3180
+ )
2806
3181
 
2807
3182
  if not creds:
2808
3183
  filter_msg = ""
@@ -2814,7 +3189,7 @@ def creds_list(engagement, service, status, host):
2814
3189
  return
2815
3190
 
2816
3191
  # Get stats
2817
- stats = cm.get_stats(eng['id'])
3192
+ stats = cm.get_stats(eng["id"])
2818
3193
 
2819
3194
  click.echo("\n" + "=" * 100)
2820
3195
  click.echo(f"CREDENTIALS - Engagement: {eng['name']}")
@@ -2830,18 +3205,22 @@ def creds_list(engagement, service, status, host):
2830
3205
  click.echo("=" * 100)
2831
3206
 
2832
3207
  # Summary line
2833
- click.echo(f"Total: {stats['total']} | " +
2834
- click.style(f"Valid: {stats['valid']}", fg='green', bold=True) +
2835
- f" | Usernames: {stats['users_only']} | Pairs: {stats['pairs']}")
3208
+ click.echo(
3209
+ f"Total: {stats['total']} | "
3210
+ + click.style(f"Valid: {stats['valid']}", fg="green", bold=True)
3211
+ + f" | Usernames: {stats['users_only']} | Pairs: {stats['pairs']}"
3212
+ )
2836
3213
  click.echo()
2837
3214
 
2838
3215
  # Separate valid and untested
2839
- valid_creds = [c for c in creds if c.get('status') == 'valid']
2840
- untested_creds = [c for c in creds if c.get('status') != 'valid']
3216
+ valid_creds = [c for c in creds if c.get("status") == "valid"]
3217
+ untested_creds = [c for c in creds if c.get("status") != "valid"]
2841
3218
 
2842
3219
  # Show valid credentials
2843
3220
  if valid_creds:
2844
- click.echo(click.style("VALID CREDENTIALS (Confirmed Working)", bold=True, fg='green'))
3221
+ click.echo(
3222
+ click.style("VALID CREDENTIALS (Confirmed Working)", bold=True, fg="green")
3223
+ )
2845
3224
  click.echo()
2846
3225
 
2847
3226
  # Create Rich Table
@@ -2856,12 +3235,12 @@ def creds_list(engagement, service, status, host):
2856
3235
  table.add_column("Tool", width=15)
2857
3236
 
2858
3237
  for cred in valid_creds:
2859
- username = cred.get('username', '')[:19]
2860
- password = cred.get('password', '')[:19]
2861
- service_name = cred.get('service', 'N/A')[:9]
2862
- ip = cred.get('ip_address', 'N/A')[:17]
2863
- port = str(cred.get('port', 'N/A'))[:5]
2864
- tool_name = cred.get('tool', 'N/A')[:14]
3238
+ username = cred.get("username", "")[:19]
3239
+ password = cred.get("password", "")[:19]
3240
+ service_name = cred.get("service", "N/A")[:9]
3241
+ ip = cred.get("ip_address", "N/A")[:17]
3242
+ port = str(cred.get("port", "N/A"))[:5]
3243
+ tool_name = cred.get("tool", "N/A")[:14]
2865
3244
 
2866
3245
  table.add_row("✓", username, password, service_name, ip, port, tool_name)
2867
3246
 
@@ -2870,19 +3249,25 @@ def creds_list(engagement, service, status, host):
2870
3249
 
2871
3250
  # Show discovered usernames
2872
3251
  if untested_creds:
2873
- click.echo(click.style(f"DISCOVERED USERNAMES ({len(untested_creds)} untested)", bold=True, fg='cyan'))
3252
+ click.echo(
3253
+ click.style(
3254
+ f"DISCOVERED USERNAMES ({len(untested_creds)} untested)",
3255
+ bold=True,
3256
+ fg="cyan",
3257
+ )
3258
+ )
2874
3259
  click.echo(DesignSystem.separator())
2875
3260
 
2876
3261
  # Group by service
2877
3262
  by_service = {}
2878
3263
  for cred in untested_creds:
2879
- svc = cred.get('service', 'unknown')
3264
+ svc = cred.get("service", "unknown")
2880
3265
  if svc not in by_service:
2881
3266
  by_service[svc] = []
2882
- by_service[svc].append(cred.get('username', ''))
3267
+ by_service[svc].append(cred.get("username", ""))
2883
3268
 
2884
3269
  for svc, usernames in sorted(by_service.items()):
2885
- user_list = ', '.join(sorted(usernames))
3270
+ user_list = ", ".join(sorted(usernames))
2886
3271
  click.echo(f"{svc.upper():<8} ({len(usernames):2}): {user_list}")
2887
3272
 
2888
3273
  click.echo(DesignSystem.separator())
@@ -2891,7 +3276,9 @@ def creds_list(engagement, service, status, host):
2891
3276
 
2892
3277
 
2893
3278
  @creds.command("stats")
2894
- @click.option("--engagement", "-w", default=None, help="Engagement name (default: current)")
3279
+ @click.option(
3280
+ "--engagement", "-w", default=None, help="Engagement name (default: current)"
3281
+ )
2895
3282
  def creds_stats(engagement):
2896
3283
  """Show credentials statistics."""
2897
3284
  from souleyez.storage.credentials import CredentialsManager
@@ -2910,13 +3297,15 @@ def creds_stats(engagement):
2910
3297
  return
2911
3298
 
2912
3299
  cm = CredentialsManager()
2913
- stats = cm.get_stats(eng['id'])
3300
+ stats = cm.get_stats(eng["id"])
2914
3301
 
2915
3302
  click.echo("\n" + "=" * 60)
2916
3303
  click.echo(f"CREDENTIALS STATISTICS - Engagement: {eng['name']}")
2917
3304
  click.echo("=" * 60)
2918
3305
  click.echo(f"Total Credentials: {stats['total']}")
2919
- click.echo(f"Valid (confirmed): {click.style(str(stats['valid']), fg='green')}")
3306
+ click.echo(
3307
+ f"Valid (confirmed): {click.style(str(stats['valid']), fg='green')}"
3308
+ )
2920
3309
  click.echo(f"Username-only: {stats['users_only']}")
2921
3310
  click.echo(f"Password-only: {stats['passwords_only']}")
2922
3311
  click.echo(f"Username:Password pairs: {stats['pairs']}")
@@ -2925,26 +3314,294 @@ def creds_stats(engagement):
2925
3314
 
2926
3315
  @creds.command("update")
2927
3316
  @click.argument("credential_id", type=int)
2928
- @click.option("--status", type=click.Choice(['untested', 'valid', 'invalid']), help="Credential status")
3317
+ @click.option(
3318
+ "--status",
3319
+ type=click.Choice(["untested", "valid", "invalid"]),
3320
+ help="Credential status",
3321
+ )
2929
3322
  @click.option("--notes", help="Additional notes")
2930
3323
  def creds_update(credential_id, status, notes):
2931
3324
  """Update credential status and notes."""
2932
3325
  from souleyez.storage.credentials import CredentialsManager
2933
-
3326
+
2934
3327
  if not status and notes is None:
2935
3328
  click.echo("✗ Must provide --status or --notes", err=True)
2936
3329
  return
2937
-
3330
+
2938
3331
  cm = CredentialsManager()
2939
-
3332
+
2940
3333
  try:
2941
3334
  success = cm.update_credential_status(credential_id, status=status, notes=notes)
2942
3335
  if success:
2943
- click.echo(click.style(f"✓ Credential {credential_id} updated", fg='green'))
3336
+ click.echo(click.style(f"✓ Credential {credential_id} updated", fg="green"))
2944
3337
  else:
2945
- click.echo(click.style(f"✗ Credential {credential_id} not found", fg='red'))
3338
+ click.echo(click.style(f"✗ Credential {credential_id} not found", fg="red"))
2946
3339
  except Exception as e:
2947
- click.echo(click.style(f"✗ Error: {e}", fg='red'))
3340
+ click.echo(click.style(f"✗ Error: {e}", fg="red"))
3341
+
3342
+
3343
+ @creds.command("cleanup")
3344
+ @require_password
3345
+ @click.option("--engagement", "-e", type=int, help="Engagement ID (default: all)")
3346
+ @click.option(
3347
+ "--confirm",
3348
+ is_flag=True,
3349
+ default=False,
3350
+ help="Actually delete the garbage credentials",
3351
+ )
3352
+ @click.option(
3353
+ "--all",
3354
+ "-a",
3355
+ "show_all",
3356
+ is_flag=True,
3357
+ default=False,
3358
+ help="Show all garbage credentials (default: first 20)",
3359
+ )
3360
+ def creds_cleanup(engagement, confirm, show_all):
3361
+ """Remove garbage credentials (scanner artifacts, injection payloads).
3362
+
3363
+ By default runs in dry-run mode to show what would be deleted.
3364
+ Use --confirm to actually delete the garbage entries.
3365
+
3366
+ Examples:
3367
+ souleyez creds cleanup # Preview what would be deleted
3368
+ souleyez creds cleanup --all # Show all garbage credentials
3369
+ souleyez creds cleanup --confirm # Actually delete garbage
3370
+ souleyez creds cleanup -e 5 # Preview for engagement 5 only
3371
+ """
3372
+ import re
3373
+ from souleyez.storage.credentials import CredentialsManager
3374
+ from souleyez.storage.engagements import EngagementManager
3375
+
3376
+ def is_garbage_username(username: str) -> bool:
3377
+ """Check if username is scanner garbage."""
3378
+ if not username or len(username) > 100:
3379
+ return True
3380
+
3381
+ username_lower = username.lower()
3382
+
3383
+ # Scanner tool signatures
3384
+ scanner_patterns = [
3385
+ "netsparker",
3386
+ "burpsuite",
3387
+ "burp",
3388
+ "acunetix",
3389
+ "nikto",
3390
+ "sqlmap",
3391
+ "havij",
3392
+ "w3af",
3393
+ "owasp",
3394
+ "zap",
3395
+ "wvs",
3396
+ ]
3397
+ for pattern in scanner_patterns:
3398
+ if pattern in username_lower:
3399
+ return True
3400
+
3401
+ # Template injection patterns
3402
+ injection_patterns = [
3403
+ "{{",
3404
+ "}}",
3405
+ "${",
3406
+ "}$",
3407
+ "<%",
3408
+ "%>",
3409
+ "{%",
3410
+ "%}",
3411
+ "sleep(",
3412
+ "benchmark(",
3413
+ "waitfor delay",
3414
+ "pg_sleep",
3415
+ ]
3416
+ for pattern in injection_patterns:
3417
+ if pattern in username_lower:
3418
+ return True
3419
+
3420
+ # Path traversal patterns
3421
+ path_patterns = [
3422
+ "/etc/",
3423
+ "\\etc\\",
3424
+ "/passwd",
3425
+ "/shadow",
3426
+ "/windows/",
3427
+ "c:\\",
3428
+ ".asp",
3429
+ ".aspx",
3430
+ ".axd",
3431
+ ".php",
3432
+ ".jsp",
3433
+ ".pl",
3434
+ "../",
3435
+ "..\\",
3436
+ "file://",
3437
+ "php://",
3438
+ "data://",
3439
+ "::1/",
3440
+ "[::1]",
3441
+ "/elmah",
3442
+ "/trace",
3443
+ "127.0.0.1/",
3444
+ ]
3445
+ for pattern in path_patterns:
3446
+ if pattern in username_lower:
3447
+ return True
3448
+
3449
+ # Command injection patterns
3450
+ cmd_patterns = [
3451
+ "& ping ",
3452
+ "| ping ",
3453
+ "; ping ",
3454
+ "ping -",
3455
+ "& whoami",
3456
+ "| whoami",
3457
+ "; whoami",
3458
+ "`whoami`",
3459
+ "$(whoami)",
3460
+ "cmd.exe",
3461
+ "/bin/sh",
3462
+ "& dir",
3463
+ "| dir",
3464
+ "; dir",
3465
+ "& ls",
3466
+ "| ls",
3467
+ "; ls",
3468
+ "nc -",
3469
+ "ncat ",
3470
+ "netcat ",
3471
+ ]
3472
+ for pattern in cmd_patterns:
3473
+ if pattern in username_lower:
3474
+ return True
3475
+
3476
+ # SQL injection patterns
3477
+ sql_patterns = [
3478
+ "' or ",
3479
+ "' and ",
3480
+ "1=1",
3481
+ "1'='1",
3482
+ "' union ",
3483
+ "select ",
3484
+ "insert ",
3485
+ "update ",
3486
+ "delete ",
3487
+ "drop ",
3488
+ "concat(",
3489
+ "char(",
3490
+ "chr(",
3491
+ "0x00",
3492
+ "@@version",
3493
+ ]
3494
+ for pattern in sql_patterns:
3495
+ if pattern in username_lower:
3496
+ return True
3497
+
3498
+ # URL encoding patterns
3499
+ if "%27" in username or "%22" in username or "%3c" in username_lower:
3500
+ return True
3501
+
3502
+ # Hex patterns
3503
+ if re.search(r"0x[0-9a-f]{4,}", username_lower):
3504
+ return True
3505
+
3506
+ # Starts/ends with injection chars
3507
+ injection_chars = ['"', "'", ";", "|", "&", "`", "(", ")", "<", ">"]
3508
+ if username[0] in injection_chars or username[-1] in injection_chars:
3509
+ return True
3510
+
3511
+ # Too many special characters
3512
+ special_count = sum(1 for c in username if c in "{}[]()$%^&*|\\/<>\"`'")
3513
+ if special_count > 3:
3514
+ return True
3515
+
3516
+ # Mostly digits and long
3517
+ if len(username) > 20:
3518
+ alnum_only = re.sub(r"[^a-zA-Z0-9]", "", username)
3519
+ if len(alnum_only) > 0:
3520
+ digit_ratio = sum(1 for c in alnum_only if c.isdigit()) / len(
3521
+ alnum_only
3522
+ )
3523
+ if digit_ratio > 0.7:
3524
+ return True
3525
+
3526
+ return False
3527
+
3528
+ cm = CredentialsManager()
3529
+ em = EngagementManager()
3530
+
3531
+ # Get credentials (must decrypt to check actual usernames)
3532
+ if engagement:
3533
+ creds_list = cm.list_credentials_for_engagement(engagement, decrypt=True)
3534
+ eng_name = f"engagement {engagement}"
3535
+ else:
3536
+ # Get all engagements (unfiltered for cleanup operation)
3537
+ all_creds = []
3538
+ for eng in em.list(user_filtered=False):
3539
+ eng_creds = cm.list_credentials_for_engagement(eng["id"], decrypt=True)
3540
+ all_creds.extend(eng_creds)
3541
+ creds_list = all_creds
3542
+ eng_name = "all engagements"
3543
+
3544
+ # Find garbage
3545
+ garbage = []
3546
+ for cred in creds_list:
3547
+ username = cred.get("username", "")
3548
+ if is_garbage_username(username):
3549
+ garbage.append(cred)
3550
+
3551
+ if not garbage:
3552
+ click.echo(
3553
+ click.style(f"✓ No garbage credentials found in {eng_name}", fg="green")
3554
+ )
3555
+ return
3556
+
3557
+ # Display what we found
3558
+ click.echo(
3559
+ click.style(
3560
+ f"\nFound {len(garbage)} garbage credential(s) in {eng_name}:\n",
3561
+ fg="yellow",
3562
+ bold=True,
3563
+ )
3564
+ )
3565
+
3566
+ # Table header
3567
+ click.echo(f" {'ID':<6} {'Service':<18} {'Username':<50}")
3568
+ click.echo("-" * 80)
3569
+
3570
+ display_list = garbage if show_all else garbage[:20]
3571
+ for cred in display_list:
3572
+ username = (cred.get("username") or "<empty>")[:48]
3573
+ cred_id = str(cred.get("id", "?"))
3574
+ service = (cred.get("service") or "unknown")[:16]
3575
+ click.echo(f" {cred_id:<6} {service:<18} {username}")
3576
+
3577
+ if not show_all and len(garbage) > 20:
3578
+ click.echo(f"\n ... and {len(garbage) - 20} more (use --all to see all)")
3579
+
3580
+ click.echo("-" * 80)
3581
+
3582
+ if confirm:
3583
+ # Actually delete
3584
+ click.echo(
3585
+ click.style(f"\nDeleting {len(garbage)} garbage credential(s)...", fg="red")
3586
+ )
3587
+ deleted = 0
3588
+ for cred in garbage:
3589
+ try:
3590
+ cm.delete_credential(cred["id"])
3591
+ deleted += 1
3592
+ except Exception as e:
3593
+ click.echo(f" Failed to delete {cred['id']}: {e}")
3594
+
3595
+ click.echo(
3596
+ click.style(f"✓ Deleted {deleted} garbage credential(s)", fg="green")
3597
+ )
3598
+ else:
3599
+ click.echo(
3600
+ click.style(
3601
+ f"\n[DRY RUN] Would delete {len(garbage)} credential(s)", fg="yellow"
3602
+ )
3603
+ )
3604
+ click.echo("Run with --confirm to actually delete them.")
2948
3605
 
2949
3606
 
2950
3607
  @cli.group()
@@ -2955,11 +3612,37 @@ def report():
2955
3612
 
2956
3613
 
2957
3614
  @report.command("generate")
2958
- @click.option("--format", "-f", type=click.Choice(['markdown', 'html', 'json'], case_sensitive=False), default='html', help="Report format")
2959
- @click.option("--type", "-t", type=click.Choice(['executive', 'technical', 'summary', 'detection'], case_sensitive=False), default='technical', help="Report type (executive, technical, summary, detection)")
2960
- @click.option("--output", "-o", type=str, help="Output file path (default: reports/<engagement>_<timestamp>.<ext>)")
2961
- @click.option("--engagement", "-e", type=int, help="Engagement ID (default: current engagement)")
2962
- @click.option("--ai", is_flag=True, default=False, help="Enable AI-enhanced report (PRO tier, requires Claude API or Ollama)")
3615
+ @click.option(
3616
+ "--format",
3617
+ "-f",
3618
+ type=click.Choice(["markdown", "html", "json"], case_sensitive=False),
3619
+ default="html",
3620
+ help="Report format",
3621
+ )
3622
+ @click.option(
3623
+ "--type",
3624
+ "-t",
3625
+ type=click.Choice(
3626
+ ["executive", "technical", "summary", "detection"], case_sensitive=False
3627
+ ),
3628
+ default="technical",
3629
+ help="Report type (executive, technical, summary, detection)",
3630
+ )
3631
+ @click.option(
3632
+ "--output",
3633
+ "-o",
3634
+ type=str,
3635
+ help="Output file path (default: reports/<engagement>_<timestamp>.<ext>)",
3636
+ )
3637
+ @click.option(
3638
+ "--engagement", "-e", type=int, help="Engagement ID (default: current engagement)"
3639
+ )
3640
+ @click.option(
3641
+ "--ai",
3642
+ is_flag=True,
3643
+ default=False,
3644
+ help="Enable AI-enhanced report (PRO tier, requires Claude API or Ollama)",
3645
+ )
2963
3646
  def report_generate(format, type, output, engagement, ai):
2964
3647
  """Generate a penetration test report.
2965
3648
 
@@ -2985,54 +3668,61 @@ def report_generate(format, type, output, engagement, ai):
2985
3668
  if engagement:
2986
3669
  eng = em.get_by_id(engagement)
2987
3670
  if not eng:
2988
- click.echo(click.style(f"✗ Engagement {engagement} not found", fg='red'))
3671
+ click.echo(click.style(f"✗ Engagement {engagement} not found", fg="red"))
2989
3672
  return
2990
3673
  else:
2991
3674
  eng = em.get_current()
2992
3675
  if not eng:
2993
- click.echo(click.style("✗ No current engagement. Use 'souleyez engagement list' to see available engagements.", fg='red'))
3676
+ click.echo(
3677
+ click.style(
3678
+ "✗ No current engagement. Use 'souleyez engagement list' to see available engagements.",
3679
+ fg="red",
3680
+ )
3681
+ )
2994
3682
  return
2995
3683
 
2996
- engagement_id = eng['id']
2997
- engagement_name = eng['name']
3684
+ engagement_id = eng["id"]
3685
+ engagement_name = eng["name"]
2998
3686
 
2999
3687
  # Detection reports require Wazuh integration
3000
- if type == 'detection':
3688
+ if type == "detection":
3001
3689
  from souleyez.integrations.wazuh.config import WazuhConfig
3690
+
3002
3691
  wazuh_config = WazuhConfig.get_config(engagement_id)
3003
- if not wazuh_config or not wazuh_config.get('enabled'):
3004
- click.echo(click.style(
3005
- "✗ Detection reports require Wazuh integration.",
3006
- fg='red'
3007
- ))
3692
+ if not wazuh_config or not wazuh_config.get("enabled"):
3693
+ click.echo(
3694
+ click.style("✗ Detection reports require Wazuh integration.", fg="red")
3695
+ )
3008
3696
  click.echo(" Configure Wazuh first: souleyez wazuh configure")
3009
3697
  return
3010
3698
 
3011
3699
  # Check for detection results
3012
3700
  from souleyez.storage.database import get_db
3701
+
3013
3702
  db = get_db()
3014
3703
  results = db.execute(
3015
3704
  "SELECT COUNT(*) FROM detection_results WHERE engagement_id = ?",
3016
- (engagement_id,)
3705
+ (engagement_id,),
3017
3706
  ).fetchone()[0]
3018
3707
  if results == 0:
3019
- click.echo(click.style(
3020
- "✗ No detection validation results found.",
3021
- fg='red'
3022
- ))
3708
+ click.echo(
3709
+ click.style("✗ No detection validation results found.", fg="red")
3710
+ )
3023
3711
  click.echo(" Run attacks and validate detections first.")
3024
3712
  click.echo(" Use: souleyez detection validate")
3025
3713
  return
3026
3714
 
3027
3715
  # Show report type being generated
3028
3716
  type_label = {
3029
- 'executive': 'EXECUTIVE (C-Level)',
3030
- 'technical': 'TECHNICAL (Full Details)',
3031
- 'summary': 'SUMMARY (Quick Status)',
3032
- 'detection': 'DETECTION COVERAGE (SIEM Analysis)'
3717
+ "executive": "EXECUTIVE (C-Level)",
3718
+ "technical": "TECHNICAL (Full Details)",
3719
+ "summary": "SUMMARY (Quick Status)",
3720
+ "detection": "DETECTION COVERAGE (SIEM Analysis)",
3033
3721
  }
3034
3722
  ai_label = " + AI Enhanced" if ai else ""
3035
- click.echo(f"Generating {click.style(type_label[type] + ai_label, fg='yellow', bold=True)} {format.upper()} report")
3723
+ click.echo(
3724
+ f"Generating {click.style(type_label[type] + ai_label, fg='yellow', bold=True)} {format.upper()} report"
3725
+ )
3036
3726
  click.echo(f"Engagement: {click.style(engagement_name, fg='cyan', bold=True)}")
3037
3727
 
3038
3728
  # Check AI availability if requested
@@ -3040,26 +3730,38 @@ def report_generate(format, type, output, engagement, ai):
3040
3730
  if ai:
3041
3731
  from souleyez.ai import AIReportService
3042
3732
  from souleyez.ai.llm_factory import LLMFactory
3733
+
3043
3734
  ai_provider = LLMFactory.get_available_provider()
3044
3735
  if ai_provider and ai_provider.is_available():
3045
3736
  provider_info = ai_provider.get_status()
3046
- provider_name = provider_info.get('provider', 'Unknown')
3047
- click.echo(f"AI Provider: {click.style(provider_name, fg='magenta', bold=True)}")
3737
+ provider_name = provider_info.get("provider", "Unknown")
3738
+ click.echo(
3739
+ f"AI Provider: {click.style(provider_name, fg='magenta', bold=True)}"
3740
+ )
3048
3741
 
3049
3742
  # Show privacy warning for cloud providers
3050
- if provider_name.lower() == 'claude':
3051
- click.echo(click.style("⚠ PRIVACY: Engagement data will be sent to Anthropic's servers.", fg='yellow'))
3743
+ if provider_name.lower() == "claude":
3744
+ click.echo(
3745
+ click.style(
3746
+ "⚠ PRIVACY: Engagement data will be sent to Anthropic's servers.",
3747
+ fg="yellow",
3748
+ )
3749
+ )
3052
3750
  else:
3053
- click.echo(click.style("⚠ AI not available. Falling back to standard report.", fg='yellow'))
3751
+ click.echo(
3752
+ click.style(
3753
+ "⚠ AI not available. Falling back to standard report.", fg="yellow"
3754
+ )
3755
+ )
3054
3756
  click.echo(" Configure Claude API key or ensure Ollama is running.")
3055
3757
  ai = False
3056
3758
  ai_provider = None
3057
3759
 
3058
3760
  # Generate output filename if not specified
3059
3761
  if not output:
3060
- timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
3061
- safe_name = engagement_name.replace(' ', '_').replace('/', '_')
3062
- ext = format if format != 'markdown' else 'md'
3762
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
3763
+ safe_name = engagement_name.replace(" ", "_").replace("/", "_")
3764
+ ext = format if format != "markdown" else "md"
3063
3765
  # Include report type and AI in filename
3064
3766
  ai_suffix = "_ai" if ai else ""
3065
3767
  output = f"reports/{safe_name}_{type}{ai_suffix}_{timestamp}.{ext}"
@@ -3071,6 +3773,7 @@ def report_generate(format, type, output, engagement, ai):
3071
3773
  # Set explicit AI provider to avoid factory fallback issues
3072
3774
  if ai_provider:
3073
3775
  from souleyez.ai import AIReportService
3776
+
3074
3777
  rg._ai_service = AIReportService(provider=ai_provider)
3075
3778
 
3076
3779
  # Generate report with specified type and AI enhancement
@@ -3079,17 +3782,18 @@ def report_generate(format, type, output, engagement, ai):
3079
3782
  format=format,
3080
3783
  output_path=output,
3081
3784
  report_type=type,
3082
- ai_enhanced=ai
3785
+ ai_enhanced=ai,
3083
3786
  )
3084
3787
 
3085
- click.echo(click.style(f"✓ Report generated successfully!", fg='green'))
3788
+ click.echo(click.style(f"✓ Report generated successfully!", fg="green"))
3086
3789
  click.echo(f" Type: {type_label[type]}{' + AI' if ai else ''}")
3087
3790
  click.echo(f" Format: {format.upper()}")
3088
3791
  click.echo(f" File: {report_path}")
3089
3792
 
3090
3793
  # Show summary (different for detection reports)
3091
- if type == 'detection':
3794
+ if type == "detection":
3092
3795
  from souleyez.reporting.detection_report import DetectionReportGatherer
3796
+
3093
3797
  gatherer = DetectionReportGatherer(engagement_id)
3094
3798
  stats = gatherer.get_summary_stats()
3095
3799
  click.echo(f"\nDetection Summary:")
@@ -3106,8 +3810,9 @@ def report_generate(format, type, output, engagement, ai):
3106
3810
  click.echo(f" Credentials: {len(data['credentials'])}")
3107
3811
 
3108
3812
  except Exception as e:
3109
- click.echo(click.style(f"✗ Error generating report: {e}", fg='red'))
3813
+ click.echo(click.style(f"✗ Error generating report: {e}", fg="red"))
3110
3814
  import traceback
3815
+
3111
3816
  traceback.print_exc()
3112
3817
 
3113
3818
 
@@ -3115,13 +3820,16 @@ def report_generate(format, type, output, engagement, ai):
3115
3820
  def report_list():
3116
3821
  """List generated reports."""
3117
3822
  import os
3823
+
3118
3824
  reports_dir = Path("reports")
3119
3825
 
3120
3826
  if not reports_dir.exists():
3121
3827
  click.echo("No reports directory found.")
3122
3828
  return
3123
3829
 
3124
- reports = sorted(reports_dir.glob("*.*"), key=lambda p: p.stat().st_mtime, reverse=True)
3830
+ reports = sorted(
3831
+ reports_dir.glob("*.*"), key=lambda p: p.stat().st_mtime, reverse=True
3832
+ )
3125
3833
 
3126
3834
  if not reports:
3127
3835
  click.echo("No reports found.")
@@ -3135,7 +3843,9 @@ def report_list():
3135
3843
  size = rpt.stat().st_size
3136
3844
  mtime = datetime.datetime.fromtimestamp(rpt.stat().st_mtime)
3137
3845
  click.echo(f"{rpt.name}")
3138
- click.echo(f" Size: {size:,} bytes | Modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')}")
3846
+ click.echo(
3847
+ f" Size: {size:,} bytes | Modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')}"
3848
+ )
3139
3849
 
3140
3850
  click.echo("=" * 70 + "\n")
3141
3851
 
@@ -3144,6 +3854,7 @@ def report_list():
3144
3854
  # AI Commands
3145
3855
  # ============================================================================
3146
3856
 
3857
+
3147
3858
  @cli.group()
3148
3859
  def ai():
3149
3860
  """AI-powered attack path recommendations (Pro feature)."""
@@ -3163,27 +3874,31 @@ def ai_status():
3163
3874
  status = service.get_status()
3164
3875
 
3165
3876
  # Display connection status
3166
- if status['connected']:
3167
- click.echo(click.style("✓ Ollama Connection: ", fg='green') + "Connected")
3877
+ if status["connected"]:
3878
+ click.echo(click.style("✓ Ollama Connection: ", fg="green") + "Connected")
3168
3879
  click.echo(f" Endpoint: {status['endpoint']}")
3169
3880
  else:
3170
- click.echo(click.style("✗ Ollama Connection: ", fg='red') + "Not connected")
3881
+ click.echo(click.style("✗ Ollama Connection: ", fg="red") + "Not connected")
3171
3882
  click.echo(f" Endpoint: {status['endpoint']}")
3172
- if status.get('error'):
3883
+ if status.get("error"):
3173
3884
  click.echo(f" Error: {status['error']}")
3174
- click.echo("\n" + click.style("💡 TIP:", fg='yellow') + " Install Ollama from https://ollama.ai")
3885
+ click.echo(
3886
+ "\n"
3887
+ + click.style("💡 TIP:", fg="yellow")
3888
+ + " Install Ollama from https://ollama.ai"
3889
+ )
3175
3890
  click.echo(" Then run: ollama serve")
3176
3891
  click.echo("=" * 70 + "\n")
3177
3892
  return
3178
3893
 
3179
3894
  # Display available models
3180
3895
  click.echo(f"\nAvailable Models: {len(status['models'])}")
3181
- if status['models']:
3182
- for model in status['models']:
3183
- if status['configured_model'] in model:
3184
- click.echo(f" • {model} " + click.style("(configured)", fg='cyan'))
3185
- elif status['default_model'] in model:
3186
- click.echo(f" • {model} " + click.style("(default)", fg='yellow'))
3896
+ if status["models"]:
3897
+ for model in status["models"]:
3898
+ if status["configured_model"] in model:
3899
+ click.echo(f" • {model} " + click.style("(configured)", fg="cyan"))
3900
+ elif status["default_model"] in model:
3901
+ click.echo(f" • {model} " + click.style("(default)", fg="yellow"))
3187
3902
  else:
3188
3903
  click.echo(f" • {model}")
3189
3904
  else:
@@ -3191,11 +3906,13 @@ def ai_status():
3191
3906
 
3192
3907
  # Display configured model status
3193
3908
  click.echo(f"\nConfigured Model: {status['configured_model']}")
3194
- if status['model_available']:
3195
- click.echo(click.style("✓ Status: ", fg='green') + "Ready")
3909
+ if status["model_available"]:
3910
+ click.echo(click.style("✓ Status: ", fg="green") + "Ready")
3196
3911
  else:
3197
- click.echo(click.style("✗ Status: ", fg='red') + "Not available")
3198
- click.echo(f"\n" + click.style("💡 TIP:", fg='yellow') + f" Pull the model with:")
3912
+ click.echo(click.style("✗ Status: ", fg="red") + "Not available")
3913
+ click.echo(
3914
+ f"\n" + click.style("💡 TIP:", fg="yellow") + f" Pull the model with:"
3915
+ )
3199
3916
  click.echo(f" ollama pull {status['configured_model']}")
3200
3917
  click.echo(f" Or change the model in Settings & Security → AI Settings")
3201
3918
 
@@ -3217,39 +3934,41 @@ def ai_init():
3217
3934
  # Check connection
3218
3935
  click.echo("🔄 Checking Ollama connection...")
3219
3936
  if not service.check_connection():
3220
- click.echo(click.style("✗ Failed:", fg='red') + " Cannot connect to Ollama")
3221
- click.echo("\n" + click.style("💡 TIP:", fg='yellow') + " Install and start Ollama:")
3937
+ click.echo(click.style("✗ Failed:", fg="red") + " Cannot connect to Ollama")
3938
+ click.echo(
3939
+ "\n" + click.style("💡 TIP:", fg="yellow") + " Install and start Ollama:"
3940
+ )
3222
3941
  click.echo(" 1. Download from https://ollama.ai")
3223
3942
  click.echo(" 2. Run: ollama serve")
3224
3943
  click.echo("=" * 70 + "\n")
3225
3944
  sys.exit(1)
3226
3945
 
3227
- click.echo(click.style("✓ Connected", fg='green') + " to Ollama\n")
3946
+ click.echo(click.style("✓ Connected", fg="green") + " to Ollama\n")
3228
3947
 
3229
3948
  # Check if model exists
3230
3949
  configured_model = service.model
3231
3950
  click.echo(f"🔍 Checking for model: {configured_model}...")
3232
3951
 
3233
3952
  if service.check_model():
3234
- click.echo(click.style("✓ Model already available", fg='green'))
3235
- click.echo("\n" + click.style("✓ AI features ready!", fg='green', bold=True))
3953
+ click.echo(click.style("✓ Model already available", fg="green"))
3954
+ click.echo("\n" + click.style("✓ AI features ready!", fg="green", bold=True))
3236
3955
  click.echo("=" * 70 + "\n")
3237
3956
  return
3238
3957
 
3239
3958
  # Model doesn't exist, pull it
3240
- click.echo(click.style("⬇️ Model not found, pulling now...", fg='yellow'))
3959
+ click.echo(click.style("⬇️ Model not found, pulling now...", fg="yellow"))
3241
3960
  click.echo(f" This may take a few minutes depending on your connection.\n")
3242
3961
 
3243
3962
  try:
3244
3963
  if service.pull_model():
3245
- click.echo(click.style("\n✓ Model pulled successfully!", fg='green'))
3246
- click.echo(click.style("✓ AI features ready!", fg='green', bold=True))
3964
+ click.echo(click.style("\n✓ Model pulled successfully!", fg="green"))
3965
+ click.echo(click.style("✓ AI features ready!", fg="green", bold=True))
3247
3966
  else:
3248
- click.echo(click.style("\n✗ Failed to pull model", fg='red'))
3967
+ click.echo(click.style("\n✗ Failed to pull model", fg="red"))
3249
3968
  click.echo(" Please try manually: ollama pull " + configured_model)
3250
3969
  sys.exit(1)
3251
3970
  except Exception as e:
3252
- click.echo(click.style(f"\n✗ Error pulling model: {e}", fg='red'))
3971
+ click.echo(click.style(f"\n✗ Error pulling model: {e}", fg="red"))
3253
3972
  click.echo(" Please try manually: ollama pull " + configured_model)
3254
3973
  sys.exit(1)
3255
3974
 
@@ -3257,8 +3976,20 @@ def ai_init():
3257
3976
 
3258
3977
 
3259
3978
  @ai.command("recommend")
3260
- @click.option("--engagement", "-e", type=int, default=None, help="Engagement ID to analyze (default: current)")
3261
- @click.option("--steps", "-s", type=int, default=1, help="Number of attack steps to generate (default: 1)")
3979
+ @click.option(
3980
+ "--engagement",
3981
+ "-e",
3982
+ type=int,
3983
+ default=None,
3984
+ help="Engagement ID to analyze (default: current)",
3985
+ )
3986
+ @click.option(
3987
+ "--steps",
3988
+ "-s",
3989
+ type=int,
3990
+ default=1,
3991
+ help="Number of attack steps to generate (default: 1)",
3992
+ )
3262
3993
  def ai_recommend(engagement, steps):
3263
3994
  """Generate AI-powered attack path recommendation."""
3264
3995
  from souleyez.ai.recommender import AttackRecommender
@@ -3271,16 +4002,21 @@ def ai_recommend(engagement, steps):
3271
4002
  # Use specified engagement ID
3272
4003
  engagement_data = eng_mgr.get_by_id(engagement)
3273
4004
  if not engagement_data:
3274
- click.echo(click.style(f"✗ Error:", fg='red') + f" Engagement ID {engagement} not found")
4005
+ click.echo(
4006
+ click.style(f"✗ Error:", fg="red")
4007
+ + f" Engagement ID {engagement} not found"
4008
+ )
3275
4009
  sys.exit(1)
3276
4010
  else:
3277
4011
  # Use current engagement
3278
4012
  engagement_data = eng_mgr.get_current()
3279
4013
  if not engagement_data:
3280
- click.echo(click.style("✗ Error:", fg='red') + " No current engagement selected")
4014
+ click.echo(
4015
+ click.style("✗ Error:", fg="red") + " No current engagement selected"
4016
+ )
3281
4017
  click.echo(" Use: souleyez engagement use <name>")
3282
4018
  sys.exit(1)
3283
- engagement = engagement_data['id']
4019
+ engagement = engagement_data["id"]
3284
4020
 
3285
4021
  click.echo("\n" + "=" * 70)
3286
4022
  if steps > 1:
@@ -3293,65 +4029,82 @@ def ai_recommend(engagement, steps):
3293
4029
  # Generate recommendation
3294
4030
  click.echo("🤔 Analyzing engagement data...")
3295
4031
  recommender = AttackRecommender()
3296
-
4032
+
3297
4033
  if steps > 1:
3298
4034
  result = recommender.generate_chain(engagement, num_steps=steps)
3299
4035
  else:
3300
4036
  result = recommender.suggest_next_step(engagement)
3301
4037
 
3302
4038
  # Check for errors
3303
- if result.get('error'):
3304
- click.echo(click.style("✗ Failed:", fg='red') + f" {result['error']}")
3305
- if 'ai init' in result['error']:
3306
- click.echo("\n" + click.style("💡 TIP:", fg='yellow') + " Run: souleyez ai init")
4039
+ if result.get("error"):
4040
+ click.echo(click.style("✗ Failed:", fg="red") + f" {result['error']}")
4041
+ if "ai init" in result["error"]:
4042
+ click.echo(
4043
+ "\n" + click.style("💡 TIP:", fg="yellow") + " Run: souleyez ai init"
4044
+ )
3307
4045
  click.echo("=" * 70 + "\n")
3308
4046
  sys.exit(1)
3309
4047
 
3310
4048
  # Display recommendation
3311
4049
  if steps > 1:
3312
4050
  # Display multi-step chain
3313
- click.echo(click.style("✓ Attack chain generated!", fg='green') + "\n")
3314
-
3315
- for step in result['steps']:
3316
- click.echo(click.style(f"STEP {step['step_number']}:", fg='yellow', bold=True) + f" {step['action']}")
4051
+ click.echo(click.style("✓ Attack chain generated!", fg="green") + "\n")
4052
+
4053
+ for step in result["steps"]:
4054
+ click.echo(
4055
+ click.style(f"STEP {step['step_number']}:", fg="yellow", bold=True)
4056
+ + f" {step['action']}"
4057
+ )
3317
4058
  click.echo(f" {click.style('TARGET:', fg='cyan')} {step['target']}")
3318
4059
  click.echo(f" {click.style('RATIONALE:', fg='white')} {step['rationale']}")
3319
4060
  click.echo(f" {click.style('EXPECTED:', fg='green')} {step['expected']}")
3320
-
3321
- risk_color = {'LOW': 'green', 'MEDIUM': 'yellow', 'HIGH': 'red'}.get(step['risk'], 'white')
3322
- click.echo(f" {click.style('RISK:', fg='cyan')} {click.style(step['risk'], fg=risk_color)}")
3323
- click.echo(f" {click.style('DEPENDENCIES:', fg='cyan')} {step['dependencies']}")
4061
+
4062
+ risk_color = {"LOW": "green", "MEDIUM": "yellow", "HIGH": "red"}.get(
4063
+ step["risk"], "white"
4064
+ )
4065
+ click.echo(
4066
+ f" {click.style('RISK:', fg='cyan')} {click.style(step['risk'], fg=risk_color)}"
4067
+ )
4068
+ click.echo(
4069
+ f" {click.style('DEPENDENCIES:', fg='cyan')} {step['dependencies']}"
4070
+ )
3324
4071
  click.echo()
3325
4072
  else:
3326
4073
  # Display single-step recommendation
3327
- click.echo(click.style("✓ Recommendation generated!", fg='green') + "\n")
4074
+ click.echo(click.style("✓ Recommendation generated!", fg="green") + "\n")
3328
4075
 
3329
- click.echo(click.style("NEXT ACTION:", fg='cyan', bold=True))
4076
+ click.echo(click.style("NEXT ACTION:", fg="cyan", bold=True))
3330
4077
  click.echo(f" {result['action']}\n")
3331
4078
 
3332
- click.echo(click.style("TARGET:", fg='cyan', bold=True))
4079
+ click.echo(click.style("TARGET:", fg="cyan", bold=True))
3333
4080
  click.echo(f" {result['target']}\n")
3334
4081
 
3335
- click.echo(click.style("RATIONALE:", fg='cyan', bold=True))
4082
+ click.echo(click.style("RATIONALE:", fg="cyan", bold=True))
3336
4083
  click.echo(f" {result['rationale']}\n")
3337
4084
 
3338
- click.echo(click.style("EXPECTED OUTCOME:", fg='cyan', bold=True))
4085
+ click.echo(click.style("EXPECTED OUTCOME:", fg="cyan", bold=True))
3339
4086
  click.echo(f" {result['expected_outcome']}\n")
3340
4087
 
3341
4088
  # Color-code risk level
3342
- risk = result['risk_level']
3343
- risk_colors = {'low': 'green', 'medium': 'yellow', 'high': 'red'}
3344
- risk_color = risk_colors.get(risk, 'white')
4089
+ risk = result["risk_level"]
4090
+ risk_colors = {"low": "green", "medium": "yellow", "high": "red"}
4091
+ risk_color = risk_colors.get(risk, "white")
3345
4092
 
3346
- click.echo(click.style("RISK LEVEL:", fg='cyan', bold=True))
4093
+ click.echo(click.style("RISK LEVEL:", fg="cyan", bold=True))
3347
4094
  click.echo(" " + click.style(risk.upper(), fg=risk_color, bold=True))
3348
4095
 
3349
4096
  click.echo("\n" + "=" * 70 + "\n")
3350
4097
 
3351
4098
 
3352
- @ai.command('paths')
3353
- @click.option('--engagement', '-e', type=int, help='Engagement ID (default: current)')
3354
- @click.option('--number', '-n', type=int, default=3, help='Number of paths to generate (default: 3)')
4099
+ @ai.command("paths")
4100
+ @click.option("--engagement", "-e", type=int, help="Engagement ID (default: current)")
4101
+ @click.option(
4102
+ "--number",
4103
+ "-n",
4104
+ type=int,
4105
+ default=3,
4106
+ help="Number of paths to generate (default: 3)",
4107
+ )
3355
4108
  def ai_paths(engagement, number):
3356
4109
  """Generate and rank multiple attack paths."""
3357
4110
  from souleyez.storage.engagements import EngagementManager
@@ -3363,64 +4116,88 @@ def ai_paths(engagement, number):
3363
4116
  if engagement:
3364
4117
  eng = em.get_by_id(engagement)
3365
4118
  if not eng:
3366
- click.echo(click.style(f"✗ Engagement {engagement} not found", fg='red'))
4119
+ click.echo(click.style(f"✗ Engagement {engagement} not found", fg="red"))
3367
4120
  return
3368
4121
  else:
3369
4122
  eng = em.get_current()
3370
4123
  if not eng:
3371
- click.echo(click.style("✗ No active engagement. Use: souleyez engagement use <name>", fg='red'))
4124
+ click.echo(
4125
+ click.style(
4126
+ "✗ No active engagement. Use: souleyez engagement use <name>",
4127
+ fg="red",
4128
+ )
4129
+ )
3372
4130
  return
3373
4131
 
3374
- click.echo(click.style(f"\n🤖 Generating {number} alternative attack paths...", fg='cyan', bold=True))
3375
- click.echo(click.style("⏳ This may take 1-3 minutes depending on AI model speed...\n", fg='yellow'))
4132
+ click.echo(
4133
+ click.style(
4134
+ f"\n🤖 Generating {number} alternative attack paths...",
4135
+ fg="cyan",
4136
+ bold=True,
4137
+ )
4138
+ )
4139
+ click.echo(
4140
+ click.style(
4141
+ "⏳ This may take 1-3 minutes depending on AI model speed...\n", fg="yellow"
4142
+ )
4143
+ )
3376
4144
 
3377
4145
  # Generate paths
3378
4146
  recommender = AttackRecommender()
3379
- result = recommender.suggest_multiple_paths(eng['id'], num_paths=number)
4147
+ result = recommender.suggest_multiple_paths(eng["id"], num_paths=number)
3380
4148
 
3381
4149
  # Handle errors
3382
- if result.get('error'):
3383
- click.echo(click.style(f"✗ Error: {result['error']}", fg='red'))
4150
+ if result.get("error"):
4151
+ click.echo(click.style(f"✗ Error: {result['error']}", fg="red"))
3384
4152
  return
3385
4153
 
3386
- paths = result.get('paths', [])
4154
+ paths = result.get("paths", [])
3387
4155
  if not paths:
3388
- click.echo(click.style("No attack paths generated", fg='yellow'))
4156
+ click.echo(click.style("No attack paths generated", fg="yellow"))
3389
4157
  return
3390
4158
 
3391
4159
  # Display ranked paths
3392
4160
  click.echo("=" * 80)
3393
- click.echo(click.style(f"RANKED ATTACK PATHS (Top {len(paths)})", bold=True, fg='cyan'))
4161
+ click.echo(
4162
+ click.style(f"RANKED ATTACK PATHS (Top {len(paths)})", bold=True, fg="cyan")
4163
+ )
3394
4164
  click.echo("=" * 80)
3395
4165
 
3396
4166
  for scored_path in paths:
3397
- rank = scored_path['rank']
3398
- path = scored_path['path']
3399
- scores = scored_path['scores']
3400
- total = scored_path['total_score']
4167
+ rank = scored_path["rank"]
4168
+ path = scored_path["path"]
4169
+ scores = scored_path["scores"]
4170
+ total = scored_path["total_score"]
3401
4171
 
3402
4172
  # Rank header with score
3403
4173
  if rank == 1:
3404
- rank_color = 'green'
3405
- rank_icon = '🥇'
4174
+ rank_color = "green"
4175
+ rank_icon = "🥇"
3406
4176
  elif rank == 2:
3407
- rank_color = 'yellow'
3408
- rank_icon = '🥈'
4177
+ rank_color = "yellow"
4178
+ rank_icon = "🥈"
3409
4179
  elif rank == 3:
3410
- rank_color = 'cyan'
3411
- rank_icon = '🥉'
4180
+ rank_color = "cyan"
4181
+ rank_icon = "🥉"
3412
4182
  else:
3413
- rank_color = 'white'
3414
- rank_icon = f'#{rank}'
4183
+ rank_color = "white"
4184
+ rank_icon = f"#{rank}"
3415
4185
 
3416
- click.echo(f"\n{rank_icon} " + click.style(f"PATH {rank}", bold=True, fg=rank_color) +
3417
- click.style(f" (Score: {total}/100)", fg=rank_color))
4186
+ click.echo(
4187
+ f"\n{rank_icon} "
4188
+ + click.style(f"PATH {rank}", bold=True, fg=rank_color)
4189
+ + click.style(f" (Score: {total}/100)", fg=rank_color)
4190
+ )
3418
4191
  click.echo("-" * 80)
3419
4192
 
3420
4193
  # Path details
3421
4194
  click.echo(f"ACTION: {path['action']}")
3422
4195
  click.echo(f"TARGET: {path['target']}")
3423
- risk_color = 'green' if path['risk_level'] == 'LOW' else ('yellow' if path['risk_level'] == 'MEDIUM' else 'red')
4196
+ risk_color = (
4197
+ "green"
4198
+ if path["risk_level"] == "LOW"
4199
+ else ("yellow" if path["risk_level"] == "MEDIUM" else "red")
4200
+ )
3424
4201
  click.echo(f"RISK: {click.style(path['risk_level'], fg=risk_color)}")
3425
4202
  click.echo(f"\nRATIONALE: {path['rationale']}")
3426
4203
  click.echo(f"EXPECTED: {path['expected']}")
@@ -3433,38 +4210,55 @@ def ai_paths(engagement, number):
3433
4210
  click.echo(f" Complexity: -{scores['complexity']} (penalty)")
3434
4211
 
3435
4212
  click.echo("\n" + "=" * 80)
3436
- click.echo(click.style(f"\n💡 Tip: Execute top path with: souleyez ai execute", fg='cyan'))
4213
+ click.echo(
4214
+ click.style(f"\n💡 Tip: Execute top path with: souleyez ai execute", fg="cyan")
4215
+ )
3437
4216
 
3438
4217
 
3439
4218
  @ai.command("execute")
3440
- @click.option("--engagement", "-e", type=int, default=None, help="Engagement ID (default: current)")
4219
+ @click.option(
4220
+ "--engagement",
4221
+ "-e",
4222
+ type=int,
4223
+ default=None,
4224
+ help="Engagement ID (default: current)",
4225
+ )
3441
4226
  @click.option("--once", is_flag=True, help="Run only one iteration then stop")
3442
4227
  @click.option("--dry-run", is_flag=True, help="Show commands but don't execute")
3443
4228
  @click.option("--auto-low", is_flag=True, help="Auto-approve LOW risk commands")
3444
- @click.option("--auto-medium", is_flag=True, help="Auto-approve LOW and MEDIUM risk commands")
3445
- @click.option("--max-iterations", "-n", type=int, default=None, help="Maximum iterations to run")
4229
+ @click.option(
4230
+ "--auto-medium", is_flag=True, help="Auto-approve LOW and MEDIUM risk commands"
4231
+ )
4232
+ @click.option(
4233
+ "--max-iterations", "-n", type=int, default=None, help="Maximum iterations to run"
4234
+ )
3446
4235
  def ai_execute(engagement, once, dry_run, auto_low, auto_medium, max_iterations):
3447
4236
  """Execute AI-driven attack recommendations interactively."""
3448
4237
  from souleyez.ai.executor import InteractiveExecutor
3449
4238
  from souleyez.ai.safety import ApprovalMode
3450
4239
  from souleyez.storage.engagements import EngagementManager
3451
-
4240
+
3452
4241
  # Get engagement
3453
4242
  eng_mgr = EngagementManager()
3454
-
4243
+
3455
4244
  if engagement:
3456
4245
  engagement_data = eng_mgr.get_by_id(engagement)
3457
4246
  if not engagement_data:
3458
- click.echo(click.style(f"✗ Error:", fg='red') + f" Engagement ID {engagement} not found")
4247
+ click.echo(
4248
+ click.style(f"✗ Error:", fg="red")
4249
+ + f" Engagement ID {engagement} not found"
4250
+ )
3459
4251
  sys.exit(1)
3460
4252
  else:
3461
4253
  engagement_data = eng_mgr.get_current()
3462
4254
  if not engagement_data:
3463
- click.echo(click.style("✗ Error:", fg='red') + " No current engagement selected")
4255
+ click.echo(
4256
+ click.style("✗ Error:", fg="red") + " No current engagement selected"
4257
+ )
3464
4258
  click.echo(" Use: souleyez engagement use <name>")
3465
4259
  sys.exit(1)
3466
- engagement = engagement_data['id']
3467
-
4260
+ engagement = engagement_data["id"]
4261
+
3468
4262
  # Determine approval mode
3469
4263
  if dry_run:
3470
4264
  approval_mode = ApprovalMode.DRY_RUN
@@ -3474,20 +4268,18 @@ def ai_execute(engagement, once, dry_run, auto_low, auto_medium, max_iterations)
3474
4268
  approval_mode = ApprovalMode.AUTO_LOW
3475
4269
  else:
3476
4270
  approval_mode = ApprovalMode.MANUAL
3477
-
4271
+
3478
4272
  # Create executor and run
3479
4273
  try:
3480
4274
  executor = InteractiveExecutor(approval_mode=approval_mode)
3481
4275
  executor.execute_loop(
3482
- engagement_id=engagement,
3483
- max_iterations=max_iterations,
3484
- once=once
4276
+ engagement_id=engagement, max_iterations=max_iterations, once=once
3485
4277
  )
3486
4278
  except KeyboardInterrupt:
3487
- click.echo(click.style("\n\n🛑 Execution stopped by user", fg='yellow'))
4279
+ click.echo(click.style("\n\n🛑 Execution stopped by user", fg="yellow"))
3488
4280
  sys.exit(0)
3489
4281
  except Exception as e:
3490
- click.echo(click.style(f"\n✗ Fatal error: {e}", fg='red'))
4282
+ click.echo(click.style(f"\n✗ Fatal error: {e}", fg="red"))
3491
4283
  sys.exit(1)
3492
4284
 
3493
4285
 
@@ -3495,6 +4287,7 @@ def ai_execute(engagement, once, dry_run, auto_low, auto_medium, max_iterations)
3495
4287
  # Import Commands
3496
4288
  # ============================================================================
3497
4289
 
4290
+
3498
4291
  @cli.group()
3499
4292
  def import_data():
3500
4293
  """Import data from external sources."""
@@ -3502,8 +4295,8 @@ def import_data():
3502
4295
 
3503
4296
 
3504
4297
  @import_data.command("msf")
3505
- @click.argument('xml_file', type=click.Path(exists=True))
3506
- @click.option('-v', '--verbose', is_flag=True, help='Show detailed import progress')
4298
+ @click.argument("xml_file", type=click.Path(exists=True))
4299
+ @click.option("-v", "--verbose", is_flag=True, help="Show detailed import progress")
3507
4300
  def import_msf(xml_file, verbose):
3508
4301
  """
3509
4302
  Import data from Metasploit Framework XML export.
@@ -3520,13 +4313,24 @@ def import_msf(xml_file, verbose):
3520
4313
  current_ws = em.get_current()
3521
4314
 
3522
4315
  if not current_ws:
3523
- click.echo(click.style("✗ No engagement selected! Use 'souleyez engagement use <name>'", fg='red'))
4316
+ click.echo(
4317
+ click.style(
4318
+ "✗ No engagement selected! Use 'souleyez engagement use <name>'",
4319
+ fg="red",
4320
+ )
4321
+ )
3524
4322
  return
3525
4323
 
3526
- engagement_id = current_ws['id']
3527
- engagement_name = current_ws['name']
4324
+ engagement_id = current_ws["id"]
4325
+ engagement_name = current_ws["name"]
3528
4326
 
3529
- click.echo(click.style(f"\n🔄 Importing Metasploit data into engagement: {engagement_name}", fg='cyan', bold=True))
4327
+ click.echo(
4328
+ click.style(
4329
+ f"\n🔄 Importing Metasploit data into engagement: {engagement_name}",
4330
+ fg="cyan",
4331
+ bold=True,
4332
+ )
4333
+ )
3530
4334
  click.echo()
3531
4335
 
3532
4336
  importer = MSFImporter(engagement_id)
@@ -3535,7 +4339,9 @@ def import_msf(xml_file, verbose):
3535
4339
  stats = importer.import_xml(xml_file, verbose=verbose)
3536
4340
 
3537
4341
  click.echo()
3538
- click.echo(click.style("✓ Import completed successfully!", fg='green', bold=True))
4342
+ click.echo(
4343
+ click.style("✓ Import completed successfully!", fg="green", bold=True)
4344
+ )
3539
4345
  click.echo()
3540
4346
  click.echo("Import Summary:")
3541
4347
  click.echo(f" • Hosts: {stats['hosts']}")
@@ -3543,20 +4349,23 @@ def import_msf(xml_file, verbose):
3543
4349
  click.echo(f" • Credentials: {stats['credentials']}")
3544
4350
  click.echo(f" • Vulnerabilities: {stats['vulnerabilities']}")
3545
4351
 
3546
- if stats['skipped'] > 0:
4352
+ if stats["skipped"] > 0:
3547
4353
  click.echo(f" • Skipped: {stats['skipped']}")
3548
4354
 
3549
4355
  click.echo()
3550
- click.echo(click.style("💡 TIP:", fg='yellow', bold=True) + " View imported data with:")
4356
+ click.echo(
4357
+ click.style("💡 TIP:", fg="yellow", bold=True) + " View imported data with:"
4358
+ )
3551
4359
  click.echo(" • souleyez dashboard")
3552
4360
  click.echo(" • souleyez interactive")
3553
4361
  click.echo(" • souleyez report generate")
3554
4362
  click.echo()
3555
4363
 
3556
4364
  except Exception as e:
3557
- click.echo(click.style(f"\n✗ Import failed: {e}", fg='red'))
4365
+ click.echo(click.style(f"\n✗ Import failed: {e}", fg="red"))
3558
4366
  if verbose:
3559
4367
  import traceback
4368
+
3560
4369
  traceback.print_exc()
3561
4370
  return
3562
4371
 
@@ -3568,7 +4377,7 @@ def config():
3568
4377
 
3569
4378
 
3570
4379
  @config.command("get")
3571
- @click.argument('key')
4380
+ @click.argument("key")
3572
4381
  def config_get(key):
3573
4382
  """
3574
4383
  Get a configuration value.
@@ -3590,8 +4399,8 @@ def config_get(key):
3590
4399
 
3591
4400
 
3592
4401
  @config.command("set")
3593
- @click.argument('key')
3594
- @click.argument('value')
4402
+ @click.argument("key")
4403
+ @click.argument("value")
3595
4404
  def config_set(key, value):
3596
4405
  """
3597
4406
  Set a configuration value.
@@ -3610,26 +4419,33 @@ def config_set(key, value):
3610
4419
  cfg = read_config()
3611
4420
 
3612
4421
  # Convert value types for common settings
3613
- if key in ('settings.threads', 'security.session_timeout_minutes',
3614
- 'security.max_login_attempts', 'security.lockout_duration_minutes',
3615
- 'security.min_password_length', 'crypto.iterations',
3616
- 'database.backup_interval_hours', 'ai.max_tokens',
3617
- 'logging.max_bytes', 'logging.backup_count'):
4422
+ if key in (
4423
+ "settings.threads",
4424
+ "security.session_timeout_minutes",
4425
+ "security.max_login_attempts",
4426
+ "security.lockout_duration_minutes",
4427
+ "security.min_password_length",
4428
+ "crypto.iterations",
4429
+ "database.backup_interval_hours",
4430
+ "ai.max_tokens",
4431
+ "logging.max_bytes",
4432
+ "logging.backup_count",
4433
+ ):
3618
4434
  try:
3619
4435
  value = int(value)
3620
4436
  except ValueError:
3621
- click.echo(click.style(f"Error: {key} requires an integer value", fg='red'))
4437
+ click.echo(click.style(f"Error: {key} requires an integer value", fg="red"))
3622
4438
  return
3623
4439
 
3624
- if key in ('ai.temperature',):
4440
+ if key in ("ai.temperature",):
3625
4441
  try:
3626
4442
  value = float(value)
3627
4443
  except ValueError:
3628
- click.echo(click.style(f"Error: {key} requires a numeric value", fg='red'))
4444
+ click.echo(click.style(f"Error: {key} requires a numeric value", fg="red"))
3629
4445
  return
3630
4446
 
3631
- if key in ('database.backup_enabled',):
3632
- value = value.lower() in ('true', '1', 'yes')
4447
+ if key in ("database.backup_enabled",):
4448
+ value = value.lower() in ("true", "1", "yes")
3633
4449
 
3634
4450
  # Set the value
3635
4451
  _set_nested(cfg, key, value)
@@ -3637,7 +4453,7 @@ def config_set(key, value):
3637
4453
  # Write config
3638
4454
  write_config(cfg)
3639
4455
 
3640
- click.echo(click.style(f"✓ Set {key} = {value}", fg='green'))
4456
+ click.echo(click.style(f"✓ Set {key} = {value}", fg="green"))
3641
4457
 
3642
4458
 
3643
4459
  @config.command("list")
@@ -3654,8 +4470,10 @@ def config_list():
3654
4470
 
3655
4471
  # Import and register screenshot commands
3656
4472
  from souleyez.commands.screenshots import screenshots
4473
+
3657
4474
  cli.add_command(screenshots)
3658
4475
 
3659
4476
  # Import and register deliverable commands
3660
4477
  from souleyez.commands.deliverables import deliverables
4478
+
3661
4479
  cli.add_command(deliverables)