souleyez 2.26.0__py3-none-any.whl → 2.28.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 +36 -12
- souleyez/docs/README.md +2 -2
- souleyez/docs/user-guide/configuration.md +1 -1
- souleyez/docs/user-guide/scope-management.md +683 -0
- souleyez/engine/background.py +655 -168
- souleyez/engine/result_handler.py +340 -11
- souleyez/engine/worker_manager.py +98 -2
- souleyez/main.py +222 -1
- souleyez/plugins/http_fingerprint.py +8 -2
- souleyez/plugins/nuclei.py +2 -1
- souleyez/plugins/searchsploit.py +21 -18
- souleyez/security/scope_validator.py +615 -0
- souleyez/storage/hosts.py +87 -2
- souleyez/storage/migrations/_026_add_engagement_scope.py +87 -0
- souleyez/ui/interactive.py +289 -5
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/METADATA +9 -3
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/RECORD +22 -19
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/WHEEL +0 -0
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/entry_points.txt +0 -0
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/licenses/LICENSE +0 -0
- {souleyez-2.26.0.dist-info → souleyez-2.28.0.dist-info}/top_level.txt +0 -0
|
@@ -10,6 +10,95 @@ from .job_status import STATUS_DONE, STATUS_NO_RESULTS, STATUS_WARNING, STATUS_E
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
# Common error patterns that indicate tool failure (not "no results")
|
|
14
|
+
TOOL_ERROR_PATTERNS = {
|
|
15
|
+
'common': [
|
|
16
|
+
'connection refused',
|
|
17
|
+
'connection timed out',
|
|
18
|
+
'no route to host',
|
|
19
|
+
'network is unreachable',
|
|
20
|
+
'name or service not known',
|
|
21
|
+
'temporary failure in name resolution',
|
|
22
|
+
'host is down',
|
|
23
|
+
'connection reset by peer',
|
|
24
|
+
],
|
|
25
|
+
'nmap': [
|
|
26
|
+
'host seems down',
|
|
27
|
+
'note: host seems down',
|
|
28
|
+
'failed to resolve',
|
|
29
|
+
],
|
|
30
|
+
'gobuster': [
|
|
31
|
+
'timeout occurred during the request',
|
|
32
|
+
'error on running gobuster',
|
|
33
|
+
'unable to connect',
|
|
34
|
+
'context deadline exceeded',
|
|
35
|
+
],
|
|
36
|
+
'hydra': [
|
|
37
|
+
'can not connect',
|
|
38
|
+
'could not connect',
|
|
39
|
+
'error connecting',
|
|
40
|
+
'target does not support',
|
|
41
|
+
],
|
|
42
|
+
'nikto': [
|
|
43
|
+
'error connecting to host',
|
|
44
|
+
'unable to connect',
|
|
45
|
+
'no web server found',
|
|
46
|
+
],
|
|
47
|
+
'nuclei': [
|
|
48
|
+
'could not connect',
|
|
49
|
+
'context deadline exceeded',
|
|
50
|
+
'no address found',
|
|
51
|
+
],
|
|
52
|
+
'ffuf': [
|
|
53
|
+
'error making request',
|
|
54
|
+
'context deadline exceeded',
|
|
55
|
+
],
|
|
56
|
+
'sqlmap': [
|
|
57
|
+
'connection timed out',
|
|
58
|
+
'unable to connect',
|
|
59
|
+
'target url content is not stable',
|
|
60
|
+
],
|
|
61
|
+
'enum4linux': [
|
|
62
|
+
'could not initialise',
|
|
63
|
+
'nt_status_connection_refused',
|
|
64
|
+
'nt_status_host_unreachable',
|
|
65
|
+
'nt_status_io_timeout',
|
|
66
|
+
],
|
|
67
|
+
'smbmap': [
|
|
68
|
+
'could not connect',
|
|
69
|
+
'connection error',
|
|
70
|
+
'nt_status_connection_refused',
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_tool_error(log_content: str, tool: str) -> Optional[str]:
|
|
76
|
+
"""
|
|
77
|
+
Check log content for tool errors that indicate failure (not just "no results").
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
log_content: The log file content
|
|
81
|
+
tool: Tool name (lowercase)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Error pattern found, or None if no error detected
|
|
85
|
+
"""
|
|
86
|
+
log_lower = log_content.lower()
|
|
87
|
+
|
|
88
|
+
# Check common patterns
|
|
89
|
+
for pattern in TOOL_ERROR_PATTERNS['common']:
|
|
90
|
+
if pattern in log_lower:
|
|
91
|
+
return pattern
|
|
92
|
+
|
|
93
|
+
# Check tool-specific patterns
|
|
94
|
+
tool_patterns = TOOL_ERROR_PATTERNS.get(tool, [])
|
|
95
|
+
for pattern in tool_patterns:
|
|
96
|
+
if pattern in log_lower:
|
|
97
|
+
return pattern
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
13
102
|
def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
14
103
|
"""
|
|
15
104
|
Process completed job and parse results into database.
|
|
@@ -44,6 +133,7 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
44
133
|
return None
|
|
45
134
|
|
|
46
135
|
if not log_path or not os.path.exists(log_path):
|
|
136
|
+
logger.error(f"Job {job.get('id')} parse failed: log file missing or does not exist (path={log_path})")
|
|
47
137
|
return None
|
|
48
138
|
|
|
49
139
|
# Get engagement ID from job or fall back to current engagement
|
|
@@ -56,10 +146,12 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
56
146
|
engagement = em.get_current()
|
|
57
147
|
|
|
58
148
|
if not engagement:
|
|
149
|
+
logger.error(f"Job {job.get('id')} parse failed: no engagement_id and no current engagement")
|
|
59
150
|
return None
|
|
60
151
|
|
|
61
152
|
engagement_id = engagement['id']
|
|
62
|
-
except Exception:
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"Job {job.get('id')} parse failed: engagement lookup error: {e}")
|
|
63
155
|
return None
|
|
64
156
|
|
|
65
157
|
# Route to appropriate parser
|
|
@@ -110,6 +202,13 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
110
202
|
parse_result = parse_dalfox_job(engagement_id, log_path, job)
|
|
111
203
|
elif tool == 'http_fingerprint':
|
|
112
204
|
parse_result = parse_http_fingerprint_job(engagement_id, log_path, job)
|
|
205
|
+
elif tool == 'hashcat':
|
|
206
|
+
parse_result = parse_hashcat_job(engagement_id, log_path, job)
|
|
207
|
+
elif tool == 'john':
|
|
208
|
+
parse_result = parse_john_job(engagement_id, log_path, job)
|
|
209
|
+
else:
|
|
210
|
+
# No parser for this tool - log it so we know
|
|
211
|
+
logger.warning(f"Job {job.get('id')} has no parser for tool '{tool}' - results not extracted")
|
|
113
212
|
|
|
114
213
|
# NOTE: Auto-chaining is now handled in background.py after parsing completes
|
|
115
214
|
# This avoids duplicate job creation and gives better control over timing
|
|
@@ -517,9 +616,16 @@ def parse_nmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
|
|
|
517
616
|
'version': svc.get('version', '')
|
|
518
617
|
})
|
|
519
618
|
|
|
619
|
+
# Check for nmap errors before determining status
|
|
620
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
621
|
+
log_content = f.read()
|
|
622
|
+
nmap_error = detect_tool_error(log_content, 'nmap')
|
|
623
|
+
|
|
520
624
|
# Determine status based on results
|
|
521
625
|
hosts_up = len([h for h in parsed.get('hosts', []) if h.get('status') == 'up'])
|
|
522
|
-
if
|
|
626
|
+
if nmap_error:
|
|
627
|
+
status = STATUS_ERROR # Tool failed to run properly
|
|
628
|
+
elif hosts_up > 0:
|
|
523
629
|
status = STATUS_DONE # Found hosts
|
|
524
630
|
else:
|
|
525
631
|
status = STATUS_NO_RESULTS # No hosts up
|
|
@@ -1112,8 +1218,13 @@ def parse_gobuster_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -
|
|
|
1112
1218
|
exclude_length = length_match.group(1)
|
|
1113
1219
|
logger.info(f"Gobuster wildcard detected: Length {exclude_length}b")
|
|
1114
1220
|
|
|
1221
|
+
# Check for gobuster errors
|
|
1222
|
+
gobuster_error = detect_tool_error(log_content, 'gobuster')
|
|
1223
|
+
|
|
1115
1224
|
# Determine status based on results
|
|
1116
|
-
if
|
|
1225
|
+
if gobuster_error:
|
|
1226
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
1227
|
+
elif wildcard_detected:
|
|
1117
1228
|
# Wildcard detected - warning status (triggers auto-retry)
|
|
1118
1229
|
status = STATUS_WARNING
|
|
1119
1230
|
elif stats['total'] > 0:
|
|
@@ -1494,8 +1605,13 @@ def parse_sqlmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
|
|
|
1494
1605
|
|
|
1495
1606
|
stats = get_sqli_stats(parsed)
|
|
1496
1607
|
|
|
1608
|
+
# Check for sqlmap errors
|
|
1609
|
+
sqlmap_error = detect_tool_error(log_content, 'sqlmap')
|
|
1610
|
+
|
|
1497
1611
|
# Determine status based on results
|
|
1498
|
-
if
|
|
1612
|
+
if sqlmap_error:
|
|
1613
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
1614
|
+
elif stats['sqli_confirmed'] or stats['xss_possible'] or stats['fi_possible']:
|
|
1499
1615
|
status = STATUS_DONE # Found injection vulnerabilities
|
|
1500
1616
|
else:
|
|
1501
1617
|
status = STATUS_NO_RESULTS # No injections found
|
|
@@ -2001,11 +2117,22 @@ def parse_smbmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
|
|
|
2001
2117
|
)
|
|
2002
2118
|
findings_added += 1
|
|
2003
2119
|
|
|
2120
|
+
# Check for smbmap errors
|
|
2121
|
+
smbmap_error = detect_tool_error(log_content, 'smbmap')
|
|
2122
|
+
|
|
2123
|
+
# Determine status
|
|
2124
|
+
if smbmap_error:
|
|
2125
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
2126
|
+
elif shares_added > 0 or findings_added > 0:
|
|
2127
|
+
status = STATUS_DONE
|
|
2128
|
+
else:
|
|
2129
|
+
status = STATUS_NO_RESULTS
|
|
2130
|
+
|
|
2004
2131
|
return {
|
|
2005
2132
|
'tool': 'smbmap',
|
|
2006
2133
|
'host': parsed['target'],
|
|
2007
2134
|
'connection_status': parsed.get('status', 'Unknown'), # SMB connection status
|
|
2008
|
-
'status':
|
|
2135
|
+
'status': status, # Job status
|
|
2009
2136
|
'shares_added': shares_added,
|
|
2010
2137
|
'files_added': files_added,
|
|
2011
2138
|
'findings_added': findings_added
|
|
@@ -2372,8 +2499,13 @@ def parse_hydra_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
|
|
|
2372
2499
|
)
|
|
2373
2500
|
findings_added += 1
|
|
2374
2501
|
|
|
2502
|
+
# Check for hydra errors
|
|
2503
|
+
hydra_error = detect_tool_error(log_content, 'hydra')
|
|
2504
|
+
|
|
2375
2505
|
# Determine status based on results
|
|
2376
|
-
if
|
|
2506
|
+
if hydra_error:
|
|
2507
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
2508
|
+
elif len(parsed.get('credentials', [])) > 0:
|
|
2377
2509
|
status = STATUS_DONE # Found valid credentials
|
|
2378
2510
|
elif len(parsed.get('usernames', [])) > 0:
|
|
2379
2511
|
status = STATUS_DONE # Found valid usernames (partial success is still a result)
|
|
@@ -2485,8 +2617,15 @@ def parse_nuclei_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
|
|
|
2485
2617
|
)
|
|
2486
2618
|
findings_added += 1
|
|
2487
2619
|
|
|
2620
|
+
# Check for nuclei errors
|
|
2621
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2622
|
+
log_content = f.read()
|
|
2623
|
+
nuclei_error = detect_tool_error(log_content, 'nuclei')
|
|
2624
|
+
|
|
2488
2625
|
# Determine status based on results
|
|
2489
|
-
if
|
|
2626
|
+
if nuclei_error:
|
|
2627
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
2628
|
+
elif parsed.get('findings_count', 0) > 0:
|
|
2490
2629
|
status = STATUS_DONE # Found vulnerabilities
|
|
2491
2630
|
else:
|
|
2492
2631
|
status = STATUS_NO_RESULTS # No vulnerabilities found
|
|
@@ -2608,6 +2747,9 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
|
|
|
2608
2747
|
'ip': parsed['target']
|
|
2609
2748
|
})
|
|
2610
2749
|
|
|
2750
|
+
# Check for enum4linux errors
|
|
2751
|
+
enum4linux_error = detect_tool_error(log_content, 'enum4linux')
|
|
2752
|
+
|
|
2611
2753
|
# Determine status: done if we found any results (shares, users, or findings)
|
|
2612
2754
|
has_results = (
|
|
2613
2755
|
findings_added > 0 or
|
|
@@ -2616,9 +2758,16 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
|
|
|
2616
2758
|
stats['total_shares'] > 0
|
|
2617
2759
|
)
|
|
2618
2760
|
|
|
2761
|
+
if enum4linux_error:
|
|
2762
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
2763
|
+
elif has_results:
|
|
2764
|
+
status = STATUS_DONE
|
|
2765
|
+
else:
|
|
2766
|
+
status = STATUS_NO_RESULTS
|
|
2767
|
+
|
|
2619
2768
|
return {
|
|
2620
2769
|
'tool': 'enum4linux',
|
|
2621
|
-
'status':
|
|
2770
|
+
'status': status,
|
|
2622
2771
|
'findings_added': findings_added,
|
|
2623
2772
|
'credentials_added': credentials_added,
|
|
2624
2773
|
'users_found': len(parsed['users']),
|
|
@@ -2725,13 +2874,26 @@ def parse_ffuf_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
|
|
|
2725
2874
|
|
|
2726
2875
|
if host_id and parsed.get('paths'):
|
|
2727
2876
|
paths_added = wpm.bulk_add_web_paths(host_id, parsed['paths'])
|
|
2728
|
-
|
|
2877
|
+
|
|
2729
2878
|
# Check for sensitive paths and create findings (same as gobuster)
|
|
2730
2879
|
created_findings = _create_findings_for_sensitive_paths(engagement_id, host_id, parsed['paths'], job)
|
|
2731
2880
|
|
|
2881
|
+
# Check for ffuf errors
|
|
2882
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2883
|
+
log_content = f.read()
|
|
2884
|
+
ffuf_error = detect_tool_error(log_content, 'ffuf')
|
|
2885
|
+
|
|
2886
|
+
# Determine status
|
|
2887
|
+
if ffuf_error:
|
|
2888
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
2889
|
+
elif parsed.get('results_found', 0) > 0:
|
|
2890
|
+
status = STATUS_DONE
|
|
2891
|
+
else:
|
|
2892
|
+
status = STATUS_NO_RESULTS
|
|
2893
|
+
|
|
2732
2894
|
return {
|
|
2733
2895
|
'tool': 'ffuf',
|
|
2734
|
-
'status':
|
|
2896
|
+
'status': status,
|
|
2735
2897
|
'target': target,
|
|
2736
2898
|
'results_found': parsed.get('results_found', 0),
|
|
2737
2899
|
'paths_added': paths_added,
|
|
@@ -3050,8 +3212,13 @@ def parse_nikto_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
|
|
|
3050
3212
|
)
|
|
3051
3213
|
findings_added += 1
|
|
3052
3214
|
|
|
3215
|
+
# Check for nikto errors
|
|
3216
|
+
nikto_error = detect_tool_error(output, 'nikto')
|
|
3217
|
+
|
|
3053
3218
|
# Determine status based on results
|
|
3054
|
-
if
|
|
3219
|
+
if nikto_error:
|
|
3220
|
+
status = STATUS_ERROR # Tool failed to connect
|
|
3221
|
+
elif findings_added > 0:
|
|
3055
3222
|
status = STATUS_DONE
|
|
3056
3223
|
else:
|
|
3057
3224
|
status = STATUS_NO_RESULTS
|
|
@@ -3245,3 +3412,165 @@ def parse_dalfox_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
|
|
|
3245
3412
|
except Exception as e:
|
|
3246
3413
|
logger.error(f"Error parsing dalfox job: {e}")
|
|
3247
3414
|
return {'error': str(e)}
|
|
3415
|
+
|
|
3416
|
+
|
|
3417
|
+
def parse_hashcat_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
|
|
3418
|
+
"""Parse hashcat job results and extract cracked passwords."""
|
|
3419
|
+
try:
|
|
3420
|
+
from souleyez.parsers.hashcat_parser import parse_hashcat_output, map_to_credentials
|
|
3421
|
+
from souleyez.storage.credentials import CredentialsManager
|
|
3422
|
+
from souleyez.storage.findings import FindingsManager
|
|
3423
|
+
|
|
3424
|
+
# Read the log file
|
|
3425
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
3426
|
+
log_content = f.read()
|
|
3427
|
+
|
|
3428
|
+
# Parse hashcat output
|
|
3429
|
+
hash_file = job.get('metadata', {}).get('hash_file', '')
|
|
3430
|
+
parsed = parse_hashcat_output(log_content, hash_file)
|
|
3431
|
+
|
|
3432
|
+
# Store credentials
|
|
3433
|
+
cm = CredentialsManager()
|
|
3434
|
+
creds_added = 0
|
|
3435
|
+
|
|
3436
|
+
for cracked in parsed.get('cracked', []):
|
|
3437
|
+
try:
|
|
3438
|
+
cm.add_credential(
|
|
3439
|
+
engagement_id=engagement_id,
|
|
3440
|
+
host_id=None, # Hash cracking typically not tied to a specific host
|
|
3441
|
+
username='', # Hashcat doesn't always know the username
|
|
3442
|
+
password=cracked['password'],
|
|
3443
|
+
service='cracked_hash',
|
|
3444
|
+
credential_type='password',
|
|
3445
|
+
tool='hashcat',
|
|
3446
|
+
status='cracked',
|
|
3447
|
+
notes=f"Cracked from hash: {cracked['hash'][:32]}..."
|
|
3448
|
+
)
|
|
3449
|
+
creds_added += 1
|
|
3450
|
+
except Exception:
|
|
3451
|
+
pass # Skip duplicates
|
|
3452
|
+
|
|
3453
|
+
# Create finding if we cracked passwords
|
|
3454
|
+
fm = FindingsManager()
|
|
3455
|
+
findings_added = 0
|
|
3456
|
+
|
|
3457
|
+
if parsed.get('cracked'):
|
|
3458
|
+
fm.add_finding(
|
|
3459
|
+
engagement_id=engagement_id,
|
|
3460
|
+
title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
|
|
3461
|
+
finding_type='credential',
|
|
3462
|
+
severity='high',
|
|
3463
|
+
description=f"Hashcat successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
|
|
3464
|
+
f"Status: {parsed['stats'].get('status', 'unknown')}\n"
|
|
3465
|
+
f"Cracked: {parsed['stats'].get('cracked_count', len(parsed['cracked']))}",
|
|
3466
|
+
tool='hashcat'
|
|
3467
|
+
)
|
|
3468
|
+
findings_added += 1
|
|
3469
|
+
|
|
3470
|
+
# Determine status
|
|
3471
|
+
if creds_added > 0:
|
|
3472
|
+
status = STATUS_DONE
|
|
3473
|
+
elif parsed['stats'].get('status') == 'exhausted':
|
|
3474
|
+
status = STATUS_NO_RESULTS # Ran to completion but found nothing
|
|
3475
|
+
else:
|
|
3476
|
+
status = STATUS_NO_RESULTS
|
|
3477
|
+
|
|
3478
|
+
return {
|
|
3479
|
+
'tool': 'hashcat',
|
|
3480
|
+
'status': status,
|
|
3481
|
+
'cracked_count': len(parsed.get('cracked', [])),
|
|
3482
|
+
'credentials_added': creds_added,
|
|
3483
|
+
'findings_added': findings_added,
|
|
3484
|
+
'hashcat_status': parsed['stats'].get('status', 'unknown')
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
except Exception as e:
|
|
3488
|
+
logger.error(f"Error parsing hashcat job: {e}")
|
|
3489
|
+
return {'error': str(e)}
|
|
3490
|
+
|
|
3491
|
+
|
|
3492
|
+
def parse_john_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
|
|
3493
|
+
"""Parse John the Ripper job results and extract cracked passwords."""
|
|
3494
|
+
try:
|
|
3495
|
+
from souleyez.parsers.john_parser import parse_john_output
|
|
3496
|
+
from souleyez.storage.credentials import CredentialsManager
|
|
3497
|
+
from souleyez.storage.findings import FindingsManager
|
|
3498
|
+
|
|
3499
|
+
# Read the log file
|
|
3500
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
3501
|
+
log_content = f.read()
|
|
3502
|
+
|
|
3503
|
+
# Get hash file from job metadata if available
|
|
3504
|
+
hash_file = job.get('metadata', {}).get('hash_file', None)
|
|
3505
|
+
|
|
3506
|
+
# Parse john output
|
|
3507
|
+
parsed = parse_john_output(log_content, hash_file)
|
|
3508
|
+
|
|
3509
|
+
# Store credentials
|
|
3510
|
+
cm = CredentialsManager()
|
|
3511
|
+
creds_added = 0
|
|
3512
|
+
|
|
3513
|
+
for cred in parsed.get('cracked', []):
|
|
3514
|
+
username = cred.get('username', '')
|
|
3515
|
+
password = cred.get('password', '')
|
|
3516
|
+
|
|
3517
|
+
if password: # At minimum we need a password
|
|
3518
|
+
try:
|
|
3519
|
+
cm.add_credential(
|
|
3520
|
+
engagement_id=engagement_id,
|
|
3521
|
+
host_id=None, # Hash cracking typically not tied to a specific host
|
|
3522
|
+
username=username if username != 'unknown' else '',
|
|
3523
|
+
password=password,
|
|
3524
|
+
service='cracked_hash',
|
|
3525
|
+
credential_type='password',
|
|
3526
|
+
tool='john',
|
|
3527
|
+
status='cracked',
|
|
3528
|
+
notes=f"Cracked by John the Ripper"
|
|
3529
|
+
)
|
|
3530
|
+
creds_added += 1
|
|
3531
|
+
except Exception:
|
|
3532
|
+
pass # Skip duplicates
|
|
3533
|
+
|
|
3534
|
+
# Create finding if we cracked passwords
|
|
3535
|
+
fm = FindingsManager()
|
|
3536
|
+
findings_added = 0
|
|
3537
|
+
|
|
3538
|
+
if parsed.get('cracked'):
|
|
3539
|
+
usernames = [c.get('username', 'unknown') for c in parsed['cracked'] if c.get('username')]
|
|
3540
|
+
usernames_str = ', '.join(usernames[:10]) # First 10
|
|
3541
|
+
if len(usernames) > 10:
|
|
3542
|
+
usernames_str += f" (+{len(usernames) - 10} more)"
|
|
3543
|
+
|
|
3544
|
+
fm.add_finding(
|
|
3545
|
+
engagement_id=engagement_id,
|
|
3546
|
+
title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
|
|
3547
|
+
finding_type='credential',
|
|
3548
|
+
severity='high',
|
|
3549
|
+
description=f"John the Ripper successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
|
|
3550
|
+
f"Usernames: {usernames_str}\n"
|
|
3551
|
+
f"Session status: {parsed.get('session_status', 'unknown')}",
|
|
3552
|
+
tool='john'
|
|
3553
|
+
)
|
|
3554
|
+
findings_added += 1
|
|
3555
|
+
|
|
3556
|
+
# Determine status
|
|
3557
|
+
if creds_added > 0:
|
|
3558
|
+
status = STATUS_DONE
|
|
3559
|
+
elif parsed.get('session_status') == 'completed':
|
|
3560
|
+
status = STATUS_NO_RESULTS # Ran to completion but found nothing
|
|
3561
|
+
else:
|
|
3562
|
+
status = STATUS_NO_RESULTS
|
|
3563
|
+
|
|
3564
|
+
return {
|
|
3565
|
+
'tool': 'john',
|
|
3566
|
+
'status': status,
|
|
3567
|
+
'cracked_count': len(parsed.get('cracked', [])),
|
|
3568
|
+
'credentials_added': creds_added,
|
|
3569
|
+
'findings_added': findings_added,
|
|
3570
|
+
'session_status': parsed.get('session_status', 'unknown'),
|
|
3571
|
+
'total_loaded': parsed.get('total_loaded', 0)
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
except Exception as e:
|
|
3575
|
+
logger.error(f"Error parsing john job: {e}")
|
|
3576
|
+
return {'error': str(e)}
|
|
@@ -4,12 +4,13 @@ Worker health check and management utilities
|
|
|
4
4
|
"""
|
|
5
5
|
import psutil
|
|
6
6
|
import time
|
|
7
|
-
from typing import Optional, Tuple
|
|
7
|
+
from typing import Optional, Tuple, Dict, Any
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def is_worker_running() -> Tuple[bool, Optional[int]]:
|
|
11
11
|
"""
|
|
12
|
-
Check if background worker is running
|
|
12
|
+
Check if background worker is running.
|
|
13
|
+
|
|
13
14
|
Returns: (is_running, pid)
|
|
14
15
|
"""
|
|
15
16
|
for proc in psutil.process_iter(['pid', 'cmdline']):
|
|
@@ -29,6 +30,40 @@ def is_worker_running() -> Tuple[bool, Optional[int]]:
|
|
|
29
30
|
return False, None
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def is_worker_healthy() -> Tuple[bool, Optional[int], Optional[str]]:
|
|
34
|
+
"""
|
|
35
|
+
Check if background worker is running AND healthy (responding).
|
|
36
|
+
|
|
37
|
+
Uses heartbeat file to verify worker is actively processing.
|
|
38
|
+
A worker process may exist but be frozen/hung - heartbeat detects this.
|
|
39
|
+
|
|
40
|
+
Returns: (is_healthy, pid, issue)
|
|
41
|
+
- is_healthy: True if worker is running and heartbeat is fresh
|
|
42
|
+
- pid: Worker PID if found, None otherwise
|
|
43
|
+
- issue: Description of issue if not healthy, None otherwise
|
|
44
|
+
"""
|
|
45
|
+
from souleyez.engine.background import (
|
|
46
|
+
get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
is_running, pid = is_worker_running()
|
|
50
|
+
|
|
51
|
+
if not is_running:
|
|
52
|
+
return False, None, "Worker process not found"
|
|
53
|
+
|
|
54
|
+
# Check heartbeat
|
|
55
|
+
heartbeat_age = get_heartbeat_age()
|
|
56
|
+
|
|
57
|
+
if heartbeat_age is None:
|
|
58
|
+
# No heartbeat file - worker may have just started
|
|
59
|
+
return True, pid, "No heartbeat yet (may be starting)"
|
|
60
|
+
|
|
61
|
+
if heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
|
|
62
|
+
return False, pid, f"Heartbeat stale ({int(heartbeat_age)}s old, threshold: {HEARTBEAT_STALE_THRESHOLD}s)"
|
|
63
|
+
|
|
64
|
+
return True, pid, None
|
|
65
|
+
|
|
66
|
+
|
|
32
67
|
def start_worker_if_needed() -> bool:
|
|
33
68
|
"""
|
|
34
69
|
Start worker if not running
|
|
@@ -107,3 +142,64 @@ def get_worker_status() -> dict:
|
|
|
107
142
|
pass
|
|
108
143
|
|
|
109
144
|
return status
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_worker_health() -> Dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Get detailed worker health status including heartbeat info.
|
|
150
|
+
|
|
151
|
+
Returns dict with:
|
|
152
|
+
- running: Whether worker process exists
|
|
153
|
+
- healthy: Whether worker is running AND responsive
|
|
154
|
+
- pid: Worker PID if running
|
|
155
|
+
- uptime: Seconds since worker started
|
|
156
|
+
- heartbeat_age: Seconds since last heartbeat
|
|
157
|
+
- heartbeat_stale: Whether heartbeat is stale
|
|
158
|
+
- issue: Description of any health issue
|
|
159
|
+
- cpu_percent: CPU usage percentage
|
|
160
|
+
- memory_mb: Memory usage in MB
|
|
161
|
+
"""
|
|
162
|
+
from souleyez.engine.background import (
|
|
163
|
+
get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
is_running, pid = is_worker_running()
|
|
167
|
+
heartbeat_age = get_heartbeat_age()
|
|
168
|
+
|
|
169
|
+
health = {
|
|
170
|
+
'running': is_running,
|
|
171
|
+
'healthy': False,
|
|
172
|
+
'pid': pid,
|
|
173
|
+
'uptime': None,
|
|
174
|
+
'heartbeat_age': heartbeat_age,
|
|
175
|
+
'heartbeat_stale': heartbeat_age is None or heartbeat_age > HEARTBEAT_STALE_THRESHOLD,
|
|
176
|
+
'issue': None,
|
|
177
|
+
'cpu_percent': None,
|
|
178
|
+
'memory_mb': None
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if not is_running:
|
|
182
|
+
health['issue'] = "Worker process not found"
|
|
183
|
+
return health
|
|
184
|
+
|
|
185
|
+
# Get process info
|
|
186
|
+
try:
|
|
187
|
+
proc = psutil.Process(pid)
|
|
188
|
+
health['uptime'] = int(time.time() - proc.create_time())
|
|
189
|
+
health['cpu_percent'] = proc.cpu_percent(interval=0.1)
|
|
190
|
+
health['memory_mb'] = round(proc.memory_info().rss / 1024 / 1024, 1)
|
|
191
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
192
|
+
health['issue'] = "Cannot access worker process"
|
|
193
|
+
return health
|
|
194
|
+
|
|
195
|
+
# Check heartbeat
|
|
196
|
+
if heartbeat_age is None:
|
|
197
|
+
health['issue'] = "No heartbeat yet (worker may be starting)"
|
|
198
|
+
health['healthy'] = True # Give benefit of doubt for new workers
|
|
199
|
+
elif heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
|
|
200
|
+
health['issue'] = f"Worker unresponsive (heartbeat {int(heartbeat_age)}s old)"
|
|
201
|
+
health['healthy'] = False
|
|
202
|
+
else:
|
|
203
|
+
health['healthy'] = True
|
|
204
|
+
|
|
205
|
+
return health
|