souleyez 2.16.0__py3-none-any.whl → 2.22.0__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.
- souleyez/__init__.py +1 -1
- souleyez/core/tool_chaining.py +99 -7
- souleyez/docs/README.md +2 -2
- souleyez/engine/background.py +9 -1
- souleyez/engine/result_handler.py +40 -0
- souleyez/integrations/siem/splunk.py +58 -11
- souleyez/main.py +1 -1
- souleyez/parsers/nmap_parser.py +97 -1
- souleyez/parsers/smbmap_parser.py +30 -2
- souleyez/parsers/sqlmap_parser.py +54 -17
- souleyez/plugins/gobuster.py +96 -3
- souleyez/plugins/msf_exploit.py +6 -3
- souleyez/ui/interactive.py +31 -13
- souleyez/ui/setup_wizard.py +331 -53
- souleyez/utils/tool_checker.py +30 -8
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/METADATA +3 -3
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/RECORD +21 -21
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/WHEEL +0 -0
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/entry_points.txt +0 -0
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/licenses/LICENSE +0 -0
- {souleyez-2.16.0.dist-info → souleyez-2.22.0.dist-info}/top_level.txt +0 -0
|
@@ -80,10 +80,15 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
80
80
|
current_method = 'GET'
|
|
81
81
|
current_post_data = None
|
|
82
82
|
|
|
83
|
+
# Track POST form URLs separately to prevent GET URL testing from overwriting them
|
|
84
|
+
# This fixes bug where chain rules get wrong URL when SQLMap tests multiple URLs
|
|
85
|
+
last_post_form_url = None
|
|
86
|
+
last_post_form_data = None
|
|
87
|
+
|
|
83
88
|
for i, line in enumerate(lines):
|
|
84
89
|
line = line.strip()
|
|
85
90
|
|
|
86
|
-
# Extract URL being tested
|
|
91
|
+
# Extract URL being tested (GET requests typically)
|
|
87
92
|
if 'testing URL' in line:
|
|
88
93
|
url_match = re.search(r"testing URL '([^']+)'", line)
|
|
89
94
|
if url_match:
|
|
@@ -100,6 +105,9 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
100
105
|
current_url = url_match.group(2)
|
|
101
106
|
if current_url not in result['urls_tested']:
|
|
102
107
|
result['urls_tested'].append(current_url)
|
|
108
|
+
# Save POST form URL separately for later use
|
|
109
|
+
if current_method == 'POST':
|
|
110
|
+
last_post_form_url = current_url
|
|
103
111
|
|
|
104
112
|
# Extract POST data (appears after "POST http://..." line)
|
|
105
113
|
# Format: "POST data: username=&password=&submit=Login"
|
|
@@ -107,6 +115,8 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
107
115
|
post_data_match = re.search(r'^POST data:\s*(.+)$', line)
|
|
108
116
|
if post_data_match:
|
|
109
117
|
current_post_data = post_data_match.group(1).strip()
|
|
118
|
+
# Associate POST data with the POST form URL
|
|
119
|
+
last_post_form_data = current_post_data
|
|
110
120
|
|
|
111
121
|
# Handle resumed injection points from stored session
|
|
112
122
|
# Pattern: "sqlmap resumed the following injection point(s) from stored session:"
|
|
@@ -129,15 +139,23 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
129
139
|
else:
|
|
130
140
|
method = current_method # Use current context
|
|
131
141
|
|
|
142
|
+
# For POST parameters, use the saved POST form URL instead of current_url
|
|
143
|
+
if method == 'POST' and last_post_form_url:
|
|
144
|
+
effective_url = last_post_form_url
|
|
145
|
+
effective_post_data = last_post_form_data or current_post_data
|
|
146
|
+
else:
|
|
147
|
+
effective_url = current_url or target
|
|
148
|
+
effective_post_data = current_post_data if method == 'POST' else None
|
|
149
|
+
|
|
132
150
|
# Mark as confirmed injection
|
|
133
151
|
result['sql_injection_confirmed'] = True
|
|
134
152
|
result['injectable_parameter'] = param
|
|
135
|
-
result['injectable_url'] =
|
|
153
|
+
result['injectable_url'] = effective_url
|
|
136
154
|
result['injectable_method'] = method
|
|
137
155
|
|
|
138
156
|
# Add vulnerability entry
|
|
139
157
|
result['vulnerabilities'].append({
|
|
140
|
-
'url':
|
|
158
|
+
'url': effective_url,
|
|
141
159
|
'parameter': param,
|
|
142
160
|
'vuln_type': 'sqli',
|
|
143
161
|
'injectable': True,
|
|
@@ -147,10 +165,10 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
147
165
|
|
|
148
166
|
# Collect injection point
|
|
149
167
|
injection_point = {
|
|
150
|
-
'url':
|
|
168
|
+
'url': effective_url,
|
|
151
169
|
'parameter': param,
|
|
152
170
|
'method': method,
|
|
153
|
-
'post_data':
|
|
171
|
+
'post_data': effective_post_data,
|
|
154
172
|
'techniques': []
|
|
155
173
|
}
|
|
156
174
|
if not any(
|
|
@@ -318,8 +336,17 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
318
336
|
)
|
|
319
337
|
|
|
320
338
|
if not already_added:
|
|
339
|
+
# For POST parameters, use the saved POST form URL instead of current_url
|
|
340
|
+
# This prevents bug where GET URL testing overwrites the correct POST form URL
|
|
341
|
+
if param_method == 'POST' and last_post_form_url:
|
|
342
|
+
effective_url = last_post_form_url
|
|
343
|
+
effective_post_data = last_post_form_data or current_post_data
|
|
344
|
+
else:
|
|
345
|
+
effective_url = current_url or target
|
|
346
|
+
effective_post_data = current_post_data if param_method == 'POST' else None
|
|
347
|
+
|
|
321
348
|
result['vulnerabilities'].append({
|
|
322
|
-
'url':
|
|
349
|
+
'url': effective_url,
|
|
323
350
|
'parameter': param,
|
|
324
351
|
'vuln_type': 'sqli',
|
|
325
352
|
'injectable': True,
|
|
@@ -332,17 +359,17 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
332
359
|
# Set confirmation flags
|
|
333
360
|
result['sql_injection_confirmed'] = True
|
|
334
361
|
result['injectable_parameter'] = param
|
|
335
|
-
result['injectable_url'] =
|
|
362
|
+
result['injectable_url'] = effective_url
|
|
336
363
|
result['injectable_method'] = param_method # GET, POST, etc.
|
|
337
|
-
if param_method == 'POST' and
|
|
338
|
-
result['injectable_post_data'] =
|
|
364
|
+
if param_method == 'POST' and effective_post_data:
|
|
365
|
+
result['injectable_post_data'] = effective_post_data
|
|
339
366
|
|
|
340
367
|
# Collect ALL injection points for fallback
|
|
341
368
|
injection_point = {
|
|
342
|
-
'url':
|
|
369
|
+
'url': effective_url,
|
|
343
370
|
'parameter': param,
|
|
344
371
|
'method': param_method,
|
|
345
|
-
'post_data':
|
|
372
|
+
'post_data': effective_post_data,
|
|
346
373
|
'techniques': techniques
|
|
347
374
|
}
|
|
348
375
|
# Avoid duplicates
|
|
@@ -364,8 +391,18 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
364
391
|
if param_match:
|
|
365
392
|
method = param_match.group(1) or current_method
|
|
366
393
|
param = param_match.group(2)
|
|
394
|
+
|
|
395
|
+
# For POST parameters, use the saved POST form URL instead of current_url
|
|
396
|
+
# This prevents bug where GET URL testing overwrites the correct POST form URL
|
|
397
|
+
if method == 'POST' and last_post_form_url:
|
|
398
|
+
effective_url = last_post_form_url
|
|
399
|
+
effective_post_data = last_post_form_data or current_post_data
|
|
400
|
+
else:
|
|
401
|
+
effective_url = current_url or target
|
|
402
|
+
effective_post_data = current_post_data if method == 'POST' else None
|
|
403
|
+
|
|
367
404
|
result['vulnerabilities'].append({
|
|
368
|
-
'url':
|
|
405
|
+
'url': effective_url,
|
|
369
406
|
'parameter': param,
|
|
370
407
|
'vuln_type': 'sqli',
|
|
371
408
|
'injectable': True,
|
|
@@ -376,17 +413,17 @@ def parse_sqlmap_output(output: str, target: str = "") -> Dict[str, Any]:
|
|
|
376
413
|
# Set confirmation flags
|
|
377
414
|
result['sql_injection_confirmed'] = True
|
|
378
415
|
result['injectable_parameter'] = param
|
|
379
|
-
result['injectable_url'] =
|
|
416
|
+
result['injectable_url'] = effective_url
|
|
380
417
|
result['injectable_method'] = method
|
|
381
|
-
if method == 'POST' and
|
|
382
|
-
result['injectable_post_data'] =
|
|
418
|
+
if method == 'POST' and effective_post_data:
|
|
419
|
+
result['injectable_post_data'] = effective_post_data
|
|
383
420
|
|
|
384
421
|
# Collect ALL injection points for fallback
|
|
385
422
|
injection_point = {
|
|
386
|
-
'url':
|
|
423
|
+
'url': effective_url,
|
|
387
424
|
'parameter': param,
|
|
388
425
|
'method': method,
|
|
389
|
-
'post_data':
|
|
426
|
+
'post_data': effective_post_data,
|
|
390
427
|
'techniques': [] # Technique details not available at this detection point
|
|
391
428
|
}
|
|
392
429
|
# Avoid duplicates
|
souleyez/plugins/gobuster.py
CHANGED
|
@@ -154,6 +154,83 @@ class GobusterPlugin(PluginBase):
|
|
|
154
154
|
category = "scanning"
|
|
155
155
|
HELP = HELP
|
|
156
156
|
|
|
157
|
+
# Minimum required version (v3.x uses subcommands like 'dir', 'dns', 'vhost')
|
|
158
|
+
MIN_VERSION = "3.0.0"
|
|
159
|
+
|
|
160
|
+
def _check_version(self) -> tuple:
|
|
161
|
+
"""
|
|
162
|
+
Check gobuster version meets minimum requirements.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
(meets_requirement: bool, version: str, error_msg: str or None)
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
# Use -v flag (not 'version' subcommand) - works on v3.x
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
["gobuster", "-v"],
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
timeout=10
|
|
174
|
+
)
|
|
175
|
+
output = result.stdout + result.stderr
|
|
176
|
+
|
|
177
|
+
# Parse version from output like "gobuster version 3.8.2"
|
|
178
|
+
version_match = re.search(r'version\s+(\d+\.\d+\.\d+)', output, re.IGNORECASE)
|
|
179
|
+
if version_match:
|
|
180
|
+
version = version_match.group(1)
|
|
181
|
+
major = int(version.split('.')[0])
|
|
182
|
+
if major >= 3:
|
|
183
|
+
return (True, version, None)
|
|
184
|
+
else:
|
|
185
|
+
return (False, version, self._upgrade_message(version))
|
|
186
|
+
|
|
187
|
+
# Also try --version flag as fallback
|
|
188
|
+
result2 = subprocess.run(
|
|
189
|
+
["gobuster", "--version"],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
timeout=10
|
|
193
|
+
)
|
|
194
|
+
output2 = result2.stdout + result2.stderr
|
|
195
|
+
version_match2 = re.search(r'version\s+(\d+\.\d+\.\d+)', output2, re.IGNORECASE)
|
|
196
|
+
if version_match2:
|
|
197
|
+
version = version_match2.group(1)
|
|
198
|
+
major = int(version.split('.')[0])
|
|
199
|
+
if major >= 3:
|
|
200
|
+
return (True, version, None)
|
|
201
|
+
else:
|
|
202
|
+
return (False, version, self._upgrade_message(version))
|
|
203
|
+
|
|
204
|
+
# If neither flag shows version info, check if v2.x by looking for subcommand error
|
|
205
|
+
# v2.x will show "Usage: gobuster [OPTIONS] ..." without subcommands
|
|
206
|
+
if "dir" not in output.lower() and "dns" not in output.lower():
|
|
207
|
+
return (False, "2.x", self._upgrade_message("2.x"))
|
|
208
|
+
|
|
209
|
+
# If we see dir/dns subcommands mentioned, assume v3.x
|
|
210
|
+
return (True, "3.x", None)
|
|
211
|
+
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
return (False, None, "ERROR: gobuster not found. Install with: sudo apt install gobuster")
|
|
214
|
+
except subprocess.TimeoutExpired:
|
|
215
|
+
return (True, "unknown", None) # Assume it works
|
|
216
|
+
except Exception as e:
|
|
217
|
+
return (True, "unknown", None) # Assume it works
|
|
218
|
+
|
|
219
|
+
def _upgrade_message(self, current_version: str) -> str:
|
|
220
|
+
"""Generate upgrade instructions for old gobuster versions."""
|
|
221
|
+
return (
|
|
222
|
+
f"ERROR: gobuster {current_version} is too old. Version 3.0.0+ required.\n\n"
|
|
223
|
+
f"Gobuster v2.x doesn't support the 'dir/dns/vhost' subcommands used by SoulEyez.\n\n"
|
|
224
|
+
f"UPGRADE OPTIONS:\n"
|
|
225
|
+
f" Option 1 - Install from Go (recommended, gets latest):\n"
|
|
226
|
+
f" go install github.com/OJ/gobuster/v3@latest\n\n"
|
|
227
|
+
f" Option 2 - Download binary from GitHub:\n"
|
|
228
|
+
f" https://github.com/OJ/gobuster/releases\n\n"
|
|
229
|
+
f" Option 3 - On Kali Linux:\n"
|
|
230
|
+
f" sudo apt update && sudo apt install gobuster\n\n"
|
|
231
|
+
f"After upgrading, verify with: gobuster version\n"
|
|
232
|
+
)
|
|
233
|
+
|
|
157
234
|
def _preflight_check(self, base_url: str, timeout: float = 5.0, log_path: str = None) -> Dict[str, Optional[str]]:
|
|
158
235
|
"""
|
|
159
236
|
Probe target with random UUID path to detect false positive responses.
|
|
@@ -225,7 +302,15 @@ class GobusterPlugin(PluginBase):
|
|
|
225
302
|
def build_command(self, target: str, args: List[str] = None, label: str = "", log_path: str = None):
|
|
226
303
|
"""Build gobuster command for background execution with PID tracking."""
|
|
227
304
|
args = args or []
|
|
228
|
-
|
|
305
|
+
|
|
306
|
+
# Check gobuster version meets requirements (v3.x+ required for subcommands)
|
|
307
|
+
meets_req, version, error_msg = self._check_version()
|
|
308
|
+
if not meets_req:
|
|
309
|
+
if log_path:
|
|
310
|
+
with open(log_path, 'w') as f:
|
|
311
|
+
f.write(error_msg)
|
|
312
|
+
return None
|
|
313
|
+
|
|
229
314
|
# Detect the mode from args
|
|
230
315
|
mode = None
|
|
231
316
|
if 'dir' in args:
|
|
@@ -323,9 +408,17 @@ class GobusterPlugin(PluginBase):
|
|
|
323
408
|
|
|
324
409
|
def run(self, target: str, args: List[str] = None, label: str = "", log_path: str = None) -> int:
|
|
325
410
|
"""Execute gobuster scan and write output to log_path."""
|
|
326
|
-
|
|
411
|
+
|
|
327
412
|
args = args or []
|
|
328
|
-
|
|
413
|
+
|
|
414
|
+
# Check gobuster version meets requirements (v3.x+ required for subcommands)
|
|
415
|
+
meets_req, version, error_msg = self._check_version()
|
|
416
|
+
if not meets_req:
|
|
417
|
+
if log_path:
|
|
418
|
+
with open(log_path, 'w') as f:
|
|
419
|
+
f.write(error_msg)
|
|
420
|
+
return 1
|
|
421
|
+
|
|
329
422
|
# Detect the mode from args
|
|
330
423
|
mode = None
|
|
331
424
|
if 'dir' in args:
|
souleyez/plugins/msf_exploit.py
CHANGED
|
@@ -295,13 +295,16 @@ class MsfExploitPlugin(PluginBase):
|
|
|
295
295
|
with open(log_path, 'a') as f:
|
|
296
296
|
f.write(f"[!] Poll error: {e}\n")
|
|
297
297
|
|
|
298
|
-
# Timeout - no session opened
|
|
298
|
+
# Timeout - no session opened (not an error, just means target likely not vulnerable)
|
|
299
299
|
if log_path:
|
|
300
300
|
with open(log_path, 'a') as f:
|
|
301
|
-
f.write(f"\n[
|
|
301
|
+
f.write(f"\n[*] No session opened after {max_poll}s\n")
|
|
302
|
+
f.write(f"[*] Target may not be vulnerable or exploit conditions not met\n")
|
|
303
|
+
f.write(f"[*] Try re-running the exploit if needed\n")
|
|
302
304
|
f.write(f"Completed: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n")
|
|
303
305
|
|
|
304
|
-
|
|
306
|
+
# Return success=False but no 'error' key - this is a "no results" case, not an error
|
|
307
|
+
return {'success': False, 'no_session': True, 'reason': f'No session after {max_poll}s'}
|
|
305
308
|
|
|
306
309
|
def _get_local_ip(self, target: str) -> str:
|
|
307
310
|
"""Get local IP that can reach the target."""
|
souleyez/ui/interactive.py
CHANGED
|
@@ -9694,9 +9694,19 @@ def _view_all_job_alerts(item: dict):
|
|
|
9694
9694
|
return f"{icon} {severity[:6].upper()}"
|
|
9695
9695
|
elif key == 'rule_id':
|
|
9696
9696
|
if is_wazuh_style:
|
|
9697
|
-
|
|
9697
|
+
# Wazuh: show first rule group (more descriptive) or rule ID
|
|
9698
|
+
rule_data = alert.get('rule', {})
|
|
9699
|
+
groups = rule_data.get('groups', [])
|
|
9700
|
+
if groups:
|
|
9701
|
+
# Get most specific group (often last is most specific)
|
|
9702
|
+
return str(groups[-1])[:12]
|
|
9703
|
+
return str(rule_data.get('id', 'N/A'))[:12]
|
|
9698
9704
|
else:
|
|
9699
|
-
|
|
9705
|
+
# Splunk: show MITRE tactic if available, else sourcetype
|
|
9706
|
+
mitre_tactics = alert.get('mitre_tactics', [])
|
|
9707
|
+
if mitre_tactics:
|
|
9708
|
+
return str(mitre_tactics[0])[:12]
|
|
9709
|
+
return str(alert.get('rule_id', 'N/A'))[:12]
|
|
9700
9710
|
elif key == 'agent_name':
|
|
9701
9711
|
if is_wazuh_style:
|
|
9702
9712
|
return alert.get('agent', {}).get('name', 'N/A')
|
|
@@ -9704,9 +9714,17 @@ def _view_all_job_alerts(item: dict):
|
|
|
9704
9714
|
return str(alert.get('source_ip', alert.get('host', 'N/A')))[:15]
|
|
9705
9715
|
elif key == 'description':
|
|
9706
9716
|
if is_wazuh_style:
|
|
9707
|
-
|
|
9717
|
+
# Wazuh: use rule description, or rule groups if more descriptive
|
|
9718
|
+
rule_data = alert.get('rule', {})
|
|
9719
|
+
desc = rule_data.get('description', '')
|
|
9720
|
+
if not desc:
|
|
9721
|
+
groups = rule_data.get('groups', [])
|
|
9722
|
+
if groups:
|
|
9723
|
+
desc = ', '.join(groups[:2])
|
|
9724
|
+
return str(desc)[:45] if desc else 'No description'
|
|
9708
9725
|
else:
|
|
9709
|
-
|
|
9726
|
+
# Splunk: prefer actual description (log content) over rule_name
|
|
9727
|
+
desc = alert.get('description', '') or alert.get('rule_name', '')
|
|
9710
9728
|
return str(desc)[:45] if desc else 'No description'
|
|
9711
9729
|
elif key == 'timestamp':
|
|
9712
9730
|
ts = alert.get('timestamp', 'N/A')
|
|
@@ -9718,9 +9736,9 @@ def _view_all_job_alerts(item: dict):
|
|
|
9718
9736
|
columns = [
|
|
9719
9737
|
{'name': '#', 'width': 5, 'key': '_idx'},
|
|
9720
9738
|
{'name': 'Level', 'width': 10, 'key': 'level_display'},
|
|
9721
|
-
{'name': '
|
|
9739
|
+
{'name': 'Type', 'width': 14, 'key': 'rule_id'},
|
|
9722
9740
|
{'name': 'Agent', 'width': 15, 'key': 'agent_name'},
|
|
9723
|
-
{'name': 'Description', 'width':
|
|
9741
|
+
{'name': 'Description', 'width': 42, 'key': 'description'},
|
|
9724
9742
|
{'name': 'Time', 'width': 20, 'key': 'timestamp'},
|
|
9725
9743
|
]
|
|
9726
9744
|
|
|
@@ -31347,13 +31365,13 @@ def run_interactive_menu():
|
|
|
31347
31365
|
click.echo("└" + "─" * (width - 2) + "┘")
|
|
31348
31366
|
click.echo("\n")
|
|
31349
31367
|
|
|
31350
|
-
# ASCII Art Banner - SOULEYEZ
|
|
31351
|
-
click.echo(click.style(" ███████╗ ██████╗ ██╗ ██╗██╗ ███████╗██╗ ██╗███████╗███████╗", fg='bright_cyan', bold=True))
|
|
31352
|
-
click.echo(click.style(" ██╔════╝██╔═══██╗██║ ██║██║ ██╔════╝╚██╗ ██╔╝██╔════╝╚══███╔╝", fg='bright_cyan', bold=True))
|
|
31353
|
-
click.echo(click.style(" ███████╗██║ ██║██║ ██║██║ █████╗ ╚████╔╝ █████╗ ███╔╝ ", fg='bright_cyan', bold=True))
|
|
31354
|
-
click.echo(click.style(" ╚════██║██║ ██║██║ ██║██║ ██╔══╝ ╚██╔╝ ██╔══╝ ███╔╝ ", fg='bright_cyan', bold=True))
|
|
31355
|
-
click.echo(click.style(" ███████║╚██████╔╝╚██████╔╝███████╗███████╗ ██║ ███████╗███████╗", fg='bright_cyan', bold=True))
|
|
31356
|
-
click.echo(click.style(" ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝", fg='bright_cyan', bold=True))
|
|
31368
|
+
# ASCII Art Banner - SOULEYEZ with all-seeing eye on the right
|
|
31369
|
+
click.echo(click.style(" ███████╗ ██████╗ ██╗ ██╗██╗ ███████╗██╗ ██╗███████╗███████╗", fg='bright_cyan', bold=True) + click.style(" ▄██▄", fg='bright_blue', bold=True))
|
|
31370
|
+
click.echo(click.style(" ██╔════╝██╔═══██╗██║ ██║██║ ██╔════╝╚██╗ ██╔╝██╔════╝╚══███╔╝", fg='bright_cyan', bold=True) + click.style(" ▄█▀ ▀█▄", fg='bright_blue', bold=True))
|
|
31371
|
+
click.echo(click.style(" ███████╗██║ ██║██║ ██║██║ █████╗ ╚████╔╝ █████╗ ███╔╝ ", fg='bright_cyan', bold=True) + click.style(" █ ◉ █", fg='bright_blue', bold=True))
|
|
31372
|
+
click.echo(click.style(" ╚════██║██║ ██║██║ ██║██║ ██╔══╝ ╚██╔╝ ██╔══╝ ███╔╝ ", fg='bright_cyan', bold=True) + click.style(" █ ═══ █", fg='bright_blue', bold=True))
|
|
31373
|
+
click.echo(click.style(" ███████║╚██████╔╝╚██████╔╝███████╗███████╗ ██║ ███████╗███████╗", fg='bright_cyan', bold=True) + click.style(" ▀█▄ ▄█▀", fg='bright_blue', bold=True))
|
|
31374
|
+
click.echo(click.style(" ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝", fg='bright_cyan', bold=True) + click.style(" ▀██▀", fg='bright_blue', bold=True))
|
|
31357
31375
|
click.echo()
|
|
31358
31376
|
|
|
31359
31377
|
# Tagline and description
|