souleyez 2.27.0__py3-none-any.whl → 2.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -321,7 +321,7 @@ def _next_job_id(jobs: List[Dict[str, Any]]) -> int:
321
321
  return maxid + 1
322
322
 
323
323
 
324
- def enqueue_job(tool: str, target: str, args: List[str], label: str = "", engagement_id: int = None, metadata: Dict[str, Any] = None, parent_id: int = None, reason: str = None, rule_id: int = None) -> int:
324
+ def enqueue_job(tool: str, target: str, args: List[str], label: str = "", engagement_id: int = None, metadata: Dict[str, Any] = None, parent_id: int = None, reason: str = None, rule_id: int = None, skip_scope_check: bool = False) -> int:
325
325
  with _lock:
326
326
  jobs = _read_jobs()
327
327
  jid = _next_job_id(jobs)
@@ -339,6 +339,43 @@ def enqueue_job(tool: str, target: str, args: List[str], label: str = "", engage
339
339
 
340
340
  # Merge parent_id, reason, and rule_id into metadata
341
341
  job_metadata = metadata or {}
342
+
343
+ # Scope validation - check if target is within engagement scope
344
+ if not skip_scope_check and engagement_id:
345
+ try:
346
+ from souleyez.security.scope_validator import ScopeValidator, ScopeViolationError
347
+ validator = ScopeValidator(engagement_id)
348
+ result = validator.validate_target(target)
349
+ enforcement = validator.get_enforcement_mode()
350
+
351
+ if not result.is_in_scope and validator.has_scope_defined():
352
+ if enforcement == 'block':
353
+ validator.log_validation(target, result, 'blocked', job_id=jid)
354
+ raise ScopeViolationError(
355
+ f"Target '{target}' is out of scope. {result.reason}"
356
+ )
357
+ elif enforcement == 'warn':
358
+ validator.log_validation(target, result, 'warned', job_id=jid)
359
+ if 'warnings' not in job_metadata:
360
+ job_metadata['warnings'] = []
361
+ job_metadata['warnings'].append(
362
+ f"SCOPE WARNING: {target} may be out of scope. {result.reason}"
363
+ )
364
+ logger.warning("Out-of-scope target allowed (warn mode)", extra={
365
+ "target": target,
366
+ "engagement_id": engagement_id,
367
+ "reason": result.reason
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={
376
+ "target": target,
377
+ "error": str(e)
378
+ })
342
379
  if parent_id is not None:
343
380
  job_metadata['parent_id'] = parent_id
344
381
  if reason:
@@ -10,6 +10,95 @@ from .job_status import STATUS_DONE, STATUS_NO_RESULTS, STATUS_WARNING, STATUS_E
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
+ # Common error patterns that indicate tool failure (not "no results")
14
+ TOOL_ERROR_PATTERNS = {
15
+ 'common': [
16
+ 'connection refused',
17
+ 'connection timed out',
18
+ 'no route to host',
19
+ 'network is unreachable',
20
+ 'name or service not known',
21
+ 'temporary failure in name resolution',
22
+ 'host is down',
23
+ 'connection reset by peer',
24
+ ],
25
+ 'nmap': [
26
+ 'host seems down',
27
+ 'note: host seems down',
28
+ 'failed to resolve',
29
+ ],
30
+ 'gobuster': [
31
+ 'timeout occurred during the request',
32
+ 'error on running gobuster',
33
+ 'unable to connect',
34
+ 'context deadline exceeded',
35
+ ],
36
+ 'hydra': [
37
+ 'can not connect',
38
+ 'could not connect',
39
+ 'error connecting',
40
+ 'target does not support',
41
+ ],
42
+ 'nikto': [
43
+ 'error connecting to host',
44
+ 'unable to connect',
45
+ 'no web server found',
46
+ ],
47
+ 'nuclei': [
48
+ 'could not connect',
49
+ 'context deadline exceeded',
50
+ 'no address found',
51
+ ],
52
+ 'ffuf': [
53
+ 'error making request',
54
+ 'context deadline exceeded',
55
+ ],
56
+ 'sqlmap': [
57
+ 'connection timed out',
58
+ 'unable to connect',
59
+ 'target url content is not stable',
60
+ ],
61
+ 'enum4linux': [
62
+ 'could not initialise',
63
+ 'nt_status_connection_refused',
64
+ 'nt_status_host_unreachable',
65
+ 'nt_status_io_timeout',
66
+ ],
67
+ 'smbmap': [
68
+ 'could not connect',
69
+ 'connection error',
70
+ 'nt_status_connection_refused',
71
+ ],
72
+ }
73
+
74
+
75
+ def detect_tool_error(log_content: str, tool: str) -> Optional[str]:
76
+ """
77
+ Check log content for tool errors that indicate failure (not just "no results").
78
+
79
+ Args:
80
+ log_content: The log file content
81
+ tool: Tool name (lowercase)
82
+
83
+ Returns:
84
+ Error pattern found, or None if no error detected
85
+ """
86
+ log_lower = log_content.lower()
87
+
88
+ # Check common patterns
89
+ for pattern in TOOL_ERROR_PATTERNS['common']:
90
+ if pattern in log_lower:
91
+ return pattern
92
+
93
+ # Check tool-specific patterns
94
+ tool_patterns = TOOL_ERROR_PATTERNS.get(tool, [])
95
+ for pattern in tool_patterns:
96
+ if pattern in log_lower:
97
+ return pattern
98
+
99
+ return None
100
+
101
+
13
102
  def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
14
103
  """
15
104
  Process completed job and parse results into database.
@@ -527,9 +616,16 @@ def parse_nmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
527
616
  'version': svc.get('version', '')
528
617
  })
529
618
 
619
+ # Check for nmap errors before determining status
620
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
621
+ log_content = f.read()
622
+ nmap_error = detect_tool_error(log_content, 'nmap')
623
+
530
624
  # Determine status based on results
531
625
  hosts_up = len([h for h in parsed.get('hosts', []) if h.get('status') == 'up'])
532
- if hosts_up > 0:
626
+ if nmap_error:
627
+ status = STATUS_ERROR # Tool failed to run properly
628
+ elif hosts_up > 0:
533
629
  status = STATUS_DONE # Found hosts
534
630
  else:
535
631
  status = STATUS_NO_RESULTS # No hosts up
@@ -1122,8 +1218,13 @@ def parse_gobuster_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -
1122
1218
  exclude_length = length_match.group(1)
1123
1219
  logger.info(f"Gobuster wildcard detected: Length {exclude_length}b")
1124
1220
 
1221
+ # Check for gobuster errors
1222
+ gobuster_error = detect_tool_error(log_content, 'gobuster')
1223
+
1125
1224
  # Determine status based on results
1126
- if wildcard_detected:
1225
+ if gobuster_error:
1226
+ status = STATUS_ERROR # Tool failed to connect
1227
+ elif wildcard_detected:
1127
1228
  # Wildcard detected - warning status (triggers auto-retry)
1128
1229
  status = STATUS_WARNING
1129
1230
  elif stats['total'] > 0:
@@ -1504,8 +1605,13 @@ def parse_sqlmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
1504
1605
 
1505
1606
  stats = get_sqli_stats(parsed)
1506
1607
 
1608
+ # Check for sqlmap errors
1609
+ sqlmap_error = detect_tool_error(log_content, 'sqlmap')
1610
+
1507
1611
  # Determine status based on results
1508
- if stats['sqli_confirmed'] or stats['xss_possible'] or stats['fi_possible']:
1612
+ if sqlmap_error:
1613
+ status = STATUS_ERROR # Tool failed to connect
1614
+ elif stats['sqli_confirmed'] or stats['xss_possible'] or stats['fi_possible']:
1509
1615
  status = STATUS_DONE # Found injection vulnerabilities
1510
1616
  else:
1511
1617
  status = STATUS_NO_RESULTS # No injections found
@@ -2011,11 +2117,22 @@ def parse_smbmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
2011
2117
  )
2012
2118
  findings_added += 1
2013
2119
 
2120
+ # Check for smbmap errors
2121
+ smbmap_error = detect_tool_error(log_content, 'smbmap')
2122
+
2123
+ # Determine status
2124
+ if smbmap_error:
2125
+ status = STATUS_ERROR # Tool failed to connect
2126
+ elif shares_added > 0 or findings_added > 0:
2127
+ status = STATUS_DONE
2128
+ else:
2129
+ status = STATUS_NO_RESULTS
2130
+
2014
2131
  return {
2015
2132
  'tool': 'smbmap',
2016
2133
  'host': parsed['target'],
2017
2134
  'connection_status': parsed.get('status', 'Unknown'), # SMB connection status
2018
- 'status': STATUS_DONE if (shares_added > 0 or findings_added > 0) else STATUS_NO_RESULTS, # Job status
2135
+ 'status': status, # Job status
2019
2136
  'shares_added': shares_added,
2020
2137
  'files_added': files_added,
2021
2138
  'findings_added': findings_added
@@ -2382,8 +2499,13 @@ def parse_hydra_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
2382
2499
  )
2383
2500
  findings_added += 1
2384
2501
 
2502
+ # Check for hydra errors
2503
+ hydra_error = detect_tool_error(log_content, 'hydra')
2504
+
2385
2505
  # Determine status based on results
2386
- if len(parsed.get('credentials', [])) > 0:
2506
+ if hydra_error:
2507
+ status = STATUS_ERROR # Tool failed to connect
2508
+ elif len(parsed.get('credentials', [])) > 0:
2387
2509
  status = STATUS_DONE # Found valid credentials
2388
2510
  elif len(parsed.get('usernames', [])) > 0:
2389
2511
  status = STATUS_DONE # Found valid usernames (partial success is still a result)
@@ -2495,8 +2617,15 @@ def parse_nuclei_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
2495
2617
  )
2496
2618
  findings_added += 1
2497
2619
 
2620
+ # Check for nuclei errors
2621
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
2622
+ log_content = f.read()
2623
+ nuclei_error = detect_tool_error(log_content, 'nuclei')
2624
+
2498
2625
  # Determine status based on results
2499
- if parsed.get('findings_count', 0) > 0:
2626
+ if nuclei_error:
2627
+ status = STATUS_ERROR # Tool failed to connect
2628
+ elif parsed.get('findings_count', 0) > 0:
2500
2629
  status = STATUS_DONE # Found vulnerabilities
2501
2630
  else:
2502
2631
  status = STATUS_NO_RESULTS # No vulnerabilities found
@@ -2618,6 +2747,9 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
2618
2747
  'ip': parsed['target']
2619
2748
  })
2620
2749
 
2750
+ # Check for enum4linux errors
2751
+ enum4linux_error = detect_tool_error(log_content, 'enum4linux')
2752
+
2621
2753
  # Determine status: done if we found any results (shares, users, or findings)
2622
2754
  has_results = (
2623
2755
  findings_added > 0 or
@@ -2626,9 +2758,16 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
2626
2758
  stats['total_shares'] > 0
2627
2759
  )
2628
2760
 
2761
+ if enum4linux_error:
2762
+ status = STATUS_ERROR # Tool failed to connect
2763
+ elif has_results:
2764
+ status = STATUS_DONE
2765
+ else:
2766
+ status = STATUS_NO_RESULTS
2767
+
2629
2768
  return {
2630
2769
  'tool': 'enum4linux',
2631
- 'status': STATUS_DONE if has_results else STATUS_NO_RESULTS,
2770
+ 'status': status,
2632
2771
  'findings_added': findings_added,
2633
2772
  'credentials_added': credentials_added,
2634
2773
  'users_found': len(parsed['users']),
@@ -2735,13 +2874,26 @@ def parse_ffuf_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
2735
2874
 
2736
2875
  if host_id and parsed.get('paths'):
2737
2876
  paths_added = wpm.bulk_add_web_paths(host_id, parsed['paths'])
2738
-
2877
+
2739
2878
  # Check for sensitive paths and create findings (same as gobuster)
2740
2879
  created_findings = _create_findings_for_sensitive_paths(engagement_id, host_id, parsed['paths'], job)
2741
2880
 
2881
+ # Check for ffuf errors
2882
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
2883
+ log_content = f.read()
2884
+ ffuf_error = detect_tool_error(log_content, 'ffuf')
2885
+
2886
+ # Determine status
2887
+ if ffuf_error:
2888
+ status = STATUS_ERROR # Tool failed to connect
2889
+ elif parsed.get('results_found', 0) > 0:
2890
+ status = STATUS_DONE
2891
+ else:
2892
+ status = STATUS_NO_RESULTS
2893
+
2742
2894
  return {
2743
2895
  'tool': 'ffuf',
2744
- 'status': STATUS_DONE if parsed.get('results_found', 0) > 0 else STATUS_NO_RESULTS,
2896
+ 'status': status,
2745
2897
  'target': target,
2746
2898
  'results_found': parsed.get('results_found', 0),
2747
2899
  'paths_added': paths_added,
@@ -3060,8 +3212,13 @@ def parse_nikto_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
3060
3212
  )
3061
3213
  findings_added += 1
3062
3214
 
3215
+ # Check for nikto errors
3216
+ nikto_error = detect_tool_error(output, 'nikto')
3217
+
3063
3218
  # Determine status based on results
3064
- if findings_added > 0:
3219
+ if nikto_error:
3220
+ status = STATUS_ERROR # Tool failed to connect
3221
+ elif findings_added > 0:
3065
3222
  status = STATUS_DONE
3066
3223
  else:
3067
3224
  status = STATUS_NO_RESULTS
souleyez/main.py CHANGED
@@ -173,7 +173,7 @@ def _check_privileged_tools():
173
173
 
174
174
 
175
175
  @click.group()
176
- @click.version_option(version='2.27.0')
176
+ @click.version_option(version='2.28.0')
177
177
  def cli():
178
178
  """SoulEyez - AI-Powered Pentesting Platform by CyberSoul Security"""
179
179
  from souleyez.log_config import init_logging
@@ -604,6 +604,227 @@ from souleyez.commands.audit import audit
604
604
  cli.add_command(audit)
605
605
 
606
606
 
607
+ # ============================================================================
608
+ # SCOPE MANAGEMENT
609
+ # ============================================================================
610
+
611
+ @cli.group()
612
+ def scope():
613
+ """Engagement scope management - define and enforce target boundaries."""
614
+ pass
615
+
616
+
617
+ @scope.command("add")
618
+ @click.argument("engagement_name")
619
+ @click.option("--cidr", help="Add CIDR range (e.g., 192.168.1.0/24)")
620
+ @click.option("--domain", help="Add domain pattern (e.g., *.example.com)")
621
+ @click.option("--url", help="Add URL (e.g., https://app.example.com)")
622
+ @click.option("--hostname", help="Add specific hostname or IP")
623
+ @click.option("--exclude", is_flag=True, help="Add as exclusion (deny rule)")
624
+ @click.option("--description", "-d", default="", help="Description for this scope entry")
625
+ def scope_add(engagement_name, cidr, domain, url, hostname, exclude, description):
626
+ """Add a scope entry to an engagement."""
627
+ from souleyez.security.scope_validator import ScopeManager
628
+
629
+ em = EngagementManager()
630
+ eng = em.get(engagement_name)
631
+ if not eng:
632
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
633
+ return
634
+
635
+ manager = ScopeManager()
636
+
637
+ # Determine scope type and value
638
+ if cidr:
639
+ scope_type, value = 'cidr', cidr
640
+ elif domain:
641
+ scope_type, value = 'domain', domain
642
+ elif url:
643
+ scope_type, value = 'url', url
644
+ elif hostname:
645
+ scope_type, value = 'hostname', hostname
646
+ else:
647
+ click.echo("Error: Must specify one of --cidr, --domain, --url, or --hostname", err=True)
648
+ return
649
+
650
+ try:
651
+ scope_id = manager.add_scope(
652
+ engagement_id=eng['id'],
653
+ scope_type=scope_type,
654
+ value=value,
655
+ is_excluded=exclude,
656
+ description=description
657
+ )
658
+ action = "exclusion" if exclude else "scope entry"
659
+ click.echo(f"Added {action}: {scope_type}={value} (id={scope_id})")
660
+ except ValueError as e:
661
+ click.echo(f"Error: {e}", err=True)
662
+
663
+
664
+ @scope.command("list")
665
+ @click.argument("engagement_name")
666
+ def scope_list(engagement_name):
667
+ """List scope entries for an engagement."""
668
+ from souleyez.security.scope_validator import ScopeManager, ScopeValidator
669
+
670
+ em = EngagementManager()
671
+ eng = em.get(engagement_name)
672
+ if not eng:
673
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
674
+ return
675
+
676
+ manager = ScopeManager()
677
+ validator = ScopeValidator(eng['id'])
678
+ entries = manager.list_scope(eng['id'])
679
+ enforcement = validator.get_enforcement_mode()
680
+
681
+ click.echo(f"\nScope for '{engagement_name}' (enforcement: {enforcement})")
682
+ click.echo("=" * 70)
683
+
684
+ if not entries:
685
+ click.echo("No scope entries defined (all targets allowed)")
686
+ return
687
+
688
+ click.echo(f"{'ID':<5} {'Type':<10} {'Value':<35} {'Excluded':<10}")
689
+ click.echo("-" * 70)
690
+
691
+ for entry in entries:
692
+ excluded = "EXCLUDE" if entry.get('is_excluded') else ""
693
+ click.echo(f"{entry['id']:<5} {entry['scope_type']:<10} {entry['value']:<35} {excluded:<10}")
694
+ if entry.get('description'):
695
+ click.echo(f" {entry['description']}")
696
+
697
+ click.echo()
698
+
699
+
700
+ @scope.command("remove")
701
+ @click.argument("engagement_name")
702
+ @click.argument("scope_id", type=int)
703
+ def scope_remove(engagement_name, scope_id):
704
+ """Remove a scope entry by ID."""
705
+ from souleyez.security.scope_validator import ScopeManager
706
+
707
+ em = EngagementManager()
708
+ eng = em.get(engagement_name)
709
+ if not eng:
710
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
711
+ return
712
+
713
+ manager = ScopeManager()
714
+ if manager.remove_scope(scope_id):
715
+ click.echo(f"Removed scope entry {scope_id}")
716
+ else:
717
+ click.echo(f"Error: Failed to remove scope entry {scope_id}", err=True)
718
+
719
+
720
+ @scope.command("enforcement")
721
+ @click.argument("engagement_name")
722
+ @click.argument("mode", type=click.Choice(['off', 'warn', 'block']))
723
+ def scope_enforcement(engagement_name, mode):
724
+ """Set enforcement mode for an engagement.
725
+
726
+ Modes:
727
+ off - No scope validation (default)
728
+ warn - Allow out-of-scope targets but log warning
729
+ block - Reject jobs targeting out-of-scope hosts
730
+ """
731
+ from souleyez.security.scope_validator import ScopeManager
732
+
733
+ em = EngagementManager()
734
+ eng = em.get(engagement_name)
735
+ if not eng:
736
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
737
+ return
738
+
739
+ manager = ScopeManager()
740
+ if manager.set_enforcement(eng['id'], mode):
741
+ click.echo(f"Enforcement mode set to '{mode}' for '{engagement_name}'")
742
+ else:
743
+ click.echo("Error: Failed to set enforcement mode", err=True)
744
+
745
+
746
+ @scope.command("validate")
747
+ @click.argument("engagement_name")
748
+ @click.argument("target")
749
+ def scope_validate(engagement_name, target):
750
+ """Test if a target is in scope."""
751
+ from souleyez.security.scope_validator import ScopeValidator
752
+
753
+ em = EngagementManager()
754
+ eng = em.get(engagement_name)
755
+ if not eng:
756
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
757
+ return
758
+
759
+ validator = ScopeValidator(eng['id'])
760
+ result = validator.validate_target(target)
761
+
762
+ if result.is_in_scope:
763
+ click.echo(f"IN SCOPE: {target}")
764
+ if result.matched_entry:
765
+ click.echo(f" Matched: {result.matched_entry.get('value')}")
766
+ else:
767
+ click.echo(f"OUT OF SCOPE: {target}")
768
+ click.echo(f" Reason: {result.reason}")
769
+
770
+ click.echo(f" Enforcement: {validator.get_enforcement_mode()}")
771
+
772
+
773
+ @scope.command("revalidate")
774
+ @click.argument("engagement_name")
775
+ def scope_revalidate(engagement_name):
776
+ """Revalidate scope status for all hosts in an engagement."""
777
+ from souleyez.storage.hosts import HostManager
778
+
779
+ em = EngagementManager()
780
+ eng = em.get(engagement_name)
781
+ if not eng:
782
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
783
+ return
784
+
785
+ hm = HostManager()
786
+ result = hm.revalidate_scope_status(eng['id'])
787
+
788
+ click.echo(f"Revalidated hosts for '{engagement_name}':")
789
+ click.echo(f" Updated: {result['updated']}")
790
+ click.echo(f" In scope: {result['in_scope']}")
791
+ click.echo(f" Out of scope: {result['out_of_scope']}")
792
+
793
+
794
+ @scope.command("log")
795
+ @click.argument("engagement_name")
796
+ @click.option("--limit", "-n", default=50, help="Number of entries to show")
797
+ def scope_log(engagement_name, limit):
798
+ """Show scope validation audit log."""
799
+ from souleyez.security.scope_validator import ScopeManager
800
+
801
+ em = EngagementManager()
802
+ eng = em.get(engagement_name)
803
+ if not eng:
804
+ click.echo(f"Error: Engagement '{engagement_name}' not found", err=True)
805
+ return
806
+
807
+ manager = ScopeManager()
808
+ log_entries = manager.get_validation_log(eng['id'], limit)
809
+
810
+ click.echo(f"\nScope validation log for '{engagement_name}' (last {limit})")
811
+ click.echo("=" * 80)
812
+
813
+ if not log_entries:
814
+ click.echo("No validation log entries")
815
+ return
816
+
817
+ click.echo(f"{'Time':<20} {'Target':<25} {'Result':<12} {'Action':<10}")
818
+ click.echo("-" * 80)
819
+
820
+ for entry in log_entries:
821
+ timestamp = entry.get('created_at', '')[:19] # Trim to datetime
822
+ target = entry.get('target', '')[:24]
823
+ result = entry.get('validation_result', '')
824
+ action = entry.get('action_taken', '')
825
+ click.echo(f"{timestamp:<20} {target:<25} {result:<12} {action:<10}")
826
+
827
+
607
828
  @cli.group()
608
829
  def jobs():
609
830
  """Background job management."""
@@ -195,8 +195,9 @@ class NucleiPlugin(PluginBase):
195
195
  import os
196
196
  from pathlib import Path
197
197
 
198
- # Check common template locations
198
+ # Check common template locations (nuclei v3 uses ~/.local/nuclei-templates)
199
199
  template_paths = [
200
+ Path.home() / ".local" / "nuclei-templates", # nuclei v3 default
200
201
  Path.home() / "nuclei-templates",
201
202
  Path.home() / ".nuclei-templates",
202
203
  Path("/usr/share/nuclei-templates"),
@@ -126,27 +126,30 @@ class SearchSploitPlugin(PluginBase):
126
126
 
127
127
  def build_command(self, target: str, args: List[str] = None, label: str = "", log_path: str = None):
128
128
  """Build command for background execution with PID tracking."""
129
- # Validate target
130
- if not target or target.lower() == 'none':
131
- raise ValueError("SearchSploit requires a search term. Usage: souleyez run searchsploit 'Apache 2.4.49'")
132
-
133
129
  args = args or []
134
-
130
+
135
131
  # Replace <target> placeholder
136
132
  args = [arg.replace("<target>", target) for arg in args]
137
-
138
- # searchsploit syntax: searchsploit [--json] search_term [args]
139
- cmd = ["searchsploit"]
140
-
141
- # Force JSON output for parsing
142
- if "--json" not in args:
143
- cmd.append("--json")
144
-
145
- # Add search term
146
- cmd.append(target)
147
-
148
- # Add user args (after target)
149
- cmd.extend(args)
133
+
134
+ # searchsploit syntax: searchsploit [--json] search_term
135
+ cmd = ["searchsploit", "--json"]
136
+
137
+ # Determine search term: use args if they contain a non-flag search term,
138
+ # otherwise use target (but skip if target is a URL - not a valid search term)
139
+ non_flag_args = [a for a in args if not a.startswith('-')]
140
+
141
+ if non_flag_args:
142
+ # Args contain search term(s) - use those, ignore URL target
143
+ cmd.extend(non_flag_args)
144
+ elif target and not target.startswith(('http://', 'https://')):
145
+ # Target is not a URL - use it as search term
146
+ cmd.append(target)
147
+ else:
148
+ raise ValueError("SearchSploit requires a search term. Usage: souleyez run searchsploit 'Apache 2.4.49'")
149
+
150
+ # Add remaining flag args (excluding --json which is already added)
151
+ flag_args = [a for a in args if a.startswith('-') and a != '--json']
152
+ cmd.extend(flag_args)
150
153
 
151
154
  return {
152
155
  'cmd': cmd,