souleyez 2.43.28__py3-none-any.whl → 2.43.32__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 -2
- souleyez/ai/__init__.py +21 -15
- souleyez/ai/action_mapper.py +249 -150
- souleyez/ai/chain_advisor.py +116 -100
- souleyez/ai/claude_provider.py +29 -28
- souleyez/ai/context_builder.py +80 -62
- souleyez/ai/executor.py +158 -117
- souleyez/ai/feedback_handler.py +136 -121
- souleyez/ai/llm_factory.py +27 -20
- souleyez/ai/llm_provider.py +4 -2
- souleyez/ai/ollama_provider.py +6 -9
- souleyez/ai/ollama_service.py +44 -37
- souleyez/ai/path_scorer.py +91 -76
- souleyez/ai/recommender.py +176 -144
- souleyez/ai/report_context.py +74 -73
- souleyez/ai/report_service.py +84 -66
- souleyez/ai/result_parser.py +222 -229
- souleyez/ai/safety.py +67 -44
- souleyez/auth/__init__.py +23 -22
- souleyez/auth/audit.py +36 -26
- souleyez/auth/engagement_access.py +65 -48
- souleyez/auth/permissions.py +14 -3
- souleyez/auth/session_manager.py +54 -37
- souleyez/auth/user_manager.py +109 -64
- souleyez/commands/audit.py +40 -43
- souleyez/commands/auth.py +35 -15
- souleyez/commands/deliverables.py +55 -50
- souleyez/commands/engagement.py +47 -28
- souleyez/commands/license.py +32 -23
- souleyez/commands/screenshots.py +36 -32
- souleyez/commands/user.py +82 -36
- souleyez/config.py +52 -44
- souleyez/core/credential_tester.py +87 -81
- souleyez/core/cve_mappings.py +179 -192
- souleyez/core/cve_matcher.py +162 -148
- souleyez/core/msf_auto_mapper.py +100 -83
- souleyez/core/msf_chain_engine.py +294 -256
- souleyez/core/msf_database.py +153 -70
- souleyez/core/msf_integration.py +679 -673
- souleyez/core/msf_rpc_client.py +40 -42
- souleyez/core/msf_rpc_manager.py +77 -79
- souleyez/core/msf_sync_manager.py +241 -181
- souleyez/core/network_utils.py +22 -15
- souleyez/core/parser_handler.py +34 -25
- souleyez/core/pending_chains.py +114 -63
- souleyez/core/templates.py +158 -107
- souleyez/core/tool_chaining.py +9592 -2879
- souleyez/core/version_utils.py +79 -94
- souleyez/core/vuln_correlation.py +136 -89
- souleyez/core/web_utils.py +33 -32
- souleyez/data/wordlists/ad_users.txt +378 -0
- souleyez/data/wordlists/api_endpoints_large.txt +769 -0
- souleyez/data/wordlists/home_dir_sensitive.txt +39 -0
- souleyez/data/wordlists/lfi_payloads.txt +82 -0
- souleyez/data/wordlists/passwords_brute.txt +1548 -0
- souleyez/data/wordlists/passwords_crack.txt +2479 -0
- souleyez/data/wordlists/passwords_spray.txt +386 -0
- souleyez/data/wordlists/subdomains_large.txt +5057 -0
- souleyez/data/wordlists/usernames_common.txt +694 -0
- souleyez/data/wordlists/web_dirs_large.txt +4769 -0
- souleyez/detection/__init__.py +1 -1
- souleyez/detection/attack_signatures.py +12 -17
- souleyez/detection/mitre_mappings.py +61 -55
- souleyez/detection/validator.py +97 -86
- souleyez/devtools.py +23 -10
- souleyez/docs/README.md +4 -4
- souleyez/docs/api-reference/cli-commands.md +2 -2
- souleyez/docs/developer-guide/adding-new-tools.md +562 -0
- souleyez/docs/user-guide/auto-chaining.md +30 -8
- souleyez/docs/user-guide/getting-started.md +1 -1
- souleyez/docs/user-guide/installation.md +26 -3
- souleyez/docs/user-guide/metasploit-integration.md +2 -2
- souleyez/docs/user-guide/rbac.md +1 -1
- souleyez/docs/user-guide/scope-management.md +1 -1
- souleyez/docs/user-guide/siem-integration.md +1 -1
- souleyez/docs/user-guide/tools-reference.md +1 -8
- souleyez/docs/user-guide/worker-management.md +1 -1
- souleyez/engine/background.py +1238 -535
- souleyez/engine/base.py +4 -1
- souleyez/engine/job_status.py +17 -49
- souleyez/engine/log_sanitizer.py +103 -77
- souleyez/engine/manager.py +38 -7
- souleyez/engine/result_handler.py +2198 -1550
- souleyez/engine/worker_manager.py +50 -41
- souleyez/export/evidence_bundle.py +72 -62
- souleyez/feature_flags/features.py +16 -20
- souleyez/feature_flags.py +5 -9
- souleyez/handlers/__init__.py +11 -0
- souleyez/handlers/base.py +188 -0
- souleyez/handlers/bash_handler.py +277 -0
- souleyez/handlers/bloodhound_handler.py +243 -0
- souleyez/handlers/certipy_handler.py +311 -0
- souleyez/handlers/crackmapexec_handler.py +486 -0
- souleyez/handlers/dnsrecon_handler.py +344 -0
- souleyez/handlers/enum4linux_handler.py +400 -0
- souleyez/handlers/evil_winrm_handler.py +493 -0
- souleyez/handlers/ffuf_handler.py +815 -0
- souleyez/handlers/gobuster_handler.py +1114 -0
- souleyez/handlers/gpp_extract_handler.py +334 -0
- souleyez/handlers/hashcat_handler.py +444 -0
- souleyez/handlers/hydra_handler.py +563 -0
- souleyez/handlers/impacket_getuserspns_handler.py +343 -0
- souleyez/handlers/impacket_psexec_handler.py +222 -0
- souleyez/handlers/impacket_secretsdump_handler.py +426 -0
- souleyez/handlers/john_handler.py +286 -0
- souleyez/handlers/katana_handler.py +425 -0
- souleyez/handlers/kerbrute_handler.py +298 -0
- souleyez/handlers/ldapsearch_handler.py +636 -0
- souleyez/handlers/lfi_extract_handler.py +464 -0
- souleyez/handlers/msf_auxiliary_handler.py +408 -0
- souleyez/handlers/msf_exploit_handler.py +380 -0
- souleyez/handlers/nikto_handler.py +413 -0
- souleyez/handlers/nmap_handler.py +821 -0
- souleyez/handlers/nuclei_handler.py +359 -0
- souleyez/handlers/nxc_handler.py +371 -0
- souleyez/handlers/rdp_sec_check_handler.py +353 -0
- souleyez/handlers/registry.py +288 -0
- souleyez/handlers/responder_handler.py +232 -0
- souleyez/handlers/service_explorer_handler.py +434 -0
- souleyez/handlers/smbclient_handler.py +344 -0
- souleyez/handlers/smbmap_handler.py +510 -0
- souleyez/handlers/smbpasswd_handler.py +296 -0
- souleyez/handlers/sqlmap_handler.py +1116 -0
- souleyez/handlers/theharvester_handler.py +601 -0
- souleyez/handlers/whois_handler.py +277 -0
- souleyez/handlers/wpscan_handler.py +554 -0
- souleyez/history.py +32 -16
- souleyez/importers/msf_importer.py +106 -75
- souleyez/importers/smart_importer.py +208 -147
- souleyez/integrations/siem/__init__.py +10 -10
- souleyez/integrations/siem/base.py +17 -18
- souleyez/integrations/siem/elastic.py +108 -122
- souleyez/integrations/siem/factory.py +207 -80
- souleyez/integrations/siem/googlesecops.py +146 -154
- souleyez/integrations/siem/rule_mappings/__init__.py +1 -1
- souleyez/integrations/siem/rule_mappings/wazuh_rules.py +8 -5
- souleyez/integrations/siem/sentinel.py +107 -109
- souleyez/integrations/siem/splunk.py +246 -212
- souleyez/integrations/siem/wazuh.py +65 -71
- souleyez/integrations/wazuh/__init__.py +5 -5
- souleyez/integrations/wazuh/client.py +70 -93
- souleyez/integrations/wazuh/config.py +85 -57
- souleyez/integrations/wazuh/host_mapper.py +28 -36
- souleyez/integrations/wazuh/sync.py +78 -68
- souleyez/intelligence/__init__.py +4 -5
- souleyez/intelligence/correlation_analyzer.py +309 -295
- souleyez/intelligence/exploit_knowledge.py +661 -623
- souleyez/intelligence/exploit_suggestions.py +159 -139
- souleyez/intelligence/gap_analyzer.py +132 -97
- souleyez/intelligence/gap_detector.py +251 -214
- souleyez/intelligence/sensitive_tables.py +266 -129
- souleyez/intelligence/service_parser.py +137 -123
- souleyez/intelligence/surface_analyzer.py +407 -268
- souleyez/intelligence/target_parser.py +159 -162
- souleyez/licensing/__init__.py +6 -6
- souleyez/licensing/validator.py +17 -19
- souleyez/log_config.py +79 -54
- souleyez/main.py +1505 -687
- souleyez/migrations/fix_job_counter.py +16 -14
- souleyez/parsers/bloodhound_parser.py +41 -39
- souleyez/parsers/crackmapexec_parser.py +178 -111
- souleyez/parsers/dalfox_parser.py +72 -77
- souleyez/parsers/dnsrecon_parser.py +103 -91
- souleyez/parsers/enum4linux_parser.py +183 -153
- souleyez/parsers/ffuf_parser.py +29 -25
- souleyez/parsers/gobuster_parser.py +301 -41
- souleyez/parsers/hashcat_parser.py +324 -79
- souleyez/parsers/http_fingerprint_parser.py +350 -103
- souleyez/parsers/hydra_parser.py +131 -111
- souleyez/parsers/impacket_parser.py +231 -178
- souleyez/parsers/john_parser.py +98 -86
- souleyez/parsers/katana_parser.py +316 -0
- souleyez/parsers/msf_parser.py +943 -498
- souleyez/parsers/nikto_parser.py +346 -65
- souleyez/parsers/nmap_parser.py +262 -174
- souleyez/parsers/nuclei_parser.py +40 -44
- souleyez/parsers/responder_parser.py +26 -26
- souleyez/parsers/searchsploit_parser.py +74 -74
- souleyez/parsers/service_explorer_parser.py +279 -0
- souleyez/parsers/smbmap_parser.py +180 -124
- souleyez/parsers/sqlmap_parser.py +434 -308
- souleyez/parsers/theharvester_parser.py +75 -57
- souleyez/parsers/whois_parser.py +135 -94
- souleyez/parsers/wpscan_parser.py +278 -190
- souleyez/plugins/afp.py +44 -36
- souleyez/plugins/afp_brute.py +114 -46
- souleyez/plugins/ard.py +48 -37
- souleyez/plugins/bloodhound.py +95 -61
- souleyez/plugins/certipy.py +303 -0
- souleyez/plugins/crackmapexec.py +186 -85
- souleyez/plugins/dalfox.py +120 -59
- souleyez/plugins/dns_hijack.py +146 -41
- souleyez/plugins/dnsrecon.py +97 -61
- souleyez/plugins/enum4linux.py +91 -66
- souleyez/plugins/evil_winrm.py +291 -0
- souleyez/plugins/ffuf.py +166 -90
- souleyez/plugins/firmware_extract.py +133 -29
- souleyez/plugins/gobuster.py +387 -190
- souleyez/plugins/gpp_extract.py +393 -0
- souleyez/plugins/hashcat.py +100 -73
- souleyez/plugins/http_fingerprint.py +854 -267
- souleyez/plugins/hydra.py +566 -200
- souleyez/plugins/impacket_getnpusers.py +117 -69
- souleyez/plugins/impacket_psexec.py +84 -64
- souleyez/plugins/impacket_secretsdump.py +103 -69
- souleyez/plugins/impacket_smbclient.py +89 -75
- souleyez/plugins/john.py +86 -69
- souleyez/plugins/katana.py +313 -0
- souleyez/plugins/kerbrute.py +237 -0
- souleyez/plugins/lfi_extract.py +541 -0
- souleyez/plugins/macos_ssh.py +117 -48
- souleyez/plugins/mdns.py +35 -30
- souleyez/plugins/msf_auxiliary.py +253 -130
- souleyez/plugins/msf_exploit.py +239 -161
- souleyez/plugins/nikto.py +134 -78
- souleyez/plugins/nmap.py +275 -91
- souleyez/plugins/nuclei.py +180 -89
- souleyez/plugins/nxc.py +285 -0
- souleyez/plugins/plugin_base.py +35 -36
- souleyez/plugins/plugin_template.py +13 -5
- souleyez/plugins/rdp_sec_check.py +130 -0
- souleyez/plugins/responder.py +112 -71
- souleyez/plugins/router_http_brute.py +76 -65
- souleyez/plugins/router_ssh_brute.py +118 -41
- souleyez/plugins/router_telnet_brute.py +124 -42
- souleyez/plugins/routersploit.py +91 -59
- souleyez/plugins/routersploit_exploit.py +77 -55
- souleyez/plugins/searchsploit.py +91 -77
- souleyez/plugins/service_explorer.py +1160 -0
- souleyez/plugins/smbmap.py +122 -72
- souleyez/plugins/smbpasswd.py +215 -0
- souleyez/plugins/sqlmap.py +301 -113
- souleyez/plugins/theharvester.py +127 -75
- souleyez/plugins/tr069.py +79 -57
- souleyez/plugins/upnp.py +65 -47
- souleyez/plugins/upnp_abuse.py +73 -55
- souleyez/plugins/vnc_access.py +129 -42
- souleyez/plugins/vnc_brute.py +109 -38
- souleyez/plugins/whois.py +77 -58
- souleyez/plugins/wpscan.py +173 -69
- souleyez/reporting/__init__.py +2 -1
- souleyez/reporting/attack_chain.py +411 -346
- souleyez/reporting/charts.py +436 -501
- souleyez/reporting/compliance_mappings.py +334 -201
- souleyez/reporting/detection_report.py +126 -125
- souleyez/reporting/formatters.py +828 -591
- souleyez/reporting/generator.py +386 -302
- souleyez/reporting/metrics.py +72 -75
- souleyez/scanner.py +35 -29
- souleyez/security/__init__.py +37 -11
- souleyez/security/scope_validator.py +175 -106
- souleyez/security/validation.py +223 -149
- souleyez/security.py +22 -6
- souleyez/storage/credentials.py +247 -186
- souleyez/storage/crypto.py +296 -129
- souleyez/storage/database.py +73 -50
- souleyez/storage/db.py +58 -36
- souleyez/storage/deliverable_evidence.py +177 -128
- souleyez/storage/deliverable_exporter.py +282 -246
- souleyez/storage/deliverable_templates.py +134 -116
- souleyez/storage/deliverables.py +135 -130
- souleyez/storage/engagements.py +109 -56
- souleyez/storage/evidence.py +181 -152
- souleyez/storage/execution_log.py +31 -17
- souleyez/storage/exploit_attempts.py +93 -57
- souleyez/storage/exploits.py +67 -36
- souleyez/storage/findings.py +48 -61
- souleyez/storage/hosts.py +176 -144
- souleyez/storage/migrate_to_engagements.py +43 -19
- souleyez/storage/migrations/_001_add_credential_enhancements.py +22 -12
- souleyez/storage/migrations/_002_add_status_tracking.py +10 -7
- souleyez/storage/migrations/_003_add_execution_log.py +14 -8
- souleyez/storage/migrations/_005_screenshots.py +13 -5
- souleyez/storage/migrations/_006_deliverables.py +13 -5
- souleyez/storage/migrations/_007_deliverable_templates.py +12 -7
- souleyez/storage/migrations/_008_add_nuclei_table.py +10 -4
- souleyez/storage/migrations/_010_evidence_linking.py +17 -10
- souleyez/storage/migrations/_011_timeline_tracking.py +20 -13
- souleyez/storage/migrations/_012_team_collaboration.py +34 -21
- souleyez/storage/migrations/_013_add_host_tags.py +12 -6
- souleyez/storage/migrations/_014_exploit_attempts.py +22 -10
- souleyez/storage/migrations/_015_add_mac_os_fields.py +15 -7
- souleyez/storage/migrations/_016_add_domain_field.py +10 -4
- souleyez/storage/migrations/_017_msf_sessions.py +16 -8
- souleyez/storage/migrations/_018_add_osint_target.py +10 -6
- souleyez/storage/migrations/_019_add_engagement_type.py +10 -6
- souleyez/storage/migrations/_020_add_rbac.py +36 -15
- souleyez/storage/migrations/_021_wazuh_integration.py +20 -8
- souleyez/storage/migrations/_022_wazuh_indexer_columns.py +6 -4
- souleyez/storage/migrations/_023_fix_detection_results_fk.py +16 -6
- souleyez/storage/migrations/_024_wazuh_vulnerabilities.py +26 -10
- souleyez/storage/migrations/_025_multi_siem_support.py +3 -5
- souleyez/storage/migrations/_026_add_engagement_scope.py +31 -12
- souleyez/storage/migrations/_027_multi_siem_persistence.py +32 -15
- souleyez/storage/migrations/__init__.py +26 -26
- souleyez/storage/migrations/migration_manager.py +19 -19
- souleyez/storage/msf_sessions.py +100 -65
- souleyez/storage/osint.py +17 -24
- souleyez/storage/recommendation_engine.py +269 -235
- souleyez/storage/screenshots.py +33 -32
- souleyez/storage/smb_shares.py +136 -92
- souleyez/storage/sqlmap_data.py +183 -128
- souleyez/storage/team_collaboration.py +135 -141
- souleyez/storage/timeline_tracker.py +122 -94
- souleyez/storage/wazuh_vulns.py +64 -66
- souleyez/storage/web_paths.py +33 -37
- souleyez/testing/credential_tester.py +221 -205
- souleyez/ui/__init__.py +1 -1
- souleyez/ui/ai_quotes.py +12 -12
- souleyez/ui/attack_surface.py +2439 -1516
- souleyez/ui/chain_rules_view.py +914 -382
- souleyez/ui/correlation_view.py +312 -230
- souleyez/ui/dashboard.py +2382 -1130
- souleyez/ui/deliverables_view.py +148 -62
- souleyez/ui/design_system.py +13 -13
- souleyez/ui/errors.py +49 -49
- souleyez/ui/evidence_linking_view.py +284 -179
- souleyez/ui/evidence_vault.py +393 -285
- souleyez/ui/exploit_suggestions_view.py +555 -349
- souleyez/ui/export_view.py +100 -66
- souleyez/ui/gap_analysis_view.py +315 -171
- souleyez/ui/help_system.py +105 -97
- souleyez/ui/intelligence_view.py +436 -293
- souleyez/ui/interactive.py +23142 -10430
- souleyez/ui/interactive_selector.py +75 -68
- souleyez/ui/log_formatter.py +47 -39
- souleyez/ui/menu_components.py +22 -13
- souleyez/ui/msf_auxiliary_menu.py +184 -133
- souleyez/ui/pending_chains_view.py +336 -172
- souleyez/ui/progress_indicators.py +5 -3
- souleyez/ui/recommendations_view.py +195 -137
- souleyez/ui/rule_builder.py +343 -225
- souleyez/ui/setup_wizard.py +678 -284
- souleyez/ui/shortcuts.py +217 -165
- souleyez/ui/splunk_gap_analysis_view.py +452 -270
- souleyez/ui/splunk_vulns_view.py +139 -86
- souleyez/ui/team_dashboard.py +498 -335
- souleyez/ui/template_selector.py +196 -105
- souleyez/ui/terminal.py +6 -6
- souleyez/ui/timeline_view.py +198 -127
- souleyez/ui/tool_setup.py +264 -164
- souleyez/ui/tutorial.py +202 -72
- souleyez/ui/tutorial_state.py +40 -40
- souleyez/ui/wazuh_vulns_view.py +235 -141
- souleyez/ui/wordlist_browser.py +260 -107
- souleyez/ui.py +464 -312
- souleyez/utils/tool_checker.py +427 -367
- souleyez/utils.py +33 -29
- souleyez/wordlists.py +134 -167
- {souleyez-2.43.28.dist-info → souleyez-2.43.32.dist-info}/METADATA +1 -1
- souleyez-2.43.32.dist-info/RECORD +441 -0
- {souleyez-2.43.28.dist-info → souleyez-2.43.32.dist-info}/WHEEL +1 -1
- souleyez-2.43.28.dist-info/RECORD +0 -379
- {souleyez-2.43.28.dist-info → souleyez-2.43.32.dist-info}/entry_points.txt +0 -0
- {souleyez-2.43.28.dist-info → souleyez-2.43.32.dist-info}/licenses/LICENSE +0 -0
- {souleyez-2.43.28.dist-info → souleyez-2.43.32.dist-info}/top_level.txt +0 -0
souleyez/engine/background.py
CHANGED
|
@@ -14,25 +14,35 @@ Design notes:
|
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import
|
|
17
|
+
|
|
18
|
+
import fcntl
|
|
19
|
+
import inspect
|
|
19
20
|
import json
|
|
20
|
-
import
|
|
21
|
-
import signal
|
|
22
|
-
import tempfile
|
|
21
|
+
import os
|
|
23
22
|
import shutil
|
|
23
|
+
import signal
|
|
24
24
|
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
25
27
|
import threading
|
|
26
|
-
import
|
|
28
|
+
import time
|
|
27
29
|
import traceback
|
|
28
|
-
import
|
|
29
|
-
from typing import
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Any, Dict, List, Optional
|
|
32
|
+
|
|
30
33
|
from souleyez.log_config import get_logger
|
|
31
|
-
|
|
34
|
+
|
|
32
35
|
from .job_status import (
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
STATUS_DONE,
|
|
37
|
+
STATUS_ERROR,
|
|
38
|
+
STATUS_KILLED,
|
|
39
|
+
STATUS_NO_RESULTS,
|
|
40
|
+
STATUS_QUEUED,
|
|
41
|
+
STATUS_RUNNING,
|
|
42
|
+
STATUS_WARNING,
|
|
43
|
+
is_chainable,
|
|
35
44
|
)
|
|
45
|
+
from .log_sanitizer import LogSanitizer
|
|
36
46
|
|
|
37
47
|
logger = get_logger(__name__)
|
|
38
48
|
|
|
@@ -43,15 +53,102 @@ LOGS_DIR = os.path.join(DATA_DIR, "logs")
|
|
|
43
53
|
JOBS_FILE = os.path.join(JOBS_DIR, "jobs.json")
|
|
44
54
|
WORKER_LOG = os.path.join(LOGS_DIR, "worker.log")
|
|
45
55
|
HEARTBEAT_FILE = os.path.join(JOBS_DIR, ".worker_heartbeat")
|
|
56
|
+
JOBS_LOCK_FILE = os.path.join(JOBS_DIR, ".jobs.lock") # Cross-process file lock
|
|
46
57
|
JOB_TIMEOUT_SECONDS = 3600 # 1 hour (changed from 300s/5min)
|
|
47
58
|
HEARTBEAT_INTERVAL = 10 # seconds between heartbeat writes
|
|
48
59
|
HEARTBEAT_STALE_THRESHOLD = 30 # seconds before heartbeat considered stale
|
|
49
60
|
JOB_HUNG_THRESHOLD = 300 # 5 minutes with no output = possibly hung
|
|
50
61
|
JOBS_BACKUP_COUNT = 3 # Number of rotating backups to keep
|
|
62
|
+
MAX_RETRIES = 2 # Maximum auto-retries for transient errors
|
|
63
|
+
|
|
64
|
+
# Patterns indicating transient errors that should trigger auto-retry
|
|
65
|
+
# These are network/timing issues that often succeed on retry
|
|
66
|
+
TRANSIENT_ERROR_PATTERNS = [
|
|
67
|
+
"NetBIOSTimeout",
|
|
68
|
+
"connection timed out",
|
|
69
|
+
"Connection timed out",
|
|
70
|
+
"NETBIOS connection with the remote host timed out",
|
|
71
|
+
"Connection reset by peer",
|
|
72
|
+
"temporarily unavailable",
|
|
73
|
+
"Resource temporarily unavailable",
|
|
74
|
+
"SMBTimeout",
|
|
75
|
+
"timed out while waiting",
|
|
76
|
+
]
|
|
51
77
|
|
|
52
78
|
_lock = threading.RLock() # Reentrant lock allows nested acquisition by same thread
|
|
53
79
|
|
|
54
80
|
|
|
81
|
+
def _is_transient_error(log_content: str) -> bool:
|
|
82
|
+
"""Check if log content indicates a transient error that should be retried."""
|
|
83
|
+
if not log_content:
|
|
84
|
+
return False
|
|
85
|
+
for pattern in TRANSIENT_ERROR_PATTERNS:
|
|
86
|
+
if pattern.lower() in log_content.lower():
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _CrossProcessLock:
|
|
92
|
+
"""
|
|
93
|
+
Cross-process file lock using fcntl.flock().
|
|
94
|
+
|
|
95
|
+
This ensures that only one process (UI or worker) can read/write
|
|
96
|
+
jobs.json at a time, preventing race conditions where one process
|
|
97
|
+
overwrites another's changes.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, lock_file: str, timeout: float = 10.0):
|
|
101
|
+
self.lock_file = lock_file
|
|
102
|
+
self.timeout = timeout
|
|
103
|
+
self._fd = None
|
|
104
|
+
|
|
105
|
+
def __enter__(self):
|
|
106
|
+
import errno
|
|
107
|
+
import fcntl
|
|
108
|
+
|
|
109
|
+
# Ensure lock file directory exists
|
|
110
|
+
os.makedirs(os.path.dirname(self.lock_file), exist_ok=True)
|
|
111
|
+
|
|
112
|
+
# Open lock file (create if doesn't exist)
|
|
113
|
+
self._fd = open(self.lock_file, "w")
|
|
114
|
+
|
|
115
|
+
# Try to acquire lock with timeout
|
|
116
|
+
start_time = time.time()
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
120
|
+
return self # Lock acquired
|
|
121
|
+
except (IOError, OSError) as e:
|
|
122
|
+
if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN):
|
|
123
|
+
raise
|
|
124
|
+
# Lock held by another process, wait and retry
|
|
125
|
+
if time.time() - start_time > self.timeout:
|
|
126
|
+
raise TimeoutError(
|
|
127
|
+
f"Could not acquire lock on {self.lock_file} within {self.timeout}s"
|
|
128
|
+
)
|
|
129
|
+
time.sleep(0.05) # 50ms backoff
|
|
130
|
+
|
|
131
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
132
|
+
import fcntl
|
|
133
|
+
|
|
134
|
+
if self._fd:
|
|
135
|
+
try:
|
|
136
|
+
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
try:
|
|
140
|
+
self._fd.close()
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
self._fd = None
|
|
144
|
+
return False # Don't suppress exceptions
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _jobs_lock():
|
|
148
|
+
"""Get a cross-process lock for jobs.json access."""
|
|
149
|
+
return _CrossProcessLock(JOBS_LOCK_FILE)
|
|
150
|
+
|
|
151
|
+
|
|
55
152
|
def _ensure_dirs():
|
|
56
153
|
os.makedirs(JOBS_DIR, exist_ok=True)
|
|
57
154
|
os.makedirs(LOGS_DIR, exist_ok=True)
|
|
@@ -102,11 +199,13 @@ def _recover_from_backup() -> List[Dict[str, Any]]:
|
|
|
102
199
|
with open(backup_path, "r", encoding="utf-8") as fh:
|
|
103
200
|
jobs = json.load(fh)
|
|
104
201
|
if isinstance(jobs, list):
|
|
105
|
-
_append_worker_log(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
202
|
+
_append_worker_log(
|
|
203
|
+
f"recovered {len(jobs)} jobs from backup: {backup_path}"
|
|
204
|
+
)
|
|
205
|
+
logger.info(
|
|
206
|
+
"Jobs recovered from backup",
|
|
207
|
+
extra={"backup_path": backup_path, "job_count": len(jobs)},
|
|
208
|
+
)
|
|
110
209
|
return jobs
|
|
111
210
|
except Exception as e:
|
|
112
211
|
_append_worker_log(f"backup {backup_path} also corrupt: {e}")
|
|
@@ -115,19 +214,33 @@ def _recover_from_backup() -> List[Dict[str, Any]]:
|
|
|
115
214
|
|
|
116
215
|
|
|
117
216
|
def _read_jobs() -> List[Dict[str, Any]]:
|
|
217
|
+
"""
|
|
218
|
+
Read jobs from jobs.json with cross-process file locking.
|
|
219
|
+
|
|
220
|
+
The file lock ensures we don't read while another process is writing,
|
|
221
|
+
preventing partially-written files from being read.
|
|
222
|
+
"""
|
|
118
223
|
_ensure_dirs()
|
|
119
224
|
if not os.path.exists(JOBS_FILE):
|
|
120
225
|
return []
|
|
121
226
|
try:
|
|
122
|
-
with
|
|
123
|
-
|
|
227
|
+
with _jobs_lock():
|
|
228
|
+
with open(JOBS_FILE, "r", encoding="utf-8") as fh:
|
|
229
|
+
return json.load(fh)
|
|
230
|
+
except TimeoutError:
|
|
231
|
+
# Lock acquisition timed out - log and try without lock
|
|
232
|
+
_append_worker_log("jobs.json lock timeout on read, reading anyway")
|
|
233
|
+
try:
|
|
234
|
+
with open(JOBS_FILE, "r", encoding="utf-8") as fh:
|
|
235
|
+
return json.load(fh)
|
|
236
|
+
except Exception:
|
|
237
|
+
return []
|
|
124
238
|
except Exception as e:
|
|
125
239
|
# Log corruption event
|
|
126
240
|
_append_worker_log(f"jobs.json corrupt: {e}")
|
|
127
|
-
logger.error(
|
|
128
|
-
"error": str(e),
|
|
129
|
-
|
|
130
|
-
})
|
|
241
|
+
logger.error(
|
|
242
|
+
"Jobs file corrupted", extra={"error": str(e), "jobs_file": JOBS_FILE}
|
|
243
|
+
)
|
|
131
244
|
|
|
132
245
|
# Try to recover from backup
|
|
133
246
|
recovered_jobs = _recover_from_backup()
|
|
@@ -143,7 +256,7 @@ def _read_jobs() -> List[Dict[str, Any]]:
|
|
|
143
256
|
# If we recovered jobs, write them back
|
|
144
257
|
if recovered_jobs:
|
|
145
258
|
try:
|
|
146
|
-
|
|
259
|
+
_write_jobs_unlocked(recovered_jobs)
|
|
147
260
|
_append_worker_log(f"restored {len(recovered_jobs)} jobs from backup")
|
|
148
261
|
except Exception as write_err:
|
|
149
262
|
_append_worker_log(f"failed to restore jobs: {write_err}")
|
|
@@ -151,7 +264,19 @@ def _read_jobs() -> List[Dict[str, Any]]:
|
|
|
151
264
|
return recovered_jobs
|
|
152
265
|
|
|
153
266
|
|
|
154
|
-
def
|
|
267
|
+
def _read_jobs_unlocked() -> List[Dict[str, Any]]:
|
|
268
|
+
"""Read jobs without acquiring file lock (for internal use when lock already held)."""
|
|
269
|
+
if not os.path.exists(JOBS_FILE):
|
|
270
|
+
return []
|
|
271
|
+
try:
|
|
272
|
+
with open(JOBS_FILE, "r", encoding="utf-8") as fh:
|
|
273
|
+
return json.load(fh)
|
|
274
|
+
except Exception:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _write_jobs_unlocked(jobs: List[Dict[str, Any]]):
|
|
279
|
+
"""Write jobs without acquiring file lock (for internal use when lock already held)."""
|
|
155
280
|
_ensure_dirs()
|
|
156
281
|
|
|
157
282
|
# Rotate backups before writing (keeps last 3 good copies)
|
|
@@ -167,11 +292,29 @@ def _write_jobs(jobs: List[Dict[str, Any]]):
|
|
|
167
292
|
finally:
|
|
168
293
|
if os.path.exists(tmp.name):
|
|
169
294
|
try:
|
|
170
|
-
os.
|
|
295
|
+
os.unlink(tmp.name)
|
|
171
296
|
except Exception:
|
|
172
297
|
pass
|
|
173
298
|
|
|
174
299
|
|
|
300
|
+
def _write_jobs(jobs: List[Dict[str, Any]]):
|
|
301
|
+
"""
|
|
302
|
+
Write jobs to jobs.json with cross-process file locking.
|
|
303
|
+
|
|
304
|
+
The file lock ensures we don't write while another process is reading
|
|
305
|
+
or writing, preventing race conditions.
|
|
306
|
+
"""
|
|
307
|
+
_ensure_dirs()
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
with _jobs_lock():
|
|
311
|
+
_write_jobs_unlocked(jobs)
|
|
312
|
+
except TimeoutError:
|
|
313
|
+
# Lock acquisition timed out - log and write anyway (better than losing data)
|
|
314
|
+
_append_worker_log("jobs.json lock timeout on write, writing anyway")
|
|
315
|
+
_write_jobs_unlocked(jobs)
|
|
316
|
+
|
|
317
|
+
|
|
175
318
|
def _append_worker_log(msg: str):
|
|
176
319
|
_ensure_dirs()
|
|
177
320
|
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
@@ -184,7 +327,7 @@ def _update_heartbeat():
|
|
|
184
327
|
"""Write current timestamp to heartbeat file for health monitoring."""
|
|
185
328
|
_ensure_dirs()
|
|
186
329
|
try:
|
|
187
|
-
with open(HEARTBEAT_FILE,
|
|
330
|
+
with open(HEARTBEAT_FILE, "w") as fh:
|
|
188
331
|
fh.write(str(time.time()))
|
|
189
332
|
except Exception:
|
|
190
333
|
pass # Non-critical, don't crash worker
|
|
@@ -199,7 +342,7 @@ def get_heartbeat_age() -> Optional[float]:
|
|
|
199
342
|
"""
|
|
200
343
|
try:
|
|
201
344
|
if os.path.exists(HEARTBEAT_FILE):
|
|
202
|
-
with open(HEARTBEAT_FILE,
|
|
345
|
+
with open(HEARTBEAT_FILE, "r") as fh:
|
|
203
346
|
last_beat = float(fh.read().strip())
|
|
204
347
|
return time.time() - last_beat
|
|
205
348
|
return None
|
|
@@ -227,13 +370,13 @@ def _get_process_start_time(pid: int) -> Optional[float]:
|
|
|
227
370
|
if not os.path.exists(stat_path):
|
|
228
371
|
return None
|
|
229
372
|
|
|
230
|
-
with open(stat_path,
|
|
373
|
+
with open(stat_path, "r") as f:
|
|
231
374
|
stat = f.read()
|
|
232
375
|
|
|
233
376
|
# Parse stat file - field 22 is starttime (in clock ticks since boot)
|
|
234
377
|
# Format: pid (comm) state ppid pgrp session tty_nr ... starttime ...
|
|
235
378
|
# Need to handle comm field which may contain spaces/parentheses
|
|
236
|
-
parts = stat.rsplit(
|
|
379
|
+
parts = stat.rsplit(")", 1)
|
|
237
380
|
if len(parts) < 2:
|
|
238
381
|
return None
|
|
239
382
|
|
|
@@ -241,19 +384,21 @@ def _get_process_start_time(pid: int) -> Optional[float]:
|
|
|
241
384
|
if len(fields) < 20:
|
|
242
385
|
return None
|
|
243
386
|
|
|
244
|
-
starttime_ticks = int(
|
|
387
|
+
starttime_ticks = int(
|
|
388
|
+
fields[19]
|
|
389
|
+
) # 0-indexed, field 22 is at index 19 after comm
|
|
245
390
|
|
|
246
391
|
# Convert to timestamp using system boot time and clock ticks per second
|
|
247
|
-
with open(
|
|
392
|
+
with open("/proc/stat", "r") as f:
|
|
248
393
|
for line in f:
|
|
249
|
-
if line.startswith(
|
|
394
|
+
if line.startswith("btime"):
|
|
250
395
|
boot_time = int(line.split()[1])
|
|
251
396
|
break
|
|
252
397
|
else:
|
|
253
398
|
return None
|
|
254
399
|
|
|
255
400
|
# Get clock ticks per second (usually 100)
|
|
256
|
-
ticks_per_sec = os.sysconf(os.sysconf_names[
|
|
401
|
+
ticks_per_sec = os.sysconf(os.sysconf_names["SC_CLK_TCK"])
|
|
257
402
|
|
|
258
403
|
return boot_time + (starttime_ticks / ticks_per_sec)
|
|
259
404
|
except Exception:
|
|
@@ -275,14 +420,14 @@ def _next_job_id(jobs: List[Dict[str, Any]]) -> int:
|
|
|
275
420
|
_ensure_dirs()
|
|
276
421
|
|
|
277
422
|
# Use a separate lock file to allow atomic read-modify-write
|
|
278
|
-
with open(lock_file,
|
|
423
|
+
with open(lock_file, "w") as lock_fh:
|
|
279
424
|
# Acquire exclusive lock (blocks until available)
|
|
280
425
|
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
281
426
|
|
|
282
427
|
try:
|
|
283
428
|
# Read current counter
|
|
284
429
|
if os.path.exists(counter_file):
|
|
285
|
-
with open(counter_file,
|
|
430
|
+
with open(counter_file, "r") as f:
|
|
286
431
|
next_id = int(f.read().strip())
|
|
287
432
|
else:
|
|
288
433
|
# Initialize from existing jobs
|
|
@@ -296,8 +441,8 @@ def _next_job_id(jobs: List[Dict[str, Any]]) -> int:
|
|
|
296
441
|
next_id = maxid + 1
|
|
297
442
|
|
|
298
443
|
# Write incremented counter atomically
|
|
299
|
-
tmp_file = counter_file +
|
|
300
|
-
with open(tmp_file,
|
|
444
|
+
tmp_file = counter_file + ".tmp"
|
|
445
|
+
with open(tmp_file, "w") as f:
|
|
301
446
|
f.write(str(next_id + 1))
|
|
302
447
|
f.flush()
|
|
303
448
|
os.fsync(f.fileno())
|
|
@@ -321,133 +466,235 @@ def _next_job_id(jobs: List[Dict[str, Any]]) -> int:
|
|
|
321
466
|
return maxid + 1
|
|
322
467
|
|
|
323
468
|
|
|
324
|
-
def enqueue_job(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
469
|
+
def enqueue_job(
|
|
470
|
+
tool: str,
|
|
471
|
+
target: str,
|
|
472
|
+
args: List[str],
|
|
473
|
+
label: str = "",
|
|
474
|
+
engagement_id: int = None,
|
|
475
|
+
metadata: Dict[str, Any] = None,
|
|
476
|
+
parent_id: int = None,
|
|
477
|
+
reason: str = None,
|
|
478
|
+
rule_id: int = None,
|
|
479
|
+
skip_scope_check: bool = False,
|
|
480
|
+
) -> int:
|
|
481
|
+
# Prepare data outside lock to minimize lock hold time
|
|
482
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
329
483
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
484
|
+
# Get current engagement if not specified
|
|
485
|
+
if engagement_id is None:
|
|
486
|
+
try:
|
|
487
|
+
from souleyez.storage.engagements import EngagementManager
|
|
488
|
+
|
|
489
|
+
em = EngagementManager()
|
|
490
|
+
current = em.get_current()
|
|
491
|
+
engagement_id = current["id"] if current else None
|
|
492
|
+
except BaseException:
|
|
493
|
+
engagement_id = None
|
|
494
|
+
|
|
495
|
+
# Merge parent_id, reason, and rule_id into metadata
|
|
496
|
+
job_metadata = metadata or {}
|
|
497
|
+
if parent_id is not None:
|
|
498
|
+
job_metadata["parent_id"] = parent_id
|
|
499
|
+
if reason:
|
|
500
|
+
job_metadata["reason"] = reason
|
|
501
|
+
if rule_id is not None:
|
|
502
|
+
job_metadata["rule_id"] = rule_id
|
|
503
|
+
|
|
504
|
+
# Atomic read-modify-write with both thread lock and cross-process file lock
|
|
505
|
+
with _lock: # Thread safety within this process
|
|
506
|
+
try:
|
|
507
|
+
with _jobs_lock(): # Cross-process safety
|
|
508
|
+
_ensure_dirs()
|
|
509
|
+
jobs = _read_jobs_unlocked()
|
|
510
|
+
jid = _next_job_id(jobs)
|
|
511
|
+
|
|
512
|
+
# Scope validation - check if target is within engagement scope
|
|
513
|
+
# Done inside lock because it uses jid for logging
|
|
514
|
+
if not skip_scope_check and engagement_id:
|
|
515
|
+
try:
|
|
516
|
+
from souleyez.security.scope_validator import (
|
|
517
|
+
ScopeValidator,
|
|
518
|
+
ScopeViolationError,
|
|
356
519
|
)
|
|
357
|
-
|
|
358
|
-
validator
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
520
|
+
|
|
521
|
+
validator = ScopeValidator(engagement_id)
|
|
522
|
+
result = validator.validate_target(target)
|
|
523
|
+
enforcement = validator.get_enforcement_mode()
|
|
524
|
+
|
|
525
|
+
if not result.is_in_scope and validator.has_scope_defined():
|
|
526
|
+
if enforcement == "block":
|
|
527
|
+
validator.log_validation(
|
|
528
|
+
target, result, "blocked", job_id=jid
|
|
529
|
+
)
|
|
530
|
+
raise ScopeViolationError(
|
|
531
|
+
f"Target '{target}' is out of scope. {result.reason}"
|
|
532
|
+
)
|
|
533
|
+
elif enforcement == "warn":
|
|
534
|
+
validator.log_validation(
|
|
535
|
+
target, result, "warned", job_id=jid
|
|
536
|
+
)
|
|
537
|
+
if "warnings" not in job_metadata:
|
|
538
|
+
job_metadata["warnings"] = []
|
|
539
|
+
job_metadata["warnings"].append(
|
|
540
|
+
f"SCOPE WARNING: {target} may be out of scope. {result.reason}"
|
|
541
|
+
)
|
|
542
|
+
logger.warning(
|
|
543
|
+
"Out-of-scope target allowed (warn mode)",
|
|
544
|
+
extra={
|
|
545
|
+
"target": target,
|
|
546
|
+
"engagement_id": engagement_id,
|
|
547
|
+
"reason": result.reason,
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
else:
|
|
551
|
+
validator.log_validation(
|
|
552
|
+
target, result, "allowed", job_id=jid
|
|
553
|
+
)
|
|
554
|
+
except ScopeViolationError:
|
|
555
|
+
raise # Re-raise scope violations
|
|
556
|
+
except Exception as e:
|
|
557
|
+
# Don't block jobs if scope validation fails unexpectedly
|
|
558
|
+
logger.warning(
|
|
559
|
+
"Scope validation error (allowing job)",
|
|
560
|
+
extra={"target": target, "error": str(e)},
|
|
363
561
|
)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
})
|
|
369
|
-
else:
|
|
370
|
-
validator.log_validation(target, result, 'allowed', job_id=jid)
|
|
371
|
-
except ScopeViolationError:
|
|
372
|
-
raise # Re-raise scope violations
|
|
373
|
-
except Exception as e:
|
|
374
|
-
# Don't block jobs if scope validation fails unexpectedly
|
|
375
|
-
logger.warning("Scope validation error (allowing job)", extra={
|
|
562
|
+
|
|
563
|
+
job = {
|
|
564
|
+
"id": jid,
|
|
565
|
+
"tool": tool,
|
|
376
566
|
"target": target,
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
567
|
+
"args": args or [],
|
|
568
|
+
"label": label or "",
|
|
569
|
+
"status": STATUS_QUEUED,
|
|
570
|
+
"created_at": now,
|
|
571
|
+
"started_at": None,
|
|
572
|
+
"finished_at": None,
|
|
573
|
+
"result_scan_id": None,
|
|
574
|
+
"error": None,
|
|
575
|
+
"log": os.path.join(JOBS_DIR, f"{jid}.log"),
|
|
576
|
+
"pid": None,
|
|
577
|
+
"engagement_id": engagement_id,
|
|
578
|
+
"chainable": False,
|
|
579
|
+
"chained": False,
|
|
580
|
+
"chained_job_ids": [],
|
|
581
|
+
"chain_error": None,
|
|
582
|
+
"metadata": job_metadata,
|
|
583
|
+
"parent_id": parent_id, # Top-level field for easier querying
|
|
584
|
+
"rule_id": rule_id, # Rule that triggered this job (if auto-chained)
|
|
585
|
+
}
|
|
586
|
+
jobs.append(job)
|
|
587
|
+
_write_jobs_unlocked(jobs)
|
|
588
|
+
except TimeoutError:
|
|
589
|
+
# Lock acquisition timed out - fall back to non-locked operation
|
|
590
|
+
_append_worker_log("jobs.json lock timeout in enqueue_job, using fallback")
|
|
591
|
+
jobs = _read_jobs()
|
|
592
|
+
jid = _next_job_id(jobs)
|
|
593
|
+
|
|
594
|
+
# Scope validation fallback
|
|
595
|
+
if not skip_scope_check and engagement_id:
|
|
596
|
+
try:
|
|
597
|
+
from souleyez.security.scope_validator import (
|
|
598
|
+
ScopeValidator,
|
|
599
|
+
ScopeViolationError,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
validator = ScopeValidator(engagement_id)
|
|
603
|
+
result = validator.validate_target(target)
|
|
604
|
+
enforcement = validator.get_enforcement_mode()
|
|
605
|
+
|
|
606
|
+
if not result.is_in_scope and validator.has_scope_defined():
|
|
607
|
+
if enforcement == "block":
|
|
608
|
+
validator.log_validation(
|
|
609
|
+
target, result, "blocked", job_id=jid
|
|
610
|
+
)
|
|
611
|
+
raise ScopeViolationError(
|
|
612
|
+
f"Target '{target}' is out of scope. {result.reason}"
|
|
613
|
+
)
|
|
614
|
+
elif enforcement == "warn":
|
|
615
|
+
validator.log_validation(
|
|
616
|
+
target, result, "warned", job_id=jid
|
|
617
|
+
)
|
|
618
|
+
if "warnings" not in job_metadata:
|
|
619
|
+
job_metadata["warnings"] = []
|
|
620
|
+
job_metadata["warnings"].append(
|
|
621
|
+
f"SCOPE WARNING: {target} may be out of scope. {result.reason}"
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
validator.log_validation(target, result, "allowed", job_id=jid)
|
|
625
|
+
except ScopeViolationError:
|
|
626
|
+
raise
|
|
627
|
+
except Exception:
|
|
628
|
+
pass
|
|
629
|
+
|
|
630
|
+
job = {
|
|
631
|
+
"id": jid,
|
|
632
|
+
"tool": tool,
|
|
633
|
+
"target": target,
|
|
634
|
+
"args": args or [],
|
|
635
|
+
"label": label or "",
|
|
636
|
+
"status": STATUS_QUEUED,
|
|
637
|
+
"created_at": now,
|
|
638
|
+
"started_at": None,
|
|
639
|
+
"finished_at": None,
|
|
640
|
+
"result_scan_id": None,
|
|
641
|
+
"error": None,
|
|
642
|
+
"log": os.path.join(JOBS_DIR, f"{jid}.log"),
|
|
643
|
+
"pid": None,
|
|
644
|
+
"engagement_id": engagement_id,
|
|
645
|
+
"chainable": False,
|
|
646
|
+
"chained": False,
|
|
647
|
+
"chained_job_ids": [],
|
|
648
|
+
"chain_error": None,
|
|
649
|
+
"metadata": job_metadata,
|
|
650
|
+
"parent_id": parent_id,
|
|
651
|
+
"rule_id": rule_id,
|
|
652
|
+
}
|
|
653
|
+
jobs.append(job)
|
|
654
|
+
_write_jobs(jobs)
|
|
655
|
+
|
|
656
|
+
logger.info(
|
|
657
|
+
"Job enqueued",
|
|
658
|
+
extra={
|
|
659
|
+
"event_type": "job_enqueued",
|
|
660
|
+
"job_id": jid,
|
|
388
661
|
"tool": tool,
|
|
389
662
|
"target": target,
|
|
390
|
-
"args": args or [],
|
|
391
|
-
"label": label or "",
|
|
392
|
-
"status": STATUS_QUEUED,
|
|
393
|
-
"created_at": now,
|
|
394
|
-
"started_at": None,
|
|
395
|
-
"finished_at": None,
|
|
396
|
-
"result_scan_id": None,
|
|
397
|
-
"error": None,
|
|
398
|
-
"log": os.path.join(JOBS_DIR, f"{jid}.log"),
|
|
399
|
-
"pid": None,
|
|
400
663
|
"engagement_id": engagement_id,
|
|
401
|
-
"
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"chain_error": None,
|
|
405
|
-
"metadata": job_metadata,
|
|
406
|
-
"parent_id": parent_id, # Top-level field for easier querying
|
|
407
|
-
"rule_id": rule_id # Rule that triggered this job (if auto-chained)
|
|
408
|
-
}
|
|
409
|
-
jobs.append(job)
|
|
410
|
-
_write_jobs(jobs)
|
|
411
|
-
|
|
412
|
-
logger.info("Job enqueued", extra={
|
|
413
|
-
"event_type": "job_enqueued",
|
|
414
|
-
"job_id": jid,
|
|
415
|
-
"tool": tool,
|
|
416
|
-
"target": target,
|
|
417
|
-
"engagement_id": engagement_id,
|
|
418
|
-
"label": label
|
|
419
|
-
})
|
|
664
|
+
"label": label,
|
|
665
|
+
},
|
|
666
|
+
)
|
|
420
667
|
_append_worker_log(f"enqueued job {jid}: {tool} {target}")
|
|
421
668
|
return jid
|
|
422
669
|
|
|
423
670
|
|
|
424
671
|
def list_jobs(limit: int = 100) -> List[Dict[str, Any]]:
|
|
425
672
|
jobs = _read_jobs()
|
|
426
|
-
# Sort by job ID
|
|
427
|
-
return sorted(jobs, key=lambda x: x.get("id", 0), reverse=
|
|
673
|
+
# Sort by job ID descending (newest first) so limit cuts old jobs, not new ones
|
|
674
|
+
return sorted(jobs, key=lambda x: x.get("id", 0), reverse=True)[:limit]
|
|
428
675
|
|
|
429
676
|
|
|
430
677
|
def get_active_jobs() -> List[Dict[str, Any]]:
|
|
431
678
|
"""Get all running/pending/queued jobs without limit.
|
|
432
|
-
|
|
679
|
+
|
|
433
680
|
Returns jobs sorted with running jobs first, then by ID descending.
|
|
434
681
|
"""
|
|
435
682
|
jobs = _read_jobs()
|
|
436
|
-
active = [j for j in jobs if j.get(
|
|
437
|
-
|
|
683
|
+
active = [j for j in jobs if j.get("status") in ("pending", "running", "queued")]
|
|
684
|
+
|
|
438
685
|
# Sort: running jobs first, then by ID descending (newest first)
|
|
439
686
|
def sort_key(j):
|
|
440
|
-
status = j.get(
|
|
441
|
-
status_priority = 0 if status ==
|
|
442
|
-
job_id = j.get(
|
|
687
|
+
status = j.get("status", "")
|
|
688
|
+
status_priority = 0 if status == "running" else 1
|
|
689
|
+
job_id = j.get("id", 0)
|
|
443
690
|
return (status_priority, -job_id)
|
|
444
|
-
|
|
691
|
+
|
|
445
692
|
return sorted(active, key=sort_key)
|
|
446
693
|
|
|
447
694
|
|
|
448
695
|
def get_all_jobs() -> List[Dict[str, Any]]:
|
|
449
696
|
"""Get ALL jobs without any limit.
|
|
450
|
-
|
|
697
|
+
|
|
451
698
|
Returns jobs sorted by ID descending (newest first).
|
|
452
699
|
"""
|
|
453
700
|
jobs = _read_jobs()
|
|
@@ -476,7 +723,7 @@ def kill_job(jid: int) -> bool:
|
|
|
476
723
|
if not job:
|
|
477
724
|
return False
|
|
478
725
|
|
|
479
|
-
status = job.get(
|
|
726
|
+
status = job.get("status")
|
|
480
727
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
481
728
|
|
|
482
729
|
# Handle queued jobs - just mark as killed
|
|
@@ -493,7 +740,7 @@ def kill_job(jid: int) -> bool:
|
|
|
493
740
|
|
|
494
741
|
# Handle running jobs - send signal
|
|
495
742
|
if status == STATUS_RUNNING:
|
|
496
|
-
pid = job.get(
|
|
743
|
+
pid = job.get("pid")
|
|
497
744
|
if not pid:
|
|
498
745
|
_update_job(jid, status=STATUS_KILLED, finished_at=now)
|
|
499
746
|
return True
|
|
@@ -506,7 +753,8 @@ def kill_job(jid: int) -> bool:
|
|
|
506
753
|
pgid = os.getpgid(pid)
|
|
507
754
|
except ProcessLookupError:
|
|
508
755
|
# Process already dead
|
|
509
|
-
_update_job(jid, status=
|
|
756
|
+
_update_job(jid, status=STATUS_KILLED, finished_at=now, pid=None)
|
|
757
|
+
_append_worker_log(f"job {jid}: process already dead, marked as killed")
|
|
510
758
|
return True
|
|
511
759
|
|
|
512
760
|
# Kill entire process group (parent + all children)
|
|
@@ -515,10 +763,15 @@ def kill_job(jid: int) -> bool:
|
|
|
515
763
|
_append_worker_log(f"job {jid}: sent SIGTERM to process group {pgid}")
|
|
516
764
|
except ProcessLookupError:
|
|
517
765
|
# Process group already dead
|
|
518
|
-
_update_job(jid, status=
|
|
766
|
+
_update_job(jid, status=STATUS_KILLED, finished_at=now, pid=None)
|
|
767
|
+
_append_worker_log(
|
|
768
|
+
f"job {jid}: process group already dead, marked as killed"
|
|
769
|
+
)
|
|
519
770
|
return True
|
|
520
771
|
except PermissionError:
|
|
521
|
-
_append_worker_log(
|
|
772
|
+
_append_worker_log(
|
|
773
|
+
f"job {jid}: permission denied to kill process group {pgid}"
|
|
774
|
+
)
|
|
522
775
|
return False
|
|
523
776
|
|
|
524
777
|
# Wait briefly for graceful termination
|
|
@@ -533,11 +786,13 @@ def kill_job(jid: int) -> bool:
|
|
|
533
786
|
pass # Already dead, good
|
|
534
787
|
|
|
535
788
|
# Update job status
|
|
536
|
-
_update_job(jid, status=
|
|
789
|
+
_update_job(jid, status=STATUS_KILLED, finished_at=now, pid=None)
|
|
790
|
+
_append_worker_log(f"job {jid}: killed successfully")
|
|
537
791
|
return True
|
|
538
792
|
except ProcessLookupError:
|
|
539
793
|
# Process already dead
|
|
540
|
-
_update_job(jid, status=
|
|
794
|
+
_update_job(jid, status=STATUS_KILLED, finished_at=now, pid=None)
|
|
795
|
+
_append_worker_log(f"job {jid}: process already dead, marked as killed")
|
|
541
796
|
return True
|
|
542
797
|
except PermissionError:
|
|
543
798
|
_append_worker_log(f"job {jid}: permission denied to kill PID {pid}")
|
|
@@ -547,6 +802,7 @@ def kill_job(jid: int) -> bool:
|
|
|
547
802
|
return False
|
|
548
803
|
|
|
549
804
|
# Job is in some other state (done, killed, etc.)
|
|
805
|
+
_append_worker_log(f"job {jid}: cannot kill - status is '{status}'")
|
|
550
806
|
return False
|
|
551
807
|
|
|
552
808
|
|
|
@@ -554,32 +810,63 @@ def delete_job(jid: int) -> bool:
|
|
|
554
810
|
"""
|
|
555
811
|
Delete a job from the queue (completed jobs only).
|
|
556
812
|
|
|
813
|
+
Uses atomic read-modify-write with cross-process file locking.
|
|
814
|
+
|
|
557
815
|
Args:
|
|
558
816
|
jid: Job ID to delete
|
|
559
817
|
|
|
560
818
|
Returns:
|
|
561
819
|
True if job was deleted, False if not found or still running
|
|
562
820
|
"""
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
821
|
+
with _lock: # Thread safety within this process
|
|
822
|
+
try:
|
|
823
|
+
with _jobs_lock(): # Cross-process safety
|
|
824
|
+
jobs = _read_jobs_unlocked()
|
|
825
|
+
job = None
|
|
826
|
+
for j in jobs:
|
|
827
|
+
if j.get("id") == jid:
|
|
828
|
+
job = j
|
|
829
|
+
break
|
|
566
830
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return False
|
|
831
|
+
if not job:
|
|
832
|
+
return False
|
|
570
833
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
_write_jobs(jobs)
|
|
834
|
+
# Don't delete running or pending jobs
|
|
835
|
+
if job.get("status") in ("running", "pending"):
|
|
836
|
+
return False
|
|
575
837
|
|
|
576
|
-
|
|
838
|
+
jobs = [j for j in jobs if j.get("id") != jid]
|
|
839
|
+
_write_jobs_unlocked(jobs)
|
|
840
|
+
return True
|
|
841
|
+
except TimeoutError:
|
|
842
|
+
# Fall back to non-locked operation
|
|
843
|
+
_append_worker_log(
|
|
844
|
+
f"jobs.json lock timeout in delete_job for {jid}, using fallback"
|
|
845
|
+
)
|
|
846
|
+
jobs = _read_jobs()
|
|
847
|
+
job = None
|
|
848
|
+
for j in jobs:
|
|
849
|
+
if j.get("id") == jid:
|
|
850
|
+
job = j
|
|
851
|
+
break
|
|
852
|
+
|
|
853
|
+
if not job:
|
|
854
|
+
return False
|
|
855
|
+
|
|
856
|
+
if job.get("status") in ("running", "pending"):
|
|
857
|
+
return False
|
|
858
|
+
|
|
859
|
+
jobs = [j for j in jobs if j.get("id") != jid]
|
|
860
|
+
_write_jobs(jobs)
|
|
861
|
+
return True
|
|
577
862
|
|
|
578
863
|
|
|
579
864
|
def purge_jobs(status_filter: List[str] = None, engagement_id: int = None) -> int:
|
|
580
865
|
"""
|
|
581
866
|
Purge multiple jobs at once based on filters.
|
|
582
867
|
|
|
868
|
+
Uses atomic read-modify-write with cross-process file locking.
|
|
869
|
+
|
|
583
870
|
Args:
|
|
584
871
|
status_filter: List of statuses to purge (e.g., ['done', 'error', 'killed'])
|
|
585
872
|
If None, purges all non-running jobs
|
|
@@ -589,36 +876,46 @@ def purge_jobs(status_filter: List[str] = None, engagement_id: int = None) -> in
|
|
|
589
876
|
Number of jobs purged
|
|
590
877
|
"""
|
|
591
878
|
if status_filter is None:
|
|
592
|
-
status_filter = [
|
|
593
|
-
|
|
594
|
-
with _lock:
|
|
595
|
-
jobs = _read_jobs()
|
|
596
|
-
original_count = len(jobs)
|
|
879
|
+
status_filter = ["done", "error", "killed"]
|
|
597
880
|
|
|
598
|
-
|
|
881
|
+
def _filter_jobs(jobs):
|
|
882
|
+
"""Filter out jobs to keep based on criteria."""
|
|
599
883
|
kept_jobs = []
|
|
600
884
|
for j in jobs:
|
|
601
885
|
# Keep running/pending jobs always
|
|
602
|
-
if j.get(
|
|
886
|
+
if j.get("status") in ("running", "pending"):
|
|
603
887
|
kept_jobs.append(j)
|
|
604
888
|
continue
|
|
605
889
|
|
|
606
890
|
# Keep if status doesn't match filter
|
|
607
|
-
if j.get(
|
|
891
|
+
if j.get("status") not in status_filter:
|
|
608
892
|
kept_jobs.append(j)
|
|
609
893
|
continue
|
|
610
894
|
|
|
611
895
|
# Keep if engagement_id specified and doesn't match
|
|
612
|
-
if engagement_id is not None and j.get(
|
|
896
|
+
if engagement_id is not None and j.get("engagement_id") != engagement_id:
|
|
613
897
|
kept_jobs.append(j)
|
|
614
898
|
continue
|
|
615
899
|
|
|
616
900
|
# Otherwise, purge this job (don't add to kept_jobs)
|
|
901
|
+
return kept_jobs
|
|
617
902
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
903
|
+
with _lock: # Thread safety within this process
|
|
904
|
+
try:
|
|
905
|
+
with _jobs_lock(): # Cross-process safety
|
|
906
|
+
jobs = _read_jobs_unlocked()
|
|
907
|
+
original_count = len(jobs)
|
|
908
|
+
kept_jobs = _filter_jobs(jobs)
|
|
909
|
+
_write_jobs_unlocked(kept_jobs)
|
|
910
|
+
return original_count - len(kept_jobs)
|
|
911
|
+
except TimeoutError:
|
|
912
|
+
# Fall back to non-locked operation
|
|
913
|
+
_append_worker_log("jobs.json lock timeout in purge_jobs, using fallback")
|
|
914
|
+
jobs = _read_jobs()
|
|
915
|
+
original_count = len(jobs)
|
|
916
|
+
kept_jobs = _filter_jobs(jobs)
|
|
917
|
+
_write_jobs(kept_jobs)
|
|
918
|
+
return original_count - len(kept_jobs)
|
|
622
919
|
|
|
623
920
|
|
|
624
921
|
def purge_all_jobs() -> int:
|
|
@@ -629,12 +926,15 @@ def purge_all_jobs() -> int:
|
|
|
629
926
|
Returns:
|
|
630
927
|
Number of jobs purged
|
|
631
928
|
"""
|
|
632
|
-
return purge_jobs(status_filter=[
|
|
929
|
+
return purge_jobs(status_filter=["done", "error", "killed"])
|
|
633
930
|
|
|
634
931
|
|
|
635
932
|
def _update_job(jid: int, respect_killed: bool = True, **fields):
|
|
636
933
|
"""
|
|
637
|
-
Update job fields atomically.
|
|
934
|
+
Update job fields atomically with cross-process locking.
|
|
935
|
+
|
|
936
|
+
Uses both threading lock (for same-process safety) and file lock
|
|
937
|
+
(for cross-process safety) to ensure atomic read-modify-write.
|
|
638
938
|
|
|
639
939
|
Args:
|
|
640
940
|
jid: Job ID to update
|
|
@@ -642,29 +942,63 @@ def _update_job(jid: int, respect_killed: bool = True, **fields):
|
|
|
642
942
|
This prevents race condition where job is killed while completing.
|
|
643
943
|
**fields: Fields to update
|
|
644
944
|
"""
|
|
645
|
-
with _lock:
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
945
|
+
with _lock: # Thread safety within this process
|
|
946
|
+
try:
|
|
947
|
+
with _jobs_lock(): # Cross-process safety
|
|
948
|
+
# Read directly without going through _read_jobs (we already have lock)
|
|
949
|
+
_ensure_dirs()
|
|
950
|
+
jobs = []
|
|
951
|
+
if os.path.exists(JOBS_FILE):
|
|
952
|
+
try:
|
|
953
|
+
with open(JOBS_FILE, "r", encoding="utf-8") as fh:
|
|
954
|
+
jobs = json.load(fh)
|
|
955
|
+
except Exception:
|
|
956
|
+
jobs = []
|
|
957
|
+
|
|
958
|
+
changed = False
|
|
959
|
+
for j in jobs:
|
|
960
|
+
if j.get("id") == jid:
|
|
961
|
+
# Race condition protection: don't change status of killed jobs
|
|
962
|
+
if (
|
|
963
|
+
respect_killed
|
|
964
|
+
and j.get("status") == STATUS_KILLED
|
|
965
|
+
and "status" in fields
|
|
966
|
+
):
|
|
967
|
+
# Job was killed - don't overwrite status, but allow other updates
|
|
968
|
+
fields_copy = dict(fields)
|
|
969
|
+
del fields_copy["status"]
|
|
970
|
+
if fields_copy:
|
|
971
|
+
j.update(fields_copy)
|
|
972
|
+
changed = True
|
|
973
|
+
logger.debug(
|
|
974
|
+
"Skipped status update for killed job",
|
|
975
|
+
extra={
|
|
976
|
+
"job_id": jid,
|
|
977
|
+
"attempted_status": fields.get("status"),
|
|
978
|
+
},
|
|
979
|
+
)
|
|
980
|
+
else:
|
|
981
|
+
j.update(fields)
|
|
982
|
+
changed = True
|
|
983
|
+
break
|
|
984
|
+
|
|
985
|
+
if changed:
|
|
986
|
+
# Write directly without going through _write_jobs (we already have lock)
|
|
987
|
+
_write_jobs_unlocked(jobs)
|
|
988
|
+
except TimeoutError:
|
|
989
|
+
# Fall back to non-locked operation (better than failing)
|
|
990
|
+
_append_worker_log(
|
|
991
|
+
f"jobs.json lock timeout updating job {jid}, using fallback"
|
|
992
|
+
)
|
|
993
|
+
jobs = _read_jobs()
|
|
994
|
+
changed = False
|
|
995
|
+
for j in jobs:
|
|
996
|
+
if j.get("id") == jid:
|
|
663
997
|
j.update(fields)
|
|
664
998
|
changed = True
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
999
|
+
break
|
|
1000
|
+
if changed:
|
|
1001
|
+
_write_jobs(jobs)
|
|
668
1002
|
|
|
669
1003
|
|
|
670
1004
|
def _process_pending_chains():
|
|
@@ -685,83 +1019,139 @@ def _process_pending_chains():
|
|
|
685
1019
|
try:
|
|
686
1020
|
jobs = _read_jobs()
|
|
687
1021
|
|
|
1022
|
+
# Cleanup: Mark jobs stuck in "chaining in progress" for too long (> 5 min) as failed
|
|
1023
|
+
CHAIN_TIMEOUT_SECONDS = 300 # 5 minutes
|
|
1024
|
+
now = datetime.now(timezone.utc)
|
|
1025
|
+
for j in jobs:
|
|
1026
|
+
chaining_started = j.get("chaining_started_at")
|
|
1027
|
+
if chaining_started and not j.get("chained", False):
|
|
1028
|
+
try:
|
|
1029
|
+
started_at = datetime.fromisoformat(
|
|
1030
|
+
chaining_started.replace("Z", "+00:00")
|
|
1031
|
+
)
|
|
1032
|
+
if (now - started_at).total_seconds() > CHAIN_TIMEOUT_SECONDS:
|
|
1033
|
+
jid = j.get("id")
|
|
1034
|
+
_append_worker_log(
|
|
1035
|
+
f"job {jid}: chaining timed out after {CHAIN_TIMEOUT_SECONDS}s, marking as failed"
|
|
1036
|
+
)
|
|
1037
|
+
_update_job(
|
|
1038
|
+
jid,
|
|
1039
|
+
chained=True,
|
|
1040
|
+
chain_error="Chaining timed out",
|
|
1041
|
+
chaining_started_at=None,
|
|
1042
|
+
)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass # Ignore parse errors
|
|
1045
|
+
|
|
688
1046
|
# Find jobs ready for chaining
|
|
689
1047
|
# Include jobs with chainable statuses: done, no_results, warning
|
|
1048
|
+
# Skip jobs that are currently being chained (chaining_started_at is set)
|
|
690
1049
|
chainable_jobs = [
|
|
691
|
-
j
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
and
|
|
1050
|
+
j
|
|
1051
|
+
for j in jobs
|
|
1052
|
+
if j.get("chainable", False) == True
|
|
1053
|
+
and j.get("chained", False) == False
|
|
1054
|
+
and is_chainable(j.get("status", ""))
|
|
1055
|
+
and not j.get("chaining_started_at") # Skip if already being processed
|
|
695
1056
|
]
|
|
696
1057
|
|
|
697
1058
|
if not chainable_jobs:
|
|
698
1059
|
return 0 # Nothing to process
|
|
699
1060
|
|
|
700
1061
|
# Sort by created_at (process oldest first - FIFO)
|
|
701
|
-
chainable_jobs.sort(key=lambda x: x.get(
|
|
1062
|
+
chainable_jobs.sort(key=lambda x: x.get("created_at", ""))
|
|
702
1063
|
job_to_chain = chainable_jobs[0]
|
|
703
1064
|
|
|
704
|
-
jid = job_to_chain[
|
|
705
|
-
tool = job_to_chain.get(
|
|
1065
|
+
jid = job_to_chain["id"]
|
|
1066
|
+
tool = job_to_chain.get("tool", "unknown")
|
|
706
1067
|
|
|
707
1068
|
_append_worker_log(f"processing chains for job {jid} ({tool})")
|
|
708
|
-
logger.info(
|
|
709
|
-
"
|
|
710
|
-
"tool": tool,
|
|
711
|
-
|
|
712
|
-
|
|
1069
|
+
logger.info(
|
|
1070
|
+
"Processing chainable job",
|
|
1071
|
+
extra={"job_id": jid, "tool": tool, "queue_depth": len(chainable_jobs)},
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# Mark job as chaining in progress BEFORE starting (prevents retry loop if auto_chain hangs)
|
|
1075
|
+
chaining_start = datetime.now(timezone.utc).isoformat()
|
|
1076
|
+
_update_job(jid, chaining_started_at=chaining_start)
|
|
713
1077
|
|
|
714
1078
|
try:
|
|
715
1079
|
from souleyez.core.tool_chaining import ToolChaining
|
|
1080
|
+
|
|
716
1081
|
chaining = ToolChaining()
|
|
717
1082
|
|
|
718
1083
|
if not chaining.is_enabled():
|
|
719
1084
|
# Chaining was disabled after job marked as chainable
|
|
720
|
-
_update_job(jid, chained=True)
|
|
1085
|
+
_update_job(jid, chained=True, chaining_started_at=None)
|
|
721
1086
|
_append_worker_log(f"job {jid}: chaining now disabled, skipping")
|
|
722
1087
|
return 1
|
|
723
1088
|
|
|
724
1089
|
# Get parse results from job
|
|
725
|
-
parse_result = job_to_chain.get(
|
|
1090
|
+
parse_result = job_to_chain.get("parse_result", {})
|
|
726
1091
|
|
|
727
1092
|
if not parse_result:
|
|
728
1093
|
# No parse results - this shouldn't happen if job was properly marked chainable
|
|
729
1094
|
# Log warning and store reason for debugging
|
|
730
|
-
logger.warning(
|
|
731
|
-
"
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1095
|
+
logger.warning(
|
|
1096
|
+
"Job marked chainable but has no parse_result",
|
|
1097
|
+
extra={
|
|
1098
|
+
"job_id": jid,
|
|
1099
|
+
"tool": tool,
|
|
1100
|
+
"status": job_to_chain.get("status"),
|
|
1101
|
+
},
|
|
1102
|
+
)
|
|
1103
|
+
_append_worker_log(
|
|
1104
|
+
f"job {jid}: WARNING - marked chainable but parse_result is empty/missing"
|
|
1105
|
+
)
|
|
1106
|
+
_update_job(
|
|
1107
|
+
jid,
|
|
1108
|
+
chained=True,
|
|
1109
|
+
chain_skip_reason="parse_result missing",
|
|
1110
|
+
chaining_started_at=None,
|
|
1111
|
+
)
|
|
737
1112
|
return 1
|
|
738
1113
|
|
|
739
|
-
if
|
|
1114
|
+
if "error" in parse_result:
|
|
740
1115
|
# Parse had an error - log and skip
|
|
741
|
-
logger.warning(
|
|
742
|
-
"
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1116
|
+
logger.warning(
|
|
1117
|
+
"Job has parse error, skipping chaining",
|
|
1118
|
+
extra={
|
|
1119
|
+
"job_id": jid,
|
|
1120
|
+
"tool": tool,
|
|
1121
|
+
"parse_error": parse_result.get("error"),
|
|
1122
|
+
},
|
|
1123
|
+
)
|
|
1124
|
+
_append_worker_log(
|
|
1125
|
+
f"job {jid}: parse error '{parse_result.get('error')}', skipping chain"
|
|
1126
|
+
)
|
|
1127
|
+
_update_job(
|
|
1128
|
+
jid,
|
|
1129
|
+
chained=True,
|
|
1130
|
+
chain_skip_reason=f"parse_error: {parse_result.get('error')}",
|
|
1131
|
+
chaining_started_at=None,
|
|
1132
|
+
)
|
|
748
1133
|
return 1
|
|
749
1134
|
|
|
750
1135
|
# Process auto-chaining
|
|
751
1136
|
chained_job_ids = chaining.auto_chain(job_to_chain, parse_result)
|
|
752
1137
|
|
|
753
|
-
# Update job with chaining results
|
|
754
|
-
_update_job(
|
|
1138
|
+
# Update job with chaining results (clear chaining_started_at)
|
|
1139
|
+
_update_job(
|
|
1140
|
+
jid,
|
|
755
1141
|
chained=True,
|
|
756
|
-
chained_job_ids=chained_job_ids or []
|
|
1142
|
+
chained_job_ids=chained_job_ids or [],
|
|
1143
|
+
chaining_started_at=None,
|
|
757
1144
|
)
|
|
758
1145
|
|
|
759
1146
|
if chained_job_ids:
|
|
760
|
-
logger.info(
|
|
761
|
-
"
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1147
|
+
logger.info(
|
|
1148
|
+
"Auto-chaining completed",
|
|
1149
|
+
extra={
|
|
1150
|
+
"job_id": jid,
|
|
1151
|
+
"chained_jobs": chained_job_ids,
|
|
1152
|
+
"count": len(chained_job_ids),
|
|
1153
|
+
},
|
|
1154
|
+
)
|
|
765
1155
|
_append_worker_log(
|
|
766
1156
|
f"job {jid}: created {len(chained_job_ids)} chained job(s): {chained_job_ids}"
|
|
767
1157
|
)
|
|
@@ -773,29 +1163,33 @@ def _process_pending_chains():
|
|
|
773
1163
|
except Exception as chain_err:
|
|
774
1164
|
# Chaining failed - mark as chained with error to prevent retry loops
|
|
775
1165
|
error_msg = str(chain_err)
|
|
776
|
-
logger.error(
|
|
777
|
-
"
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1166
|
+
logger.error(
|
|
1167
|
+
"Auto-chaining failed",
|
|
1168
|
+
extra={
|
|
1169
|
+
"job_id": jid,
|
|
1170
|
+
"error": error_msg,
|
|
1171
|
+
"traceback": traceback.format_exc(),
|
|
1172
|
+
},
|
|
1173
|
+
)
|
|
781
1174
|
_append_worker_log(f"job {jid} chain error: {error_msg}")
|
|
782
|
-
_update_job(
|
|
783
|
-
chained=True,
|
|
784
|
-
chain_error=error_msg
|
|
1175
|
+
_update_job(
|
|
1176
|
+
jid, chained=True, chain_error=error_msg, chaining_started_at=None
|
|
785
1177
|
)
|
|
786
1178
|
return 1 # Still count as processed (with error)
|
|
787
1179
|
|
|
788
1180
|
except Exception as e:
|
|
789
1181
|
# Unexpected error in chain processor itself
|
|
790
|
-
logger.error(
|
|
791
|
-
"error"
|
|
792
|
-
"traceback": traceback.format_exc()
|
|
793
|
-
|
|
1182
|
+
logger.error(
|
|
1183
|
+
"Chain processor error",
|
|
1184
|
+
extra={"error": str(e), "traceback": traceback.format_exc()},
|
|
1185
|
+
)
|
|
794
1186
|
_append_worker_log(f"chain processor error: {e}")
|
|
795
1187
|
return 0
|
|
796
1188
|
|
|
797
1189
|
|
|
798
|
-
def _try_run_plugin(
|
|
1190
|
+
def _try_run_plugin(
|
|
1191
|
+
tool: str, target: str, args: List[str], label: str, log_path: str, jid: int = None
|
|
1192
|
+
) -> tuple:
|
|
799
1193
|
try:
|
|
800
1194
|
from .loader import discover_plugins
|
|
801
1195
|
|
|
@@ -827,21 +1221,29 @@ def _try_run_plugin(tool: str, target: str, args: List[str], label: str, log_pat
|
|
|
827
1221
|
fh.write(f"Target: {target}\n")
|
|
828
1222
|
fh.write(f"Args: {args}\n")
|
|
829
1223
|
fh.write(f"Label: {label}\n")
|
|
830
|
-
fh.write(
|
|
831
|
-
|
|
1224
|
+
fh.write(
|
|
1225
|
+
f"Started: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n\n"
|
|
1226
|
+
)
|
|
1227
|
+
|
|
832
1228
|
# Build command specification
|
|
833
|
-
cmd_spec = build_command_method(
|
|
834
|
-
|
|
1229
|
+
cmd_spec = build_command_method(
|
|
1230
|
+
target, args or [], label or "", log_path
|
|
1231
|
+
)
|
|
1232
|
+
|
|
835
1233
|
if cmd_spec is None:
|
|
836
1234
|
# build_command returned None - check if this is a deliberate abort
|
|
837
1235
|
# (e.g., gobuster detected host redirect and aborted to avoid wasted scan)
|
|
838
1236
|
if os.path.exists(log_path):
|
|
839
|
-
with open(
|
|
1237
|
+
with open(
|
|
1238
|
+
log_path, "r", encoding="utf-8", errors="replace"
|
|
1239
|
+
) as fh:
|
|
840
1240
|
log_content = fh.read()
|
|
841
|
-
if
|
|
1241
|
+
if "HOST_REDIRECT_TARGET:" in log_content:
|
|
842
1242
|
# Plugin aborted due to host redirect - don't fall through to run()
|
|
843
1243
|
# Return success (0) so parser can set WARNING status and trigger retry
|
|
844
|
-
_append_worker_log(
|
|
1244
|
+
_append_worker_log(
|
|
1245
|
+
f"job {jid}: gobuster aborted due to host redirect"
|
|
1246
|
+
)
|
|
845
1247
|
return (True, 0)
|
|
846
1248
|
|
|
847
1249
|
# Otherwise check if plugin has run() method
|
|
@@ -854,7 +1256,9 @@ def _try_run_plugin(tool: str, target: str, args: List[str], label: str, log_pat
|
|
|
854
1256
|
|
|
855
1257
|
try:
|
|
856
1258
|
if "log_path" in params:
|
|
857
|
-
rc = run_method(
|
|
1259
|
+
rc = run_method(
|
|
1260
|
+
target, args or [], label or "", log_path
|
|
1261
|
+
)
|
|
858
1262
|
elif "label" in params:
|
|
859
1263
|
rc = run_method(target, args or [], label or "")
|
|
860
1264
|
elif "args" in params:
|
|
@@ -863,30 +1267,38 @@ def _try_run_plugin(tool: str, target: str, args: List[str], label: str, log_pat
|
|
|
863
1267
|
rc = run_method(target)
|
|
864
1268
|
return (True, rc if isinstance(rc, int) else 0)
|
|
865
1269
|
except Exception as e:
|
|
866
|
-
with open(
|
|
1270
|
+
with open(
|
|
1271
|
+
log_path, "a", encoding="utf-8", errors="replace"
|
|
1272
|
+
) as fh:
|
|
867
1273
|
fh.write(f"\n=== PLUGIN RUN ERROR ===\n")
|
|
868
1274
|
fh.write(f"{type(e).__name__}: {e}\n")
|
|
869
1275
|
fh.write(f"\n{traceback.format_exc()}\n")
|
|
870
1276
|
return (True, 1)
|
|
871
1277
|
else:
|
|
872
1278
|
# No run() method either - actual validation failure
|
|
873
|
-
with open(
|
|
874
|
-
|
|
1279
|
+
with open(
|
|
1280
|
+
log_path, "a", encoding="utf-8", errors="replace"
|
|
1281
|
+
) as fh:
|
|
1282
|
+
fh.write(
|
|
1283
|
+
"ERROR: Plugin validation failed (build_command returned None)\n"
|
|
1284
|
+
)
|
|
875
1285
|
return (True, 1)
|
|
876
|
-
|
|
1286
|
+
|
|
877
1287
|
# Execute using new subprocess handler with PID tracking
|
|
878
|
-
rc = _run_subprocess_with_spec(
|
|
1288
|
+
rc = _run_subprocess_with_spec(
|
|
1289
|
+
cmd_spec, log_path, jid=jid, plugin=plugin
|
|
1290
|
+
)
|
|
879
1291
|
|
|
880
1292
|
# Completion message already written by _run_subprocess_with_spec
|
|
881
1293
|
return (True, rc)
|
|
882
|
-
|
|
1294
|
+
|
|
883
1295
|
except Exception as e:
|
|
884
1296
|
with open(log_path, "a", encoding="utf-8", errors="replace") as fh:
|
|
885
1297
|
fh.write("\n=== PLUGIN ERROR ===\n")
|
|
886
1298
|
fh.write(f"{type(e).__name__}: {e}\n")
|
|
887
1299
|
fh.write(f"\n{traceback.format_exc()}\n")
|
|
888
1300
|
return (True, 1)
|
|
889
|
-
|
|
1301
|
+
|
|
890
1302
|
# FALLBACK: Use old run() method for backward compatibility
|
|
891
1303
|
run_method = getattr(plugin, "run", None)
|
|
892
1304
|
if not callable(run_method):
|
|
@@ -900,24 +1312,36 @@ def _try_run_plugin(tool: str, target: str, args: List[str], label: str, log_pat
|
|
|
900
1312
|
fh.write(f"Target: {target}\n")
|
|
901
1313
|
fh.write(f"Args: {args}\n")
|
|
902
1314
|
fh.write(f"Label: {label}\n")
|
|
903
|
-
fh.write(
|
|
1315
|
+
fh.write(
|
|
1316
|
+
f"Started: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n\n"
|
|
1317
|
+
)
|
|
904
1318
|
|
|
905
1319
|
try:
|
|
906
|
-
if
|
|
1320
|
+
if "log_path" in params or len(params) >= 4:
|
|
907
1321
|
rc = run_method(target, args or [], label or "", log_path)
|
|
908
1322
|
else:
|
|
909
1323
|
result = run_method(target, args or [], label or "")
|
|
910
1324
|
|
|
911
1325
|
if isinstance(result, tuple) and len(result) >= 2:
|
|
912
1326
|
rc, old_logpath = result[0], result[1]
|
|
913
|
-
if
|
|
1327
|
+
if (
|
|
1328
|
+
old_logpath
|
|
1329
|
+
and os.path.exists(old_logpath)
|
|
1330
|
+
and old_logpath != log_path
|
|
1331
|
+
):
|
|
914
1332
|
try:
|
|
915
|
-
with open(
|
|
916
|
-
|
|
1333
|
+
with open(
|
|
1334
|
+
old_logpath, "r", encoding="utf-8", errors="replace"
|
|
1335
|
+
) as src:
|
|
1336
|
+
with open(
|
|
1337
|
+
log_path, "a", encoding="utf-8", errors="replace"
|
|
1338
|
+
) as dst:
|
|
917
1339
|
dst.write("\n=== Plugin Output ===\n")
|
|
918
1340
|
dst.write(src.read())
|
|
919
1341
|
except Exception as e:
|
|
920
|
-
with open(
|
|
1342
|
+
with open(
|
|
1343
|
+
log_path, "a", encoding="utf-8", errors="replace"
|
|
1344
|
+
) as fh:
|
|
921
1345
|
fh.write(f"\nWarning: Could not copy old log: {e}\n")
|
|
922
1346
|
elif isinstance(result, int):
|
|
923
1347
|
rc = result
|
|
@@ -941,7 +1365,9 @@ def _try_run_plugin(tool: str, target: str, args: List[str], label: str, log_pat
|
|
|
941
1365
|
return (False, 0)
|
|
942
1366
|
|
|
943
1367
|
|
|
944
|
-
def _run_rpc_exploit(
|
|
1368
|
+
def _run_rpc_exploit(
|
|
1369
|
+
cmd_spec: Dict[str, Any], log_path: str, jid: int = None, plugin=None
|
|
1370
|
+
) -> int:
|
|
945
1371
|
"""
|
|
946
1372
|
Execute MSF exploit via RPC mode (Pro feature).
|
|
947
1373
|
|
|
@@ -964,10 +1390,10 @@ def _run_rpc_exploit(cmd_spec: Dict[str, Any], log_path: str, jid: int = None, p
|
|
|
964
1390
|
Returns:
|
|
965
1391
|
Exit code (0 = success with session, non-zero = failure)
|
|
966
1392
|
"""
|
|
967
|
-
exploit_path = cmd_spec.get(
|
|
968
|
-
target = cmd_spec.get(
|
|
969
|
-
options = cmd_spec.get(
|
|
970
|
-
payload = cmd_spec.get(
|
|
1393
|
+
exploit_path = cmd_spec.get("exploit_path")
|
|
1394
|
+
target = cmd_spec.get("target")
|
|
1395
|
+
options = cmd_spec.get("options", {})
|
|
1396
|
+
payload = cmd_spec.get("payload")
|
|
971
1397
|
|
|
972
1398
|
_append_worker_log(f"job {jid}: RPC mode exploit - {exploit_path}")
|
|
973
1399
|
|
|
@@ -975,6 +1401,7 @@ def _run_rpc_exploit(cmd_spec: Dict[str, Any], log_path: str, jid: int = None, p
|
|
|
975
1401
|
if plugin is None:
|
|
976
1402
|
try:
|
|
977
1403
|
from souleyez.plugins.msf_exploit import MsfExploitPlugin
|
|
1404
|
+
|
|
978
1405
|
plugin = MsfExploitPlugin()
|
|
979
1406
|
except Exception as e:
|
|
980
1407
|
with open(log_path, "a", encoding="utf-8", errors="replace") as fh:
|
|
@@ -987,12 +1414,12 @@ def _run_rpc_exploit(cmd_spec: Dict[str, Any], log_path: str, jid: int = None, p
|
|
|
987
1414
|
target=target,
|
|
988
1415
|
options=options,
|
|
989
1416
|
log_path=log_path,
|
|
990
|
-
payload=payload
|
|
1417
|
+
payload=payload,
|
|
991
1418
|
)
|
|
992
1419
|
|
|
993
|
-
if result.get(
|
|
994
|
-
session_id = result.get(
|
|
995
|
-
session_info = result.get(
|
|
1420
|
+
if result.get("success"):
|
|
1421
|
+
session_id = result.get("session_id")
|
|
1422
|
+
session_info = result.get("session_info", {})
|
|
996
1423
|
|
|
997
1424
|
# Store session in database
|
|
998
1425
|
try:
|
|
@@ -1001,40 +1428,46 @@ def _run_rpc_exploit(cmd_spec: Dict[str, Any], log_path: str, jid: int = None, p
|
|
|
1001
1428
|
_append_worker_log(f"job {jid}: failed to store session: {e}")
|
|
1002
1429
|
|
|
1003
1430
|
# Update job with session info
|
|
1004
|
-
session_type = session_info.get(
|
|
1431
|
+
session_type = session_info.get("type", "shell")
|
|
1005
1432
|
_update_job(
|
|
1006
1433
|
jid,
|
|
1007
1434
|
exploitation_detected=True,
|
|
1008
|
-
session_info=f"Session {session_id} ({session_type})"
|
|
1435
|
+
session_info=f"Session {session_id} ({session_type})",
|
|
1009
1436
|
)
|
|
1010
1437
|
|
|
1011
1438
|
return 0
|
|
1012
|
-
elif result.get(
|
|
1439
|
+
elif result.get("no_session"):
|
|
1013
1440
|
# Exploit ran but no session opened - this is "no results", not an error
|
|
1014
1441
|
# Return 1 but let parser set status to no_results
|
|
1015
|
-
reason = result.get(
|
|
1442
|
+
reason = result.get("reason", "No session opened")
|
|
1016
1443
|
_append_worker_log(f"job {jid}: exploit completed - {reason}")
|
|
1017
1444
|
return 1
|
|
1018
1445
|
else:
|
|
1019
1446
|
# True error (connection failed, RPC error, etc.)
|
|
1020
|
-
error = result.get(
|
|
1447
|
+
error = result.get("error", "Unknown error")
|
|
1021
1448
|
_append_worker_log(f"job {jid}: RPC exploit failed - {error}")
|
|
1022
1449
|
return 1
|
|
1023
1450
|
|
|
1024
1451
|
|
|
1025
|
-
def _store_msf_session(
|
|
1452
|
+
def _store_msf_session(
|
|
1453
|
+
jid: int,
|
|
1454
|
+
target: str,
|
|
1455
|
+
exploit_path: str,
|
|
1456
|
+
session_id: str,
|
|
1457
|
+
session_info: Dict[str, Any],
|
|
1458
|
+
):
|
|
1026
1459
|
"""Store MSF session in database."""
|
|
1027
1460
|
try:
|
|
1028
|
-
from souleyez.storage.msf_sessions import add_msf_session
|
|
1029
1461
|
from souleyez.storage.database import get_db
|
|
1030
1462
|
from souleyez.storage.hosts import HostManager
|
|
1463
|
+
from souleyez.storage.msf_sessions import add_msf_session
|
|
1031
1464
|
|
|
1032
1465
|
# Get job info for engagement_id
|
|
1033
1466
|
job = get_job(jid)
|
|
1034
1467
|
if not job:
|
|
1035
1468
|
return
|
|
1036
1469
|
|
|
1037
|
-
engagement_id = job.get(
|
|
1470
|
+
engagement_id = job.get("engagement_id")
|
|
1038
1471
|
if not engagement_id:
|
|
1039
1472
|
return
|
|
1040
1473
|
|
|
@@ -1044,7 +1477,7 @@ def _store_msf_session(jid: int, target: str, exploit_path: str, session_id: str
|
|
|
1044
1477
|
|
|
1045
1478
|
hm = HostManager()
|
|
1046
1479
|
host = hm.get_host_by_ip(engagement_id, target)
|
|
1047
|
-
host_id = host[
|
|
1480
|
+
host_id = host["id"] if host else None
|
|
1048
1481
|
|
|
1049
1482
|
if host_id:
|
|
1050
1483
|
add_msf_session(
|
|
@@ -1052,15 +1485,15 @@ def _store_msf_session(jid: int, target: str, exploit_path: str, session_id: str
|
|
|
1052
1485
|
engagement_id=engagement_id,
|
|
1053
1486
|
host_id=host_id,
|
|
1054
1487
|
msf_session_id=int(session_id),
|
|
1055
|
-
session_type=session_info.get(
|
|
1488
|
+
session_type=session_info.get("type"),
|
|
1056
1489
|
via_exploit=exploit_path,
|
|
1057
|
-
via_payload=session_info.get(
|
|
1058
|
-
platform=session_info.get(
|
|
1059
|
-
arch=session_info.get(
|
|
1060
|
-
username=session_info.get(
|
|
1061
|
-
port=session_info.get(
|
|
1062
|
-
tunnel_peer=session_info.get(
|
|
1063
|
-
notes=f"Created by job #{jid}"
|
|
1490
|
+
via_payload=session_info.get("via_payload"),
|
|
1491
|
+
platform=session_info.get("platform"),
|
|
1492
|
+
arch=session_info.get("arch"),
|
|
1493
|
+
username=session_info.get("username"),
|
|
1494
|
+
port=session_info.get("target_port"),
|
|
1495
|
+
tunnel_peer=session_info.get("tunnel_peer"),
|
|
1496
|
+
notes=f"Created by job #{jid}",
|
|
1064
1497
|
)
|
|
1065
1498
|
conn.commit()
|
|
1066
1499
|
|
|
@@ -1079,7 +1512,7 @@ def _is_stdbuf_available() -> bool:
|
|
|
1079
1512
|
"""Check if stdbuf is available for line-buffered output."""
|
|
1080
1513
|
global _stdbuf_available
|
|
1081
1514
|
if _stdbuf_available is None:
|
|
1082
|
-
_stdbuf_available = shutil.which(
|
|
1515
|
+
_stdbuf_available = shutil.which("stdbuf") is not None
|
|
1083
1516
|
return _stdbuf_available
|
|
1084
1517
|
|
|
1085
1518
|
|
|
@@ -1102,7 +1535,7 @@ def _wrap_cmd_for_line_buffering(cmd: List[str]) -> List[str]:
|
|
|
1102
1535
|
|
|
1103
1536
|
if _is_stdbuf_available():
|
|
1104
1537
|
# stdbuf -oL = line-buffered stdout, -eL = line-buffered stderr
|
|
1105
|
-
return [
|
|
1538
|
+
return ["stdbuf", "-oL", "-eL"] + cmd
|
|
1106
1539
|
|
|
1107
1540
|
return cmd
|
|
1108
1541
|
|
|
@@ -1115,12 +1548,14 @@ def _get_subprocess_env() -> Dict[str, str]:
|
|
|
1115
1548
|
to prevent interactive terminal issues.
|
|
1116
1549
|
"""
|
|
1117
1550
|
env = os.environ.copy()
|
|
1118
|
-
env[
|
|
1119
|
-
env[
|
|
1551
|
+
env["TERM"] = "dumb" # Prevent stty errors from interactive tools
|
|
1552
|
+
env["PYTHONUNBUFFERED"] = "1" # Disable Python output buffering
|
|
1120
1553
|
return env
|
|
1121
1554
|
|
|
1122
1555
|
|
|
1123
|
-
def _run_subprocess_with_spec(
|
|
1556
|
+
def _run_subprocess_with_spec(
|
|
1557
|
+
cmd_spec: Dict[str, Any], log_path: str, jid: int = None, plugin=None
|
|
1558
|
+
) -> int:
|
|
1124
1559
|
"""
|
|
1125
1560
|
Execute a command specification with proper PID tracking.
|
|
1126
1561
|
|
|
@@ -1153,19 +1588,19 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1153
1588
|
Exit code (0 = success, non-zero = failure)
|
|
1154
1589
|
"""
|
|
1155
1590
|
# Check for RPC mode (Pro feature)
|
|
1156
|
-
if cmd_spec.get(
|
|
1591
|
+
if cmd_spec.get("mode") == "rpc":
|
|
1157
1592
|
return _run_rpc_exploit(cmd_spec, log_path, jid, plugin)
|
|
1158
1593
|
|
|
1159
|
-
cmd = cmd_spec.get(
|
|
1594
|
+
cmd = cmd_spec.get("cmd")
|
|
1160
1595
|
if not cmd:
|
|
1161
1596
|
with open(log_path, "a", encoding="utf-8", errors="replace") as fh:
|
|
1162
1597
|
fh.write("ERROR: No command provided in spec\n")
|
|
1163
1598
|
return 1
|
|
1164
1599
|
|
|
1165
|
-
timeout = cmd_spec.get(
|
|
1166
|
-
spec_env = cmd_spec.get(
|
|
1167
|
-
cwd = cmd_spec.get(
|
|
1168
|
-
needs_shell = cmd_spec.get(
|
|
1600
|
+
timeout = cmd_spec.get("timeout", JOB_TIMEOUT_SECONDS)
|
|
1601
|
+
spec_env = cmd_spec.get("env")
|
|
1602
|
+
cwd = cmd_spec.get("cwd")
|
|
1603
|
+
needs_shell = cmd_spec.get("needs_shell", False)
|
|
1169
1604
|
|
|
1170
1605
|
_append_worker_log(f"_run_subprocess_with_spec: timeout={timeout}s for job {jid}")
|
|
1171
1606
|
|
|
@@ -1187,7 +1622,9 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1187
1622
|
fh.write(f"Environment: {spec_env}\n")
|
|
1188
1623
|
if cwd:
|
|
1189
1624
|
fh.write(f"Working Dir: {cwd}\n")
|
|
1190
|
-
fh.write(
|
|
1625
|
+
fh.write(
|
|
1626
|
+
f"Started: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n\n"
|
|
1627
|
+
)
|
|
1191
1628
|
fh.flush()
|
|
1192
1629
|
|
|
1193
1630
|
try:
|
|
@@ -1201,9 +1638,9 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1201
1638
|
preexec_fn=os.setsid, # Creates new session
|
|
1202
1639
|
env=proc_env,
|
|
1203
1640
|
cwd=cwd,
|
|
1204
|
-
shell=needs_shell # nosec B602 - intentional for security tool command execution
|
|
1641
|
+
shell=needs_shell, # nosec B602 - intentional for security tool command execution
|
|
1205
1642
|
)
|
|
1206
|
-
|
|
1643
|
+
|
|
1207
1644
|
# Store PID and process start time for stale detection
|
|
1208
1645
|
if jid is not None:
|
|
1209
1646
|
proc_start_time = _get_process_start_time(proc.pid)
|
|
@@ -1225,19 +1662,31 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1225
1662
|
# For MSF exploits, check if a session was opened before timeout
|
|
1226
1663
|
# A timeout with an open session is success, not failure
|
|
1227
1664
|
session_opened = False
|
|
1228
|
-
if hasattr(plugin,
|
|
1665
|
+
if hasattr(plugin, "tool") and plugin.tool in (
|
|
1666
|
+
"msf_exploit",
|
|
1667
|
+
"msf_auxiliary",
|
|
1668
|
+
):
|
|
1229
1669
|
try:
|
|
1230
1670
|
fh.flush()
|
|
1231
|
-
with open(
|
|
1671
|
+
with open(
|
|
1672
|
+
log_path, "r", encoding="utf-8", errors="replace"
|
|
1673
|
+
) as rf:
|
|
1232
1674
|
content = rf.read()
|
|
1233
1675
|
import re
|
|
1234
|
-
|
|
1676
|
+
|
|
1677
|
+
session_opened = bool(
|
|
1678
|
+
re.search(r"session \d+ opened", content, re.IGNORECASE)
|
|
1679
|
+
)
|
|
1235
1680
|
except Exception:
|
|
1236
1681
|
pass
|
|
1237
1682
|
|
|
1238
1683
|
if session_opened:
|
|
1239
|
-
fh.write(
|
|
1240
|
-
|
|
1684
|
+
fh.write(
|
|
1685
|
+
f"\n[*] Session opened successfully (timeout expected - session is active)\n"
|
|
1686
|
+
)
|
|
1687
|
+
fh.write(
|
|
1688
|
+
f"=== Completed: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())} ===\n"
|
|
1689
|
+
)
|
|
1241
1690
|
return 0
|
|
1242
1691
|
else:
|
|
1243
1692
|
fh.write(f"\nERROR: Command timed out after {timeout} seconds\n")
|
|
@@ -1247,7 +1696,7 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1247
1696
|
# Check if job was killed externally during execution
|
|
1248
1697
|
if jid is not None:
|
|
1249
1698
|
job = get_job(jid)
|
|
1250
|
-
if job and job.get(
|
|
1699
|
+
if job and job.get("status") == "killed":
|
|
1251
1700
|
fh.write(f"\nINFO: Job was killed externally\n")
|
|
1252
1701
|
# Process may already be dead, but ensure cleanup
|
|
1253
1702
|
try:
|
|
@@ -1267,9 +1716,12 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1267
1716
|
fh.flush()
|
|
1268
1717
|
return 143 # 128 + 15 (SIGTERM)
|
|
1269
1718
|
|
|
1270
|
-
fh.write(
|
|
1719
|
+
fh.write(
|
|
1720
|
+
f"\n=== Completed: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())} ===\n"
|
|
1721
|
+
)
|
|
1271
1722
|
fh.write(f"Exit Code: {proc.returncode}\n")
|
|
1272
1723
|
fh.flush()
|
|
1724
|
+
os.fsync(fh.fileno()) # Ensure data is on disk before parsing
|
|
1273
1725
|
return proc.returncode
|
|
1274
1726
|
|
|
1275
1727
|
except FileNotFoundError:
|
|
@@ -1282,7 +1734,14 @@ def _run_subprocess_with_spec(cmd_spec: Dict[str, Any], log_path: str, jid: int
|
|
|
1282
1734
|
return 1
|
|
1283
1735
|
|
|
1284
1736
|
|
|
1285
|
-
def _run_subprocess(
|
|
1737
|
+
def _run_subprocess(
|
|
1738
|
+
tool: str,
|
|
1739
|
+
target: str,
|
|
1740
|
+
args: List[str],
|
|
1741
|
+
log_path: str,
|
|
1742
|
+
jid: int = None,
|
|
1743
|
+
timeout: int = None,
|
|
1744
|
+
) -> int:
|
|
1286
1745
|
# Use None as default and resolve at runtime to avoid Python's early binding issue
|
|
1287
1746
|
if timeout is None:
|
|
1288
1747
|
timeout = JOB_TIMEOUT_SECONDS
|
|
@@ -1298,11 +1757,13 @@ def _run_subprocess(tool: str, target: str, args: List[str], log_path: str, jid:
|
|
|
1298
1757
|
|
|
1299
1758
|
with open(log_path, "a", encoding="utf-8", errors="replace") as fh:
|
|
1300
1759
|
# Log original command (without stdbuf wrapper for clarity)
|
|
1301
|
-
original_cmd = cmd[3:] if cmd[:3] == [
|
|
1760
|
+
original_cmd = cmd[3:] if cmd[:3] == ["stdbuf", "-oL", "-eL"] else cmd
|
|
1302
1761
|
fh.write("=== Subprocess Execution ===\n")
|
|
1303
1762
|
fh.write(f"Command: {' '.join(original_cmd)}\n")
|
|
1304
1763
|
fh.write(f"Timeout: {timeout} seconds\n")
|
|
1305
|
-
fh.write(
|
|
1764
|
+
fh.write(
|
|
1765
|
+
f"Started: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n\n"
|
|
1766
|
+
)
|
|
1306
1767
|
fh.flush()
|
|
1307
1768
|
|
|
1308
1769
|
try:
|
|
@@ -1317,7 +1778,7 @@ def _run_subprocess(tool: str, target: str, args: List[str], log_path: str, jid:
|
|
|
1317
1778
|
stdout=fh,
|
|
1318
1779
|
stderr=subprocess.STDOUT,
|
|
1319
1780
|
preexec_fn=os.setsid, # Creates new session
|
|
1320
|
-
env=env
|
|
1781
|
+
env=env,
|
|
1321
1782
|
)
|
|
1322
1783
|
|
|
1323
1784
|
# Store PID and process start time for stale detection
|
|
@@ -1344,7 +1805,7 @@ def _run_subprocess(tool: str, target: str, args: List[str], log_path: str, jid:
|
|
|
1344
1805
|
# Check if job was killed externally during execution
|
|
1345
1806
|
if jid is not None:
|
|
1346
1807
|
job = get_job(jid)
|
|
1347
|
-
if job and job.get(
|
|
1808
|
+
if job and job.get("status") == "killed":
|
|
1348
1809
|
fh.write(f"\nINFO: Job was killed externally\n")
|
|
1349
1810
|
# Process may already be dead, but ensure cleanup
|
|
1350
1811
|
try:
|
|
@@ -1364,9 +1825,12 @@ def _run_subprocess(tool: str, target: str, args: List[str], log_path: str, jid:
|
|
|
1364
1825
|
fh.flush()
|
|
1365
1826
|
return 143 # 128 + 15 (SIGTERM)
|
|
1366
1827
|
|
|
1367
|
-
fh.write(
|
|
1828
|
+
fh.write(
|
|
1829
|
+
f"\n=== Completed: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())} ===\n"
|
|
1830
|
+
)
|
|
1368
1831
|
fh.write(f"Exit Code: {proc.returncode}\n")
|
|
1369
1832
|
fh.flush()
|
|
1833
|
+
os.fsync(fh.fileno()) # Ensure data is on disk before parsing
|
|
1370
1834
|
return proc.returncode
|
|
1371
1835
|
|
|
1372
1836
|
except FileNotFoundError:
|
|
@@ -1407,7 +1871,22 @@ def _is_true_error_exit_code(rc: int, tool: str) -> bool:
|
|
|
1407
1871
|
# msf_exploit returns 1 when no session opened (exploit ran but target not vulnerable)
|
|
1408
1872
|
# nikto returns non-zero when it finds vulnerabilities (not an error!)
|
|
1409
1873
|
# dnsrecon returns 1 when crt.sh lookup fails (known bug) but still collects valid DNS data
|
|
1410
|
-
|
|
1874
|
+
# evil_winrm returns non-zero even on successful auth - let handler parse output
|
|
1875
|
+
# bloodhound exits non-zero on connection errors but still collects AD data
|
|
1876
|
+
# hashcat returns 1 when exhausted (no passwords cracked) - not an error, just no results
|
|
1877
|
+
# bash web_login_test scripts return 1 when credentials fail - not an error, just invalid creds
|
|
1878
|
+
tools_with_nonzero_success = [
|
|
1879
|
+
"gobuster",
|
|
1880
|
+
"hydra",
|
|
1881
|
+
"medusa",
|
|
1882
|
+
"msf_exploit",
|
|
1883
|
+
"nikto",
|
|
1884
|
+
"dnsrecon",
|
|
1885
|
+
"evil_winrm",
|
|
1886
|
+
"bloodhound",
|
|
1887
|
+
"hashcat",
|
|
1888
|
+
"bash",
|
|
1889
|
+
]
|
|
1411
1890
|
|
|
1412
1891
|
if tool.lower() in tools_with_nonzero_success:
|
|
1413
1892
|
# Let parser determine status
|
|
@@ -1418,11 +1897,76 @@ def _is_true_error_exit_code(rc: int, tool: str) -> bool:
|
|
|
1418
1897
|
|
|
1419
1898
|
|
|
1420
1899
|
def run_job(jid: int) -> None:
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1900
|
+
"""
|
|
1901
|
+
Run a job by its ID.
|
|
1902
|
+
|
|
1903
|
+
Uses atomic status transition with cross-process file locking to prevent
|
|
1904
|
+
race conditions with kill/delete and other processes (UI).
|
|
1905
|
+
If job is not in QUEUED status when we try to start it, we abort.
|
|
1906
|
+
"""
|
|
1907
|
+
# Atomically check status and transition to RUNNING
|
|
1908
|
+
# Both thread lock and file lock ensure no other process/thread can
|
|
1909
|
+
# read/write jobs.json while we're modifying it
|
|
1910
|
+
with _lock: # Thread safety within this process
|
|
1911
|
+
try:
|
|
1912
|
+
with _jobs_lock(): # Cross-process safety
|
|
1913
|
+
jobs = _read_jobs_unlocked()
|
|
1914
|
+
job = None
|
|
1915
|
+
for j in jobs:
|
|
1916
|
+
if j.get("id") == jid:
|
|
1917
|
+
job = j
|
|
1918
|
+
break
|
|
1919
|
+
|
|
1920
|
+
if not job:
|
|
1921
|
+
logger.error("Job not found", extra={"job_id": jid})
|
|
1922
|
+
_append_worker_log(f"run_job: job {jid} not found")
|
|
1923
|
+
return
|
|
1924
|
+
|
|
1925
|
+
current_status = job.get("status")
|
|
1926
|
+
if current_status != STATUS_QUEUED:
|
|
1927
|
+
# Job was killed, deleted, or already running - abort
|
|
1928
|
+
logger.info(
|
|
1929
|
+
"Job not in queued status, skipping",
|
|
1930
|
+
extra={"job_id": jid, "current_status": current_status},
|
|
1931
|
+
)
|
|
1932
|
+
_append_worker_log(
|
|
1933
|
+
f"run_job: job {jid} not queued (status={current_status}), skipping"
|
|
1934
|
+
)
|
|
1935
|
+
return
|
|
1936
|
+
|
|
1937
|
+
# Atomically set to RUNNING while still holding both locks
|
|
1938
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1939
|
+
job["status"] = STATUS_RUNNING
|
|
1940
|
+
job["started_at"] = now
|
|
1941
|
+
_write_jobs_unlocked(jobs)
|
|
1942
|
+
except TimeoutError:
|
|
1943
|
+
# Fall back to non-locked operation
|
|
1944
|
+
_append_worker_log(
|
|
1945
|
+
f"jobs.json lock timeout in run_job for {jid}, using fallback"
|
|
1946
|
+
)
|
|
1947
|
+
jobs = _read_jobs()
|
|
1948
|
+
job = None
|
|
1949
|
+
for j in jobs:
|
|
1950
|
+
if j.get("id") == jid:
|
|
1951
|
+
job = j
|
|
1952
|
+
break
|
|
1953
|
+
|
|
1954
|
+
if not job:
|
|
1955
|
+
logger.error("Job not found", extra={"job_id": jid})
|
|
1956
|
+
_append_worker_log(f"run_job: job {jid} not found")
|
|
1957
|
+
return
|
|
1958
|
+
|
|
1959
|
+
current_status = job.get("status")
|
|
1960
|
+
if current_status != STATUS_QUEUED:
|
|
1961
|
+
_append_worker_log(
|
|
1962
|
+
f"run_job: job {jid} not queued (status={current_status}), skipping"
|
|
1963
|
+
)
|
|
1964
|
+
return
|
|
1965
|
+
|
|
1966
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1967
|
+
job["status"] = STATUS_RUNNING
|
|
1968
|
+
job["started_at"] = now
|
|
1969
|
+
_write_jobs(jobs)
|
|
1426
1970
|
|
|
1427
1971
|
log_path = job.get("log") or os.path.join(JOBS_DIR, f"{jid}.log")
|
|
1428
1972
|
_ensure_dirs()
|
|
@@ -1430,18 +1974,18 @@ def run_job(jid: int) -> None:
|
|
|
1430
1974
|
log_dir = os.path.dirname(log_path)
|
|
1431
1975
|
if not os.path.exists(log_dir):
|
|
1432
1976
|
os.makedirs(log_dir, exist_ok=True)
|
|
1433
|
-
|
|
1434
|
-
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1435
|
-
_update_job(jid, status=STATUS_RUNNING, started_at=now)
|
|
1436
1977
|
_append_worker_log(f"job {jid} started: {job.get('tool')} {job.get('target')}")
|
|
1437
1978
|
|
|
1438
|
-
logger.info(
|
|
1439
|
-
"
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1979
|
+
logger.info(
|
|
1980
|
+
"Job started",
|
|
1981
|
+
extra={
|
|
1982
|
+
"event_type": "job_started",
|
|
1983
|
+
"job_id": jid,
|
|
1984
|
+
"tool": job.get("tool"),
|
|
1985
|
+
"target": job.get("target"),
|
|
1986
|
+
"engagement_id": job.get("engagement_id"),
|
|
1987
|
+
},
|
|
1988
|
+
)
|
|
1445
1989
|
|
|
1446
1990
|
try:
|
|
1447
1991
|
tool = job.get("tool", "")
|
|
@@ -1452,27 +1996,33 @@ def run_job(jid: int) -> None:
|
|
|
1452
1996
|
# Resolve wordlist paths to actual filesystem locations
|
|
1453
1997
|
try:
|
|
1454
1998
|
from ..wordlists import resolve_args_wordlists
|
|
1999
|
+
|
|
1455
2000
|
args = resolve_args_wordlists(args)
|
|
1456
2001
|
except ImportError:
|
|
1457
2002
|
pass # Wordlists module not available, use args as-is
|
|
1458
2003
|
|
|
1459
2004
|
start_time = time.perf_counter()
|
|
1460
|
-
plugin_executed, rc = _try_run_plugin(
|
|
2005
|
+
plugin_executed, rc = _try_run_plugin(
|
|
2006
|
+
tool, target, args, label, log_path, jid=jid
|
|
2007
|
+
)
|
|
1461
2008
|
|
|
1462
2009
|
if not plugin_executed:
|
|
1463
|
-
_append_worker_log(
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2010
|
+
_append_worker_log(
|
|
2011
|
+
f"job {jid}: no plugin found for '{tool}', using subprocess"
|
|
2012
|
+
)
|
|
2013
|
+
logger.info(
|
|
2014
|
+
"Using subprocess fallback", extra={"job_id": jid, "tool": tool}
|
|
2015
|
+
)
|
|
1468
2016
|
rc = _run_subprocess(tool, target, args, log_path, jid=jid)
|
|
1469
2017
|
|
|
1470
2018
|
# Check if job was killed externally while we were running
|
|
1471
2019
|
job = get_job(jid)
|
|
1472
|
-
job_killed = job and job.get(
|
|
2020
|
+
job_killed = job and job.get("status") == "killed"
|
|
1473
2021
|
|
|
1474
2022
|
if job_killed:
|
|
1475
|
-
_append_worker_log(
|
|
2023
|
+
_append_worker_log(
|
|
2024
|
+
f"job {jid}: detected external kill signal, skipping post-processing"
|
|
2025
|
+
)
|
|
1476
2026
|
logger.info("Job was killed externally", extra={"job_id": jid})
|
|
1477
2027
|
|
|
1478
2028
|
# ALWAYS update status, finished_at, and pid - even if job was killed
|
|
@@ -1493,22 +2043,80 @@ def run_job(jid: int) -> None:
|
|
|
1493
2043
|
|
|
1494
2044
|
_update_job(jid, status=status, finished_at=now, pid=None)
|
|
1495
2045
|
|
|
1496
|
-
logger.info(
|
|
1497
|
-
"
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
2046
|
+
logger.info(
|
|
2047
|
+
"Job completed",
|
|
2048
|
+
extra={
|
|
2049
|
+
"event_type": "job_completed",
|
|
2050
|
+
"job_id": jid,
|
|
2051
|
+
"status": status,
|
|
2052
|
+
"exit_code": rc,
|
|
2053
|
+
"duration_ms": round(duration_ms, 2),
|
|
2054
|
+
},
|
|
2055
|
+
)
|
|
1503
2056
|
|
|
1504
2057
|
# Only do post-processing if job was not killed externally
|
|
1505
2058
|
if job_killed:
|
|
1506
2059
|
_append_worker_log(f"job {jid} finished: status={status} rc={rc}")
|
|
1507
2060
|
return
|
|
1508
2061
|
|
|
2062
|
+
# Check for transient errors and auto-retry
|
|
2063
|
+
job = get_job(jid)
|
|
2064
|
+
retry_count = job.get("metadata", {}).get("retry_count", 0)
|
|
2065
|
+
if retry_count < MAX_RETRIES:
|
|
2066
|
+
# Read log to check for transient errors
|
|
2067
|
+
# Note: Check even when rc==0 because tools like nxc may exit 0 but log errors
|
|
2068
|
+
log_path = job.get("log", "")
|
|
2069
|
+
if log_path and os.path.exists(log_path):
|
|
2070
|
+
try:
|
|
2071
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
2072
|
+
log_content = f.read()
|
|
2073
|
+
if _is_transient_error(log_content):
|
|
2074
|
+
# Transient error detected - auto-retry
|
|
2075
|
+
logger.info(
|
|
2076
|
+
"Transient error detected, auto-retrying job",
|
|
2077
|
+
extra={"job_id": jid, "retry_count": retry_count + 1},
|
|
2078
|
+
)
|
|
2079
|
+
_append_worker_log(
|
|
2080
|
+
f"job {jid}: transient error detected, auto-retry {retry_count + 1}/{MAX_RETRIES}"
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
# Build new job metadata with incremented retry count
|
|
2084
|
+
new_metadata = job.get("metadata", {}).copy()
|
|
2085
|
+
new_metadata["retry_count"] = retry_count + 1
|
|
2086
|
+
new_metadata["retried_from"] = jid
|
|
2087
|
+
|
|
2088
|
+
# Enqueue retry job
|
|
2089
|
+
retry_jid = enqueue_job(
|
|
2090
|
+
tool=job.get("tool"),
|
|
2091
|
+
target=job.get("target"),
|
|
2092
|
+
args=job.get("args", []),
|
|
2093
|
+
label=job.get("label", ""),
|
|
2094
|
+
engagement_id=job.get("engagement_id"),
|
|
2095
|
+
metadata=new_metadata,
|
|
2096
|
+
parent_id=job.get("metadata", {}).get("parent_id"),
|
|
2097
|
+
reason=f"Auto-retry {retry_count + 1}/{MAX_RETRIES} (transient error)",
|
|
2098
|
+
rule_id=job.get("metadata", {}).get("rule_id"),
|
|
2099
|
+
skip_scope_check=True, # Already validated on first run
|
|
2100
|
+
)
|
|
2101
|
+
_append_worker_log(
|
|
2102
|
+
f"job {jid}: retry enqueued as job #{retry_jid}"
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
# Mark original job as retried (not error)
|
|
2106
|
+
_update_job(
|
|
2107
|
+
jid,
|
|
2108
|
+
status=STATUS_WARNING,
|
|
2109
|
+
chained=True, # Prevent chaining from failed job
|
|
2110
|
+
parse_result={"note": f"Retried as job #{retry_jid}"},
|
|
2111
|
+
)
|
|
2112
|
+
return
|
|
2113
|
+
except Exception as e:
|
|
2114
|
+
logger.warning(f"Failed to check for transient errors: {e}")
|
|
2115
|
+
|
|
1509
2116
|
# Try to parse results into database
|
|
1510
2117
|
try:
|
|
1511
2118
|
from .result_handler import handle_job_result
|
|
2119
|
+
|
|
1512
2120
|
# Re-fetch job to get updated data
|
|
1513
2121
|
job = get_job(jid)
|
|
1514
2122
|
parse_result = handle_job_result(job)
|
|
@@ -1516,23 +2124,41 @@ def run_job(jid: int) -> None:
|
|
|
1516
2124
|
# Handle parse failure cases
|
|
1517
2125
|
if parse_result is None:
|
|
1518
2126
|
# Parser returned None - likely missing log file, no parser for tool, or missing engagement
|
|
1519
|
-
logger.
|
|
1520
|
-
"
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
2127
|
+
logger.warning(
|
|
2128
|
+
"Job parse returned None - no parser for this tool",
|
|
2129
|
+
extra={
|
|
2130
|
+
"job_id": jid,
|
|
2131
|
+
"tool": job.get("tool"),
|
|
2132
|
+
"log_exists": (
|
|
2133
|
+
os.path.exists(job.get("log", ""))
|
|
2134
|
+
if job.get("log")
|
|
2135
|
+
else False
|
|
2136
|
+
),
|
|
2137
|
+
},
|
|
2138
|
+
)
|
|
2139
|
+
_append_worker_log(
|
|
2140
|
+
f"job {jid} parse returned None (tool={job.get('tool')}) - check if parser exists"
|
|
2141
|
+
)
|
|
2142
|
+
# Only update status to WARNING if it wasn't already an ERROR
|
|
2143
|
+
# (e.g., exit code 127 = command not found should stay as ERROR)
|
|
2144
|
+
current_status = job.get("status")
|
|
2145
|
+
if current_status != STATUS_ERROR:
|
|
2146
|
+
_update_job(
|
|
2147
|
+
jid,
|
|
2148
|
+
status=STATUS_WARNING,
|
|
2149
|
+
parse_result={
|
|
2150
|
+
"error": "Parser returned None - no results extracted"
|
|
2151
|
+
},
|
|
2152
|
+
)
|
|
1527
2153
|
# Mark as chained to prevent infinite retry
|
|
1528
2154
|
_update_job(jid, chained=True)
|
|
1529
2155
|
return
|
|
1530
2156
|
|
|
1531
|
-
if
|
|
1532
|
-
logger.error(
|
|
1533
|
-
"
|
|
1534
|
-
"error": parse_result[
|
|
1535
|
-
|
|
2157
|
+
if "error" in parse_result:
|
|
2158
|
+
logger.error(
|
|
2159
|
+
"Job parse error - results may be incomplete",
|
|
2160
|
+
extra={"job_id": jid, "error": parse_result["error"]},
|
|
2161
|
+
)
|
|
1536
2162
|
_append_worker_log(f"job {jid} parse error: {parse_result['error']}")
|
|
1537
2163
|
# Update job status to warning with the error
|
|
1538
2164
|
_update_job(jid, status=STATUS_WARNING, parse_result=parse_result)
|
|
@@ -1541,49 +2167,50 @@ def run_job(jid: int) -> None:
|
|
|
1541
2167
|
return
|
|
1542
2168
|
|
|
1543
2169
|
# Parse succeeded
|
|
1544
|
-
logger.info(
|
|
1545
|
-
"
|
|
1546
|
-
"parse_result": parse_result
|
|
1547
|
-
|
|
2170
|
+
logger.info(
|
|
2171
|
+
"Job parsed successfully",
|
|
2172
|
+
extra={"job_id": jid, "parse_result": parse_result},
|
|
2173
|
+
)
|
|
1548
2174
|
_append_worker_log(f"job {jid} parsed: {parse_result}")
|
|
1549
2175
|
|
|
1550
2176
|
# Determine chainable status BEFORE updating to avoid race condition
|
|
1551
2177
|
# We must set parse_result and chainable in a single atomic update
|
|
1552
2178
|
try:
|
|
1553
2179
|
from souleyez.core.tool_chaining import ToolChaining
|
|
2180
|
+
|
|
1554
2181
|
chaining = ToolChaining()
|
|
1555
2182
|
|
|
1556
2183
|
# Get current job to check status
|
|
1557
2184
|
job = get_job(jid)
|
|
1558
|
-
job_status = job.get(
|
|
2185
|
+
job_status = job.get("status", STATUS_ERROR)
|
|
1559
2186
|
|
|
1560
2187
|
# Determine final status from parser if provided
|
|
1561
|
-
final_status = parse_result.get(
|
|
2188
|
+
final_status = parse_result.get("status", job_status)
|
|
1562
2189
|
|
|
1563
2190
|
# Check if job should be chainable
|
|
1564
2191
|
should_chain = (
|
|
1565
|
-
chaining.is_enabled()
|
|
1566
|
-
parse_result
|
|
1567
|
-
|
|
1568
|
-
is_chainable(final_status)
|
|
2192
|
+
chaining.is_enabled()
|
|
2193
|
+
and parse_result
|
|
2194
|
+
and "error" not in parse_result
|
|
2195
|
+
and is_chainable(final_status)
|
|
1569
2196
|
)
|
|
1570
2197
|
|
|
1571
2198
|
# Build update dict - ATOMIC update of parse_result + chainable
|
|
1572
|
-
update_fields = {
|
|
1573
|
-
|
|
1574
|
-
if
|
|
1575
|
-
update_fields[
|
|
1576
|
-
logger.info(
|
|
1577
|
-
"
|
|
1578
|
-
"status": final_status
|
|
1579
|
-
|
|
2199
|
+
update_fields = {"parse_result": parse_result}
|
|
2200
|
+
|
|
2201
|
+
if "status" in parse_result:
|
|
2202
|
+
update_fields["status"] = final_status
|
|
2203
|
+
logger.info(
|
|
2204
|
+
"Job status updated from parser",
|
|
2205
|
+
extra={"job_id": jid, "status": final_status},
|
|
2206
|
+
)
|
|
1580
2207
|
_append_worker_log(f"job {jid} status updated to: {final_status}")
|
|
1581
2208
|
|
|
1582
2209
|
if should_chain:
|
|
1583
|
-
update_fields[
|
|
2210
|
+
update_fields["chainable"] = True
|
|
1584
2211
|
else:
|
|
1585
2212
|
# Not chainable - mark as chained to skip
|
|
1586
|
-
update_fields[
|
|
2213
|
+
update_fields["chained"] = True
|
|
1587
2214
|
|
|
1588
2215
|
# Single atomic update to prevent race condition
|
|
1589
2216
|
_update_job(jid, **update_fields)
|
|
@@ -1591,69 +2218,89 @@ def run_job(jid: int) -> None:
|
|
|
1591
2218
|
# Log chaining decision
|
|
1592
2219
|
if should_chain:
|
|
1593
2220
|
if final_status == STATUS_WARNING:
|
|
1594
|
-
logger.info(
|
|
1595
|
-
"
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
2221
|
+
logger.info(
|
|
2222
|
+
"Job with warning status marked for chaining",
|
|
2223
|
+
extra={
|
|
2224
|
+
"job_id": jid,
|
|
2225
|
+
"tool": job.get("tool"),
|
|
2226
|
+
"wildcard_detected": parse_result.get(
|
|
2227
|
+
"wildcard_detected", False
|
|
2228
|
+
),
|
|
2229
|
+
},
|
|
2230
|
+
)
|
|
2231
|
+
_append_worker_log(
|
|
2232
|
+
f"job {jid} (status=warning) marked as chainable"
|
|
2233
|
+
)
|
|
1600
2234
|
else:
|
|
1601
|
-
logger.info(
|
|
1602
|
-
"
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2235
|
+
logger.info(
|
|
2236
|
+
"Job marked as chainable",
|
|
2237
|
+
extra={
|
|
2238
|
+
"job_id": jid,
|
|
2239
|
+
"tool": job.get("tool"),
|
|
2240
|
+
"status": final_status,
|
|
2241
|
+
},
|
|
2242
|
+
)
|
|
2243
|
+
_append_worker_log(
|
|
2244
|
+
f"job {jid} marked as chainable (status={final_status})"
|
|
2245
|
+
)
|
|
1607
2246
|
else:
|
|
1608
2247
|
reason = f"chaining_disabled={not chaining.is_enabled()}, has_error={'error' in parse_result}, status={final_status}"
|
|
1609
2248
|
_append_worker_log(f"job {jid} not chainable ({reason})")
|
|
1610
2249
|
|
|
1611
2250
|
except Exception as chain_err:
|
|
1612
|
-
logger.error(
|
|
1613
|
-
"
|
|
1614
|
-
"error": str(chain_err)
|
|
1615
|
-
|
|
2251
|
+
logger.error(
|
|
2252
|
+
"Failed to mark job as chainable",
|
|
2253
|
+
extra={"job_id": jid, "error": str(chain_err)},
|
|
2254
|
+
)
|
|
1616
2255
|
_append_worker_log(f"job {jid} chainable marking error: {chain_err}")
|
|
1617
2256
|
# Mark as chained to prevent retry loops
|
|
1618
2257
|
_update_job(jid, chained=True, chain_error=str(chain_err))
|
|
1619
2258
|
|
|
1620
2259
|
except Exception as e:
|
|
1621
|
-
logger.error(
|
|
1622
|
-
"
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2260
|
+
logger.error(
|
|
2261
|
+
"Job parse exception",
|
|
2262
|
+
extra={
|
|
2263
|
+
"job_id": jid,
|
|
2264
|
+
"error": str(e),
|
|
2265
|
+
"traceback": traceback.format_exc(),
|
|
2266
|
+
},
|
|
2267
|
+
)
|
|
1626
2268
|
_append_worker_log(f"job {jid} parse exception: {e}")
|
|
1627
2269
|
|
|
1628
2270
|
# Sanitize log file to remove credentials
|
|
1629
2271
|
try:
|
|
1630
2272
|
if os.path.exists(log_path):
|
|
1631
|
-
with open(log_path,
|
|
2273
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
1632
2274
|
original_log = f.read()
|
|
1633
|
-
|
|
2275
|
+
|
|
1634
2276
|
# Check if encryption is enabled - only sanitize if encryption is on
|
|
1635
2277
|
from souleyez.storage.crypto import CryptoManager
|
|
2278
|
+
|
|
1636
2279
|
crypto_mgr = CryptoManager()
|
|
1637
|
-
|
|
1638
|
-
if
|
|
2280
|
+
|
|
2281
|
+
if (
|
|
2282
|
+
crypto_mgr.is_encryption_enabled()
|
|
2283
|
+
and LogSanitizer.contains_credentials(original_log)
|
|
2284
|
+
):
|
|
1639
2285
|
sanitized_log = LogSanitizer.sanitize(original_log)
|
|
1640
|
-
|
|
2286
|
+
|
|
1641
2287
|
# Write sanitized log back
|
|
1642
|
-
with open(log_path,
|
|
2288
|
+
with open(log_path, "w", encoding="utf-8") as f:
|
|
1643
2289
|
f.write(sanitized_log)
|
|
1644
|
-
|
|
1645
|
-
summary = LogSanitizer.get_redaction_summary(
|
|
2290
|
+
|
|
2291
|
+
summary = LogSanitizer.get_redaction_summary(
|
|
2292
|
+
original_log, sanitized_log
|
|
2293
|
+
)
|
|
1646
2294
|
if summary:
|
|
1647
2295
|
_append_worker_log(f"job {jid}: {summary}")
|
|
1648
|
-
logger.info(
|
|
1649
|
-
"job_id": jid,
|
|
1650
|
-
|
|
1651
|
-
})
|
|
2296
|
+
logger.info(
|
|
2297
|
+
"Log sanitized", extra={"job_id": jid, "summary": summary}
|
|
2298
|
+
)
|
|
1652
2299
|
except Exception as sanitize_err:
|
|
1653
|
-
logger.warning(
|
|
1654
|
-
"
|
|
1655
|
-
"error": str(sanitize_err)
|
|
1656
|
-
|
|
2300
|
+
logger.warning(
|
|
2301
|
+
"Log sanitization failed",
|
|
2302
|
+
extra={"job_id": jid, "error": str(sanitize_err)},
|
|
2303
|
+
)
|
|
1657
2304
|
# Don't fail the job if sanitization fails
|
|
1658
2305
|
|
|
1659
2306
|
_append_worker_log(f"job {jid} finished: status={status} rc={rc}")
|
|
@@ -1661,27 +2308,31 @@ def run_job(jid: int) -> None:
|
|
|
1661
2308
|
except Exception as e:
|
|
1662
2309
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1663
2310
|
_update_job(jid, status="error", error=str(e), finished_at=now)
|
|
1664
|
-
logger.error(
|
|
1665
|
-
"
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2311
|
+
logger.error(
|
|
2312
|
+
"Job crashed",
|
|
2313
|
+
extra={
|
|
2314
|
+
"event_type": "job_failed",
|
|
2315
|
+
"job_id": jid,
|
|
2316
|
+
"error": str(e),
|
|
2317
|
+
"traceback": traceback.format_exc(),
|
|
2318
|
+
},
|
|
2319
|
+
)
|
|
1670
2320
|
_append_worker_log(f"job {jid} crashed: {e}")
|
|
1671
|
-
|
|
2321
|
+
|
|
1672
2322
|
# Sanitize log even on error
|
|
1673
2323
|
try:
|
|
1674
2324
|
if os.path.exists(log_path):
|
|
1675
2325
|
from souleyez.storage.crypto import CryptoManager
|
|
2326
|
+
|
|
1676
2327
|
crypto_mgr = CryptoManager()
|
|
1677
|
-
|
|
2328
|
+
|
|
1678
2329
|
if crypto_mgr.is_encryption_enabled():
|
|
1679
|
-
with open(log_path,
|
|
2330
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
1680
2331
|
original_log = f.read()
|
|
1681
|
-
|
|
2332
|
+
|
|
1682
2333
|
if LogSanitizer.contains_credentials(original_log):
|
|
1683
2334
|
sanitized_log = LogSanitizer.sanitize(original_log)
|
|
1684
|
-
with open(log_path,
|
|
2335
|
+
with open(log_path, "w", encoding="utf-8") as f:
|
|
1685
2336
|
f.write(sanitized_log)
|
|
1686
2337
|
except Exception:
|
|
1687
2338
|
pass # Silently fail sanitization on error
|
|
@@ -1714,7 +2365,7 @@ def _check_log_for_completion(log_path: str, tool: str) -> tuple:
|
|
|
1714
2365
|
return (False, None)
|
|
1715
2366
|
|
|
1716
2367
|
try:
|
|
1717
|
-
with open(log_path,
|
|
2368
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
1718
2369
|
# Read last 5KB of log (completion markers are at the end)
|
|
1719
2370
|
f.seek(0, 2) # End of file
|
|
1720
2371
|
file_size = f.tell()
|
|
@@ -1724,26 +2375,26 @@ def _check_log_for_completion(log_path: str, tool: str) -> tuple:
|
|
|
1724
2375
|
|
|
1725
2376
|
# Tool-specific completion markers
|
|
1726
2377
|
completion_markers = {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2378
|
+
"nmap": ["Nmap done:", "Nmap scan report for"],
|
|
2379
|
+
"gobuster": ["Finished", "Progress:"],
|
|
2380
|
+
"nikto": ["host(s) tested", "End Time:"],
|
|
2381
|
+
"nuclei": ["Scan completed", "matches found", "No results found"],
|
|
2382
|
+
"sqlmap": ["fetched data logged", "shutting down"],
|
|
2383
|
+
"hydra": ["valid password", "host:", "targets finished"],
|
|
2384
|
+
"ffuf": ["Progress:", "Duration:"],
|
|
2385
|
+
"default": ["=== Completed:", "Exit Code:"],
|
|
1735
2386
|
}
|
|
1736
2387
|
|
|
1737
|
-
markers = completion_markers.get(tool.lower(), completion_markers[
|
|
2388
|
+
markers = completion_markers.get(tool.lower(), completion_markers["default"])
|
|
1738
2389
|
|
|
1739
2390
|
for marker in markers:
|
|
1740
2391
|
if marker in log_tail:
|
|
1741
2392
|
# Try to extract exit code
|
|
1742
2393
|
exit_code = None
|
|
1743
|
-
if
|
|
2394
|
+
if "Exit Code:" in log_tail:
|
|
1744
2395
|
try:
|
|
1745
|
-
idx = log_tail.index(
|
|
1746
|
-
code_str = log_tail[idx+10:idx+15].strip().split()[0]
|
|
2396
|
+
idx = log_tail.index("Exit Code:")
|
|
2397
|
+
code_str = log_tail[idx + 10 : idx + 15].strip().split()[0]
|
|
1747
2398
|
exit_code = int(code_str)
|
|
1748
2399
|
except (ValueError, IndexError):
|
|
1749
2400
|
exit_code = 0
|
|
@@ -1771,14 +2422,14 @@ def _detect_and_recover_stale_jobs() -> int:
|
|
|
1771
2422
|
|
|
1772
2423
|
try:
|
|
1773
2424
|
jobs = _read_jobs()
|
|
1774
|
-
running_jobs = [j for j in jobs if j.get(
|
|
2425
|
+
running_jobs = [j for j in jobs if j.get("status") == STATUS_RUNNING]
|
|
1775
2426
|
|
|
1776
2427
|
for job in running_jobs:
|
|
1777
|
-
jid = job.get(
|
|
1778
|
-
pid = job.get(
|
|
1779
|
-
tool = job.get(
|
|
1780
|
-
log_path = job.get(
|
|
1781
|
-
stored_start_time = job.get(
|
|
2428
|
+
jid = job.get("id")
|
|
2429
|
+
pid = job.get("pid")
|
|
2430
|
+
tool = job.get("tool", "unknown")
|
|
2431
|
+
log_path = job.get("log")
|
|
2432
|
+
stored_start_time = job.get("process_start_time")
|
|
1782
2433
|
|
|
1783
2434
|
# Check if PID is alive
|
|
1784
2435
|
if _is_pid_alive(pid):
|
|
@@ -1793,13 +2444,16 @@ def _detect_and_recover_stale_jobs() -> int:
|
|
|
1793
2444
|
f"job {jid}: PID {pid} reused (stored start: {stored_start_time:.0f}, "
|
|
1794
2445
|
f"current: {current_start_time:.0f})"
|
|
1795
2446
|
)
|
|
1796
|
-
logger.warning(
|
|
1797
|
-
"
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2447
|
+
logger.warning(
|
|
2448
|
+
"PID reuse detected",
|
|
2449
|
+
extra={
|
|
2450
|
+
"job_id": jid,
|
|
2451
|
+
"tool": tool,
|
|
2452
|
+
"pid": pid,
|
|
2453
|
+
"stored_start_time": stored_start_time,
|
|
2454
|
+
"current_start_time": current_start_time,
|
|
2455
|
+
},
|
|
2456
|
+
)
|
|
1803
2457
|
# Fall through to stale job handling
|
|
1804
2458
|
else:
|
|
1805
2459
|
# Same process, still running
|
|
@@ -1813,11 +2467,10 @@ def _detect_and_recover_stale_jobs() -> int:
|
|
|
1813
2467
|
else:
|
|
1814
2468
|
# PID is dead - definitely stale
|
|
1815
2469
|
_append_worker_log(f"job {jid}: detected stale (PID {pid} is dead)")
|
|
1816
|
-
logger.warning(
|
|
1817
|
-
"
|
|
1818
|
-
"tool": tool,
|
|
1819
|
-
|
|
1820
|
-
})
|
|
2470
|
+
logger.warning(
|
|
2471
|
+
"Stale job detected",
|
|
2472
|
+
extra={"job_id": jid, "tool": tool, "pid": pid},
|
|
2473
|
+
)
|
|
1821
2474
|
|
|
1822
2475
|
# Check if log shows completion
|
|
1823
2476
|
completed, exit_code = _check_log_for_completion(log_path, tool)
|
|
@@ -1839,74 +2492,94 @@ def _detect_and_recover_stale_jobs() -> int:
|
|
|
1839
2492
|
|
|
1840
2493
|
# Try to parse results
|
|
1841
2494
|
try:
|
|
1842
|
-
from .result_handler import handle_job_result
|
|
1843
2495
|
from souleyez.core.tool_chaining import ToolChaining
|
|
1844
2496
|
|
|
2497
|
+
from .result_handler import handle_job_result
|
|
2498
|
+
|
|
1845
2499
|
job = get_job(jid)
|
|
1846
2500
|
parse_result = handle_job_result(job)
|
|
1847
2501
|
|
|
1848
2502
|
if parse_result:
|
|
1849
|
-
if
|
|
1850
|
-
_append_worker_log(
|
|
2503
|
+
if "error" in parse_result:
|
|
2504
|
+
_append_worker_log(
|
|
2505
|
+
f"job {jid} stale recovery parse error: {parse_result['error']}"
|
|
2506
|
+
)
|
|
1851
2507
|
else:
|
|
1852
2508
|
# Determine final status and chainable in one check
|
|
1853
|
-
final_status = parse_result.get(
|
|
2509
|
+
final_status = parse_result.get("status", status)
|
|
1854
2510
|
chaining = ToolChaining()
|
|
1855
|
-
should_chain = chaining.is_enabled() and is_chainable(
|
|
2511
|
+
should_chain = chaining.is_enabled() and is_chainable(
|
|
2512
|
+
final_status
|
|
2513
|
+
)
|
|
1856
2514
|
|
|
1857
2515
|
# Build atomic update - parse_result + status + chainable together
|
|
1858
|
-
update_fields = {
|
|
1859
|
-
if
|
|
1860
|
-
update_fields[
|
|
2516
|
+
update_fields = {"parse_result": parse_result}
|
|
2517
|
+
if "status" in parse_result:
|
|
2518
|
+
update_fields["status"] = final_status
|
|
1861
2519
|
if should_chain:
|
|
1862
|
-
update_fields[
|
|
2520
|
+
update_fields["chainable"] = True
|
|
1863
2521
|
|
|
1864
2522
|
# Single atomic update to prevent race condition
|
|
1865
2523
|
_update_job(jid, **update_fields)
|
|
1866
2524
|
|
|
1867
|
-
_append_worker_log(
|
|
2525
|
+
_append_worker_log(
|
|
2526
|
+
f"job {jid} stale recovery parsed: {parse_result.get('findings_added', 0)} findings"
|
|
2527
|
+
)
|
|
1868
2528
|
|
|
1869
|
-
logger.info(
|
|
1870
|
-
"
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2529
|
+
logger.info(
|
|
2530
|
+
"Stale job recovered with results",
|
|
2531
|
+
extra={
|
|
2532
|
+
"job_id": jid,
|
|
2533
|
+
"tool": tool,
|
|
2534
|
+
"status": final_status,
|
|
2535
|
+
"parse_result": parse_result,
|
|
2536
|
+
"chainable": should_chain,
|
|
2537
|
+
},
|
|
2538
|
+
)
|
|
1876
2539
|
|
|
1877
2540
|
if should_chain:
|
|
1878
|
-
_append_worker_log(
|
|
2541
|
+
_append_worker_log(
|
|
2542
|
+
f"job {jid} stale recovery marked as chainable"
|
|
2543
|
+
)
|
|
1879
2544
|
|
|
1880
2545
|
except Exception as parse_err:
|
|
1881
|
-
_append_worker_log(
|
|
2546
|
+
_append_worker_log(
|
|
2547
|
+
f"job {jid} stale recovery parse exception: {parse_err}"
|
|
2548
|
+
)
|
|
1882
2549
|
|
|
1883
2550
|
recovered += 1
|
|
1884
2551
|
|
|
1885
2552
|
else:
|
|
1886
2553
|
# Process died mid-execution - mark as error
|
|
1887
|
-
_append_worker_log(
|
|
1888
|
-
|
|
2554
|
+
_append_worker_log(
|
|
2555
|
+
f"job {jid}: process died unexpectedly, marking as error"
|
|
2556
|
+
)
|
|
2557
|
+
_update_job(
|
|
2558
|
+
jid,
|
|
1889
2559
|
status=STATUS_ERROR,
|
|
1890
2560
|
finished_at=now,
|
|
1891
2561
|
pid=None,
|
|
1892
|
-
error="Process terminated unexpectedly (worker restart or crash)"
|
|
2562
|
+
error="Process terminated unexpectedly (worker restart or crash)",
|
|
1893
2563
|
)
|
|
1894
2564
|
|
|
1895
|
-
logger.warning(
|
|
1896
|
-
"
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2565
|
+
logger.warning(
|
|
2566
|
+
"Stale job marked as error",
|
|
2567
|
+
extra={
|
|
2568
|
+
"job_id": jid,
|
|
2569
|
+
"tool": tool,
|
|
2570
|
+
"reason": "process_died_unexpectedly",
|
|
2571
|
+
},
|
|
2572
|
+
)
|
|
1900
2573
|
|
|
1901
2574
|
recovered += 1
|
|
1902
2575
|
|
|
1903
2576
|
return recovered
|
|
1904
2577
|
|
|
1905
2578
|
except Exception as e:
|
|
1906
|
-
logger.error(
|
|
1907
|
-
"error"
|
|
1908
|
-
"traceback": traceback.format_exc()
|
|
1909
|
-
|
|
2579
|
+
logger.error(
|
|
2580
|
+
"Stale job detection error",
|
|
2581
|
+
extra={"error": str(e), "traceback": traceback.format_exc()},
|
|
2582
|
+
)
|
|
1910
2583
|
_append_worker_log(f"stale job detection error: {e}")
|
|
1911
2584
|
return 0
|
|
1912
2585
|
|
|
@@ -1926,10 +2599,11 @@ def _check_msf_exploitation_success():
|
|
|
1926
2599
|
try:
|
|
1927
2600
|
jobs = _read_jobs()
|
|
1928
2601
|
running_msf = [
|
|
1929
|
-
j
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
and
|
|
2602
|
+
j
|
|
2603
|
+
for j in jobs
|
|
2604
|
+
if j.get("status") == STATUS_RUNNING
|
|
2605
|
+
and j.get("tool") in ("msfconsole", "msf")
|
|
2606
|
+
and not j.get("exploitation_detected") # Not already detected
|
|
1933
2607
|
]
|
|
1934
2608
|
|
|
1935
2609
|
if not running_msf:
|
|
@@ -1939,22 +2613,22 @@ def _check_msf_exploitation_success():
|
|
|
1939
2613
|
|
|
1940
2614
|
# Success patterns from MSF output
|
|
1941
2615
|
success_patterns = [
|
|
1942
|
-
r
|
|
1943
|
-
r
|
|
1944
|
-
r
|
|
1945
|
-
r
|
|
1946
|
-
r
|
|
2616
|
+
r"\[\*\]\s+Command shell session \d+ opened",
|
|
2617
|
+
r"\[\*\]\s+Meterpreter session \d+ opened",
|
|
2618
|
+
r"\[\+\]\s+\d+\.\d+\.\d+\.\d+:\d+\s+-\s+Session \d+ created",
|
|
2619
|
+
r"\[\+\].*session.*opened",
|
|
2620
|
+
r"\[\+\].*session.*created",
|
|
1947
2621
|
]
|
|
1948
2622
|
|
|
1949
2623
|
for job in running_msf:
|
|
1950
|
-
jid = job.get(
|
|
2624
|
+
jid = job.get("id")
|
|
1951
2625
|
log_path = os.path.join(JOBS_DIR, f"{jid}.log")
|
|
1952
2626
|
|
|
1953
2627
|
if not os.path.exists(log_path):
|
|
1954
2628
|
continue
|
|
1955
2629
|
|
|
1956
2630
|
try:
|
|
1957
|
-
with open(log_path,
|
|
2631
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
1958
2632
|
content = f.read()
|
|
1959
2633
|
|
|
1960
2634
|
# Check for success patterns
|
|
@@ -1966,21 +2640,27 @@ def _check_msf_exploitation_success():
|
|
|
1966
2640
|
if match:
|
|
1967
2641
|
session_opened = True
|
|
1968
2642
|
# Extract session number if available
|
|
1969
|
-
session_match = re.search(
|
|
2643
|
+
session_match = re.search(
|
|
2644
|
+
r"session (\d+)", match.group(), re.IGNORECASE
|
|
2645
|
+
)
|
|
1970
2646
|
if session_match:
|
|
1971
2647
|
session_info = f"Session {session_match.group(1)}"
|
|
1972
2648
|
break
|
|
1973
2649
|
|
|
1974
2650
|
if session_opened:
|
|
1975
2651
|
# Update job with exploitation success
|
|
1976
|
-
_update_job(
|
|
1977
|
-
|
|
2652
|
+
_update_job(
|
|
2653
|
+
jid, exploitation_detected=True, session_info=session_info
|
|
2654
|
+
)
|
|
2655
|
+
_append_worker_log(
|
|
2656
|
+
f"job {jid}: exploitation success detected - {session_info or 'session opened'}"
|
|
2657
|
+
)
|
|
1978
2658
|
|
|
1979
2659
|
# Record exploit attempt as success
|
|
1980
|
-
engagement_id = job.get(
|
|
1981
|
-
target = job.get(
|
|
1982
|
-
label = job.get(
|
|
1983
|
-
args = job.get(
|
|
2660
|
+
engagement_id = job.get("engagement_id")
|
|
2661
|
+
target = job.get("target")
|
|
2662
|
+
label = job.get("label", "")
|
|
2663
|
+
args = job.get("args", [])
|
|
1984
2664
|
|
|
1985
2665
|
if engagement_id and target:
|
|
1986
2666
|
try:
|
|
@@ -1993,35 +2673,49 @@ def _check_msf_exploitation_success():
|
|
|
1993
2673
|
if host:
|
|
1994
2674
|
# Extract port from args (look for "set RPORT X" or "RPORT X")
|
|
1995
2675
|
port = None
|
|
1996
|
-
args_str =
|
|
1997
|
-
port_match = re.search(
|
|
2676
|
+
args_str = " ".join(args) if args else ""
|
|
2677
|
+
port_match = re.search(
|
|
2678
|
+
r"RPORT\s+(\d+)", args_str, re.IGNORECASE
|
|
2679
|
+
)
|
|
1998
2680
|
if port_match:
|
|
1999
2681
|
port = int(port_match.group(1))
|
|
2000
2682
|
|
|
2001
2683
|
# Find service_id for this port
|
|
2002
2684
|
service_id = None
|
|
2003
2685
|
if port:
|
|
2004
|
-
services = hm.get_host_services(host[
|
|
2686
|
+
services = hm.get_host_services(host["id"])
|
|
2005
2687
|
for svc in services:
|
|
2006
|
-
if svc.get(
|
|
2007
|
-
service_id = svc.get(
|
|
2688
|
+
if svc.get("port") == port:
|
|
2689
|
+
service_id = svc.get("id")
|
|
2008
2690
|
break
|
|
2009
2691
|
|
|
2010
2692
|
# Extract exploit identifier from label or args
|
|
2011
|
-
exploit_id =
|
|
2693
|
+
exploit_id = (
|
|
2694
|
+
label.replace("MSF: ", "msf:")
|
|
2695
|
+
if label.startswith("MSF:")
|
|
2696
|
+
else f"msf:{label}"
|
|
2697
|
+
)
|
|
2012
2698
|
|
|
2013
2699
|
record_attempt(
|
|
2014
2700
|
engagement_id=engagement_id,
|
|
2015
|
-
host_id=host[
|
|
2701
|
+
host_id=host["id"],
|
|
2016
2702
|
exploit_identifier=exploit_id,
|
|
2017
2703
|
exploit_title=label,
|
|
2018
|
-
status=
|
|
2704
|
+
status="success",
|
|
2019
2705
|
service_id=service_id,
|
|
2020
|
-
notes=
|
|
2706
|
+
notes=(
|
|
2707
|
+
f"Session opened - {session_info}"
|
|
2708
|
+
if session_info
|
|
2709
|
+
else "Session opened"
|
|
2710
|
+
),
|
|
2711
|
+
)
|
|
2712
|
+
_append_worker_log(
|
|
2713
|
+
f"job {jid}: recorded exploitation success for {target}:{port or 'unknown'}"
|
|
2021
2714
|
)
|
|
2022
|
-
_append_worker_log(f"job {jid}: recorded exploitation success for {target}:{port or 'unknown'}")
|
|
2023
2715
|
except Exception as e:
|
|
2024
|
-
_append_worker_log(
|
|
2716
|
+
_append_worker_log(
|
|
2717
|
+
f"job {jid}: failed to record exploit attempt: {e}"
|
|
2718
|
+
)
|
|
2025
2719
|
|
|
2026
2720
|
detected_count += 1
|
|
2027
2721
|
|
|
@@ -2045,11 +2739,11 @@ def _update_job_progress():
|
|
|
2045
2739
|
"""
|
|
2046
2740
|
try:
|
|
2047
2741
|
jobs = _read_jobs()
|
|
2048
|
-
running_jobs = [j for j in jobs if j.get(
|
|
2742
|
+
running_jobs = [j for j in jobs if j.get("status") == STATUS_RUNNING]
|
|
2049
2743
|
|
|
2050
2744
|
for job in running_jobs:
|
|
2051
|
-
jid = job.get(
|
|
2052
|
-
log_path = job.get(
|
|
2745
|
+
jid = job.get("id")
|
|
2746
|
+
log_path = job.get("log")
|
|
2053
2747
|
|
|
2054
2748
|
if not log_path or not os.path.exists(log_path):
|
|
2055
2749
|
continue
|
|
@@ -2061,23 +2755,26 @@ def _update_job_progress():
|
|
|
2061
2755
|
time_since_output = current_time - mtime
|
|
2062
2756
|
|
|
2063
2757
|
# Update last_output_at in job record
|
|
2064
|
-
updates = {
|
|
2758
|
+
updates = {"last_output_at": mtime}
|
|
2065
2759
|
|
|
2066
2760
|
# Flag as possibly hung if no output for threshold
|
|
2067
|
-
was_hung = job.get(
|
|
2761
|
+
was_hung = job.get("possibly_hung", False)
|
|
2068
2762
|
is_hung = time_since_output > JOB_HUNG_THRESHOLD
|
|
2069
2763
|
|
|
2070
2764
|
if is_hung != was_hung:
|
|
2071
|
-
updates[
|
|
2765
|
+
updates["possibly_hung"] = is_hung
|
|
2072
2766
|
if is_hung:
|
|
2073
2767
|
_append_worker_log(
|
|
2074
2768
|
f"job {jid}: no output for {int(time_since_output)}s, flagged as possibly hung"
|
|
2075
2769
|
)
|
|
2076
|
-
logger.warning(
|
|
2077
|
-
"
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2770
|
+
logger.warning(
|
|
2771
|
+
"Job possibly hung",
|
|
2772
|
+
extra={
|
|
2773
|
+
"job_id": jid,
|
|
2774
|
+
"tool": job.get("tool"),
|
|
2775
|
+
"time_since_output": int(time_since_output),
|
|
2776
|
+
},
|
|
2777
|
+
)
|
|
2081
2778
|
|
|
2082
2779
|
_update_job(jid, **updates)
|
|
2083
2780
|
|
|
@@ -2178,9 +2875,9 @@ def worker_loop(poll_interval: float = 2.0):
|
|
|
2178
2875
|
if processed > 0:
|
|
2179
2876
|
_append_worker_log(f"processed {processed} chainable job(s)")
|
|
2180
2877
|
except Exception as e:
|
|
2181
|
-
logger.error(
|
|
2182
|
-
"error": str(e)
|
|
2183
|
-
|
|
2878
|
+
logger.error(
|
|
2879
|
+
"Chain processing error in worker loop", extra={"error": str(e)}
|
|
2880
|
+
)
|
|
2184
2881
|
_append_worker_log(f"chain processing error: {e}")
|
|
2185
2882
|
|
|
2186
2883
|
# Sleep before next iteration
|
|
@@ -2209,8 +2906,14 @@ def start_worker(detach: bool = True, fg: bool = False):
|
|
|
2209
2906
|
else:
|
|
2210
2907
|
# Running as Python script
|
|
2211
2908
|
python = exe or "python3"
|
|
2212
|
-
cmd = [
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2909
|
+
cmd = [
|
|
2910
|
+
python,
|
|
2911
|
+
"-u",
|
|
2912
|
+
"-c",
|
|
2913
|
+
"import sys; from souleyez.engine.background import worker_loop; worker_loop()",
|
|
2914
|
+
]
|
|
2915
|
+
|
|
2916
|
+
subprocess.Popen(
|
|
2917
|
+
cmd, stdout=open(WORKER_LOG, "a"), stderr=subprocess.STDOUT, close_fds=True
|
|
2918
|
+
)
|
|
2216
2919
|
_append_worker_log("Started background worker (detached)")
|