secator 0.6.0__py3-none-any.whl → 0.8.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.

Potentially problematic release.


This version of secator might be problematic. Click here for more details.

Files changed (90) hide show
  1. secator/celery.py +160 -185
  2. secator/celery_utils.py +268 -0
  3. secator/cli.py +427 -176
  4. secator/config.py +114 -68
  5. secator/configs/workflows/host_recon.yaml +5 -3
  6. secator/configs/workflows/port_scan.yaml +7 -3
  7. secator/configs/workflows/subdomain_recon.yaml +2 -2
  8. secator/configs/workflows/url_bypass.yaml +10 -0
  9. secator/configs/workflows/url_dirsearch.yaml +1 -1
  10. secator/configs/workflows/url_vuln.yaml +1 -1
  11. secator/decorators.py +170 -92
  12. secator/definitions.py +11 -4
  13. secator/exporters/__init__.py +7 -5
  14. secator/exporters/console.py +10 -0
  15. secator/exporters/csv.py +27 -19
  16. secator/exporters/gdrive.py +16 -11
  17. secator/exporters/json.py +3 -1
  18. secator/exporters/table.py +30 -2
  19. secator/exporters/txt.py +20 -16
  20. secator/hooks/gcs.py +53 -0
  21. secator/hooks/mongodb.py +53 -27
  22. secator/installer.py +277 -60
  23. secator/output_types/__init__.py +29 -11
  24. secator/output_types/_base.py +11 -1
  25. secator/output_types/error.py +36 -0
  26. secator/output_types/exploit.py +12 -8
  27. secator/output_types/info.py +24 -0
  28. secator/output_types/ip.py +8 -1
  29. secator/output_types/port.py +9 -2
  30. secator/output_types/progress.py +5 -0
  31. secator/output_types/record.py +5 -3
  32. secator/output_types/stat.py +33 -0
  33. secator/output_types/subdomain.py +1 -1
  34. secator/output_types/tag.py +8 -6
  35. secator/output_types/target.py +2 -2
  36. secator/output_types/url.py +14 -11
  37. secator/output_types/user_account.py +6 -6
  38. secator/output_types/vulnerability.py +8 -6
  39. secator/output_types/warning.py +24 -0
  40. secator/report.py +56 -23
  41. secator/rich.py +44 -39
  42. secator/runners/_base.py +629 -638
  43. secator/runners/_helpers.py +5 -91
  44. secator/runners/celery.py +18 -0
  45. secator/runners/command.py +404 -214
  46. secator/runners/scan.py +8 -24
  47. secator/runners/task.py +21 -55
  48. secator/runners/workflow.py +41 -40
  49. secator/scans/__init__.py +28 -0
  50. secator/serializers/dataclass.py +6 -0
  51. secator/serializers/json.py +10 -5
  52. secator/serializers/regex.py +12 -4
  53. secator/tasks/_categories.py +147 -42
  54. secator/tasks/bbot.py +295 -0
  55. secator/tasks/bup.py +99 -0
  56. secator/tasks/cariddi.py +38 -49
  57. secator/tasks/dalfox.py +3 -0
  58. secator/tasks/dirsearch.py +14 -25
  59. secator/tasks/dnsx.py +49 -30
  60. secator/tasks/dnsxbrute.py +4 -1
  61. secator/tasks/feroxbuster.py +10 -20
  62. secator/tasks/ffuf.py +3 -2
  63. secator/tasks/fping.py +4 -4
  64. secator/tasks/gau.py +5 -0
  65. secator/tasks/gf.py +2 -2
  66. secator/tasks/gospider.py +4 -0
  67. secator/tasks/grype.py +11 -13
  68. secator/tasks/h8mail.py +32 -42
  69. secator/tasks/httpx.py +58 -21
  70. secator/tasks/katana.py +19 -23
  71. secator/tasks/maigret.py +27 -25
  72. secator/tasks/mapcidr.py +2 -3
  73. secator/tasks/msfconsole.py +22 -19
  74. secator/tasks/naabu.py +18 -2
  75. secator/tasks/nmap.py +82 -55
  76. secator/tasks/nuclei.py +13 -3
  77. secator/tasks/searchsploit.py +26 -11
  78. secator/tasks/subfinder.py +5 -1
  79. secator/tasks/wpscan.py +91 -94
  80. secator/template.py +61 -45
  81. secator/thread.py +24 -0
  82. secator/utils.py +417 -78
  83. secator/utils_test.py +48 -23
  84. secator/workflows/__init__.py +28 -0
  85. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/METADATA +59 -48
  86. secator-0.8.0.dist-info/RECORD +115 -0
  87. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/WHEEL +1 -1
  88. secator-0.6.0.dist-info/RECORD +0 -101
  89. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/entry_points.txt +0 -0
  90. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,25 @@
1
+ import copy
1
2
  import getpass
2
3
  import logging
3
4
  import os
4
5
  import re
5
6
  import shlex
7
+ import signal
6
8
  import subprocess
7
9
  import sys
10
+ import uuid
8
11
 
9
- from time import sleep
12
+ from time import time
10
13
 
14
+ import psutil
11
15
  from fp.fp import FreeProxy
12
16
 
13
- from secator.template import TemplateLoader
14
17
  from secator.definitions import OPT_NOT_SUPPORTED, OPT_PIPE_INPUT
15
18
  from secator.config import CONFIG
19
+ from secator.output_types import Info, Error, Target, Stat
16
20
  from secator.runners import Runner
17
- from secator.serializers import JSONSerializer
18
- from secator.utils import debug
21
+ from secator.template import TemplateLoader
22
+ from secator.utils import debug, rich_escape as _s
19
23
 
20
24
 
21
25
  logger = logging.getLogger(__name__)
@@ -53,18 +57,18 @@ class Command(Runner):
53
57
  # Output encoding
54
58
  encoding = 'utf-8'
55
59
 
56
- # Environment variables
57
- env = {}
58
-
59
60
  # Flag to take the input
60
61
  input_flag = None
61
62
 
62
63
  # Input path (if a file is constructed)
63
64
  input_path = None
64
65
 
65
- # Input chunk size (default None)
66
+ # Input chunk size
66
67
  input_chunk_size = CONFIG.runners.input_chunk_size
67
68
 
69
+ # Input required
70
+ input_required = True
71
+
68
72
  # Flag to take a file as input
69
73
  file_flag = None
70
74
 
@@ -75,12 +79,21 @@ class Command(Runner):
75
79
  version_flag = None
76
80
 
77
81
  # Install
82
+ install_pre = None
83
+ install_post = None
78
84
  install_cmd = None
79
85
  install_github_handle = None
80
86
 
81
87
  # Serializer
82
88
  item_loader = None
83
- item_loaders = [JSONSerializer(),]
89
+ item_loaders = []
90
+
91
+ # Hooks
92
+ hooks = [
93
+ 'on_cmd',
94
+ 'on_cmd_done',
95
+ 'on_line'
96
+ ]
84
97
 
85
98
  # Ignore return code
86
99
  ignore_return_code = False
@@ -88,9 +101,6 @@ class Command(Runner):
88
101
  # Return code
89
102
  return_code = -1
90
103
 
91
- # Error
92
- error = ''
93
-
94
104
  # Output
95
105
  output = ''
96
106
 
@@ -102,7 +112,8 @@ class Command(Runner):
102
112
  # Profile
103
113
  profile = 'cpu'
104
114
 
105
- def __init__(self, input=None, **run_opts):
115
+ def __init__(self, inputs=[], **run_opts):
116
+
106
117
  # Build runnerconfig on-the-fly
107
118
  config = TemplateLoader(input={
108
119
  'name': self.__class__.__name__,
@@ -110,26 +121,45 @@ class Command(Runner):
110
121
  'description': run_opts.get('description', None)
111
122
  })
112
123
 
113
- # Run parent init
124
+ # Extract run opts
114
125
  hooks = run_opts.pop('hooks', {})
126
+ caller = run_opts.get('caller', None)
115
127
  results = run_opts.pop('results', [])
116
128
  context = run_opts.pop('context', {})
129
+ self.skip_if_no_inputs = run_opts.pop('skip_if_no_inputs', False)
130
+
131
+ # Prepare validators
132
+ input_validators = []
133
+ if not self.skip_if_no_inputs:
134
+ input_validators.append(self._validate_input_nonempty)
135
+ if not caller:
136
+ input_validators.append(self._validate_chunked_input)
137
+ validators = {'validate_input': input_validators}
138
+
139
+ # Call super().__init__
117
140
  super().__init__(
118
141
  config=config,
119
- targets=input,
142
+ inputs=inputs,
120
143
  results=results,
121
144
  run_opts=run_opts,
122
145
  hooks=hooks,
146
+ validators=validators,
123
147
  context=context)
124
148
 
149
+ # Inputs path
150
+ self.inputs_path = None
151
+
125
152
  # Current working directory for cmd
126
153
  self.cwd = self.run_opts.get('cwd', None)
127
154
 
128
- # No capturing of stdout / stderr.
129
- self.no_capture = self.run_opts.get('no_capture', False)
155
+ # Print cmd
156
+ self.print_cmd = self.run_opts.get('print_cmd', False)
130
157
 
131
- # No processing of output lines.
132
- self.no_process = self.run_opts.get('no_process', False)
158
+ # Stat update
159
+ self.last_updated_stat = None
160
+
161
+ # Process
162
+ self.process = None
133
163
 
134
164
  # Proxy config (global)
135
165
  self.proxy = self.run_opts.pop('proxy', False)
@@ -141,6 +171,9 @@ class Command(Runner):
141
171
  # Build command
142
172
  self._build_cmd()
143
173
 
174
+ # Run on_cmd hook
175
+ self.run_hooks('on_cmd')
176
+
144
177
  # Build item loaders
145
178
  instance_func = getattr(self, 'item_loader', None)
146
179
  item_loaders = self.item_loaders.copy()
@@ -148,40 +181,6 @@ class Command(Runner):
148
181
  item_loaders.append(instance_func)
149
182
  self.item_loaders = item_loaders
150
183
 
151
- # Print built cmd
152
- if self.print_cmd and not self.has_children:
153
- if self.sync and self.description:
154
- self._print(f'\n:wrench: {self.description} ...', color='bold gold3', rich=True)
155
- self._print(self.cmd.replace('[', '\\['), color='bold cyan', rich=True)
156
-
157
- # Print built input
158
- if self.print_input_file and self.input_path:
159
- input_str = '\n '.join(self.input).strip()
160
- debug(f'[dim magenta]File input:[/]\n [italic medium_turquoise]{input_str}[/]')
161
-
162
- # Print run options
163
- if self.print_run_opts:
164
- input_str = '\n '.join([
165
- f'[dim blue]{k}[/] -> [dim green]{v}[/]' for k, v in self.run_opts.items() if v is not None]).strip()
166
- debug(f'[dim magenta]Run opts:[/]\n {input_str}')
167
-
168
- # Print format options
169
- if self.print_fmt_opts:
170
- input_str = '\n '.join([
171
- f'[dim blue]{k}[/] -> [dim green]{v}[/]' for k, v in self.opts_to_print.items() if v is not None]).strip()
172
- debug(f'[dim magenta]Print opts:[/]\n {input_str}')
173
-
174
- # Print hooks
175
- if self.print_hooks:
176
- input_str = ''
177
- for hook_name, hook_funcs in self.hooks.items():
178
- hook_funcs_str = ', '.join([f'[dim green]{h.__module__}.{h.__qualname__}[/]' for h in hook_funcs])
179
- if hook_funcs:
180
- input_str += f'[dim blue]{hook_name}[/] -> {hook_funcs_str}\n '
181
- input_str = input_str.strip()
182
- if input_str:
183
- debug(f'[dim magenta]Hooks:[/]\n {input_str}')
184
-
185
184
  def toDict(self):
186
185
  res = super().toDict()
187
186
  res.update({
@@ -196,6 +195,7 @@ class Command(Runner):
196
195
  # TODO: Move this to TaskBase
197
196
  from secator.celery import run_command
198
197
  results = kwargs.get('results', [])
198
+ kwargs['sync'] = False
199
199
  name = cls.__name__
200
200
  return run_command.apply_async(args=[results, name] + list(args), kwargs={'opts': kwargs}, queue=cls.profile)
201
201
 
@@ -206,7 +206,7 @@ class Command(Runner):
206
206
  return run_command.s(cls.__name__, *args, opts=kwargs).set(queue=cls.profile)
207
207
 
208
208
  @classmethod
209
- def si(cls, results, *args, **kwargs):
209
+ def si(cls, *args, results=[], **kwargs):
210
210
  # TODO: Move this to TaskBase
211
211
  from secator.celery import run_command
212
212
  return run_command.si(results, cls.__name__, *args, opts=kwargs).set(queue=cls.profile)
@@ -226,12 +226,14 @@ class Command(Runner):
226
226
  d[k] = v.__name__
227
227
  return d
228
228
 
229
- opts = {k: convert(v) for k, v in cls.opts.items()}
229
+ cls_opts = copy.deepcopy(cls.opts)
230
+ opts = {k: convert(v) for k, v in cls_opts.items()}
230
231
  for k, v in opts.items():
231
232
  v['meta'] = cls.__name__
232
233
  v['supported'] = True
233
234
 
234
- meta_opts = {k: convert(v) for k, v in cls.meta_opts.items() if cls.opt_key_map.get(k) is not OPT_NOT_SUPPORTED}
235
+ cls_meta_opts = copy.deepcopy(cls.meta_opts)
236
+ meta_opts = {k: convert(v) for k, v in cls_meta_opts.items() if cls.opt_key_map.get(k) is not OPT_NOT_SUPPORTED}
235
237
  for k, v in meta_opts.items():
236
238
  v['meta'] = 'meta'
237
239
  if cls.opt_key_map.get(k) is OPT_NOT_SUPPORTED:
@@ -247,7 +249,7 @@ class Command(Runner):
247
249
  #---------------#
248
250
 
249
251
  @classmethod
250
- def execute(cls, cmd, name=None, cls_attributes={}, **kwargs):
252
+ def execute(cls, cmd, name=None, cls_attributes={}, run=True, **kwargs):
251
253
  """Execute an ad-hoc command.
252
254
 
253
255
  Can be used without defining an inherited class to run a command, while still enjoying all the good stuff in
@@ -264,15 +266,13 @@ class Command(Runner):
264
266
  secator.runners.Command: instance of the Command.
265
267
  """
266
268
  name = name or cmd.split(' ')[0]
267
- kwargs['no_process'] = kwargs.get('no_process', True)
268
269
  kwargs['print_cmd'] = not kwargs.get('quiet', False)
269
- kwargs['print_item'] = not kwargs.get('quiet', False)
270
- kwargs['print_line'] = not kwargs.get('quiet', False)
271
- delay_run = kwargs.pop('delay_run', False)
272
- cmd_instance = type(name, (Command,), {'cmd': cmd})(**kwargs)
270
+ kwargs['print_line'] = True
271
+ kwargs['no_process'] = kwargs.get('no_process', True)
272
+ cmd_instance = type(name, (Command,), {'cmd': cmd, 'input_required': False})(**kwargs)
273
273
  for k, v in cls_attributes.items():
274
274
  setattr(cmd_instance, k, v)
275
- if not delay_run:
275
+ if run:
276
276
  cmd_instance.run()
277
277
  return cmd_instance
278
278
 
@@ -328,124 +328,257 @@ class Command(Runner):
328
328
 
329
329
  Yields:
330
330
  str: Command stdout / stderr.
331
- dict: Parsed JSONLine object.
331
+ dict: Serialized object.
332
332
  """
333
- # Set status to 'RUNNING'
334
- self.status = 'RUNNING'
333
+ try:
335
334
 
336
- # Callback before running command
337
- self.run_hooks('on_start')
335
+ # Abort if it has children tasks
336
+ if self.has_children:
337
+ return
338
338
 
339
- # Check for sudo requirements and prepare the password if needed
340
- sudo_password = self._prompt_sudo(self.cmd)
339
+ # Print task description
340
+ self.print_description()
341
341
 
342
- # Prepare cmds
343
- command = self.cmd if self.shell else shlex.split(self.cmd)
342
+ # Abort if no inputs
343
+ if len(self.inputs) == 0 and self.skip_if_no_inputs:
344
+ yield Info(message=f'{self.unique_name} skipped (no inputs)', _source=self.unique_name, _uuid=str(uuid.uuid4()))
345
+ return
344
346
 
345
- # Output and results
346
- self.return_code = 0
347
- self.killed = False
347
+ # Yield targets
348
+ for input in self.inputs:
349
+ yield Target(name=input, _source=self.unique_name, _uuid=str(uuid.uuid4()))
350
+
351
+ # Check for sudo requirements and prepare the password if needed
352
+ sudo_password, error = self._prompt_sudo(self.cmd)
353
+ if error:
354
+ yield Error(
355
+ message=error,
356
+ _source=self.unique_name,
357
+ _uuid=str(uuid.uuid4())
358
+ )
359
+ return
348
360
 
349
- # Run the command using subprocess
350
- try:
361
+ # Prepare cmds
362
+ command = self.cmd if self.shell else shlex.split(self.cmd)
363
+
364
+ # Check command is installed and auto-install
365
+ if not self.no_process and not self.is_installed():
366
+ if CONFIG.security.auto_install_commands:
367
+ from secator.installer import ToolInstaller
368
+ yield Info(
369
+ message=f'Command {self.name} is missing but auto-installing since security.autoinstall_commands is set', # noqa: E501
370
+ _source=self.unique_name,
371
+ _uuid=str(uuid.uuid4())
372
+ )
373
+ status = ToolInstaller.install(self.__class__)
374
+ if not status.is_ok():
375
+ yield Error(
376
+ message=f'Failed installing {self.name}',
377
+ _source=self.unique_name,
378
+ _uuid=str(uuid.uuid4())
379
+ )
380
+ return
381
+
382
+ # Output and results
383
+ self.return_code = 0
384
+ self.killed = False
385
+
386
+ # Run the command using subprocess
351
387
  env = os.environ
352
- env.update(self.env)
353
388
  self.process = subprocess.Popen(
354
389
  command,
355
390
  stdin=subprocess.PIPE if sudo_password else None,
356
- stdout=sys.stdout if self.no_capture else subprocess.PIPE,
357
- stderr=sys.stderr if self.no_capture else subprocess.STDOUT,
391
+ stdout=subprocess.PIPE,
392
+ stderr=subprocess.STDOUT,
358
393
  universal_newlines=True,
359
394
  shell=self.shell,
360
395
  env=env,
361
396
  cwd=self.cwd)
397
+ self.print_command()
362
398
 
363
399
  # If sudo password is provided, send it to stdin
364
400
  if sudo_password:
365
401
  self.process.stdin.write(f"{sudo_password}\n")
366
402
  self.process.stdin.flush()
367
403
 
368
- except FileNotFoundError as e:
369
- if self.config.name in str(e):
370
- error = 'Executable not found.'
371
- if self.install_cmd:
372
- error += f' Install it with `secator install tools {self.config.name}`.'
373
- else:
374
- error = str(e)
375
- celery_id = self.context.get('celery_id', '')
376
- if celery_id:
377
- error += f' [{celery_id}]'
378
- self.errors.append(error)
379
- self.return_code = 1
380
- return
381
-
382
- try:
383
- # No capture mode, wait for command to finish and return
384
- if self.no_capture:
385
- self._wait_for_end()
386
- return
387
-
388
404
  # Process the output in real-time
389
405
  for line in iter(lambda: self.process.stdout.readline(), b''):
390
- sleep(0) # for async to give up control
406
+ # sleep(0) # for async to give up control
391
407
  if not line:
392
408
  break
409
+ yield from self.process_line(line)
393
410
 
394
- # Strip line endings
395
- line = line.rstrip()
396
- if self.no_process:
397
- yield line
398
- continue
411
+ # Run hooks after cmd has completed successfully
412
+ result = self.run_hooks('on_cmd_done')
413
+ if result:
414
+ yield from result
415
+
416
+ except FileNotFoundError as e:
417
+ yield from self.handle_file_not_found(e)
418
+
419
+ except BaseException as e:
420
+ self.debug(f'{self.unique_name}: {type(e).__name__}.', sub='error')
421
+ self.stop_process()
422
+ yield Error.from_exception(e, _source=self.unique_name, _uuid=str(uuid.uuid4()))
423
+
424
+ finally:
425
+ yield from self._wait_for_end()
426
+
427
+ def is_installed(self):
428
+ """Check if a command is installed by using `which`.
429
+
430
+ Args:
431
+ command (str): The command to check.
432
+
433
+ Returns:
434
+ bool: True if the command is installed, False otherwise.
435
+ """
436
+ result = subprocess.Popen(["which", self.cmd.split(' ')[0]], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
437
+ result.communicate()
438
+ return result.returncode == 0
439
+
440
+ def process_line(self, line):
441
+ """Process a single line of output emitted on stdout / stderr and yield results."""
442
+
443
+ # Strip line endings
444
+ line = line.rstrip()
399
445
 
400
- # Some commands output ANSI text, so we need to remove those ANSI chars
401
- if self.encoding == 'ansi':
402
- # ansi_regex = r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[K]?'
403
- # line = re.sub(ansi_regex, '', line.strip())
404
- ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
405
- line = ansi_escape.sub('', line)
406
- line = line.replace('\\x0d\\x0a', '\n')
446
+ # Some commands output ANSI text, so we need to remove those ANSI chars
447
+ if self.encoding == 'ansi':
448
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
449
+ line = ansi_escape.sub('', line)
450
+ line = line.replace('\\x0d\\x0a', '\n')
407
451
 
408
- # Run on_line hooks
409
- line = self.run_hooks('on_line', line)
452
+ # Run on_line hooks
453
+ line = self.run_hooks('on_line', line)
454
+ if line is None:
455
+ return
456
+
457
+ # Run item_loader to try parsing as dict
458
+ item_count = 0
459
+ for item in self.run_item_loaders(line):
460
+ yield item
461
+ item_count += 1
462
+
463
+ # Yield line if no items were yielded
464
+ if item_count == 0:
465
+ yield line
466
+
467
+ # Skip rest of iteration (no process mode)
468
+ if self.no_process:
469
+ return
470
+
471
+ # Yield command stats (CPU, memory, conns ...)
472
+ # TODO: enable stats support with timer
473
+ if self.last_updated_stat and (time() - self.last_updated_stat) < CONFIG.runners.stat_update_frequency:
474
+ return
410
475
 
411
- # Run item_loader to try parsing as dict
412
- items = None
413
- if self.output_json:
414
- items = self.run_item_loaders(line)
476
+ yield from self.stats()
477
+ self.last_updated_stat = time()
415
478
 
416
- # Yield line if no items parsed
417
- if not items:
418
- yield line
479
+ def print_description(self):
480
+ """Print description"""
481
+ if self.sync and not self.has_children:
482
+ if self.caller and self.description:
483
+ self._print(f'\n[bold gold3]:wrench: {self.description} [dim cyan]({self.config.name})[/][/] ...', rich=True)
419
484
 
420
- # Turn results into list if not already a list
421
- elif not isinstance(items, list):
422
- items = [items]
485
+ def print_command(self):
486
+ """Print command."""
487
+ if self.print_cmd:
488
+ cmd_str = _s(self.cmd)
489
+ if self.sync and self.chunk and self.chunk_count:
490
+ cmd_str += f' [dim gray11]({self.chunk}/{self.chunk_count})[/]'
491
+ self._print(cmd_str, color='bold cyan', rich=True)
492
+ self.debug('Command', obj={'cmd': self.cmd}, sub='init')
493
+
494
+ def handle_file_not_found(self, exc):
495
+ """Handle case where binary is not found.
496
+
497
+ Args:
498
+ exc (FileNotFoundError): the exception.
499
+
500
+ Yields:
501
+ secator.output_types.Error: the error.
502
+ """
503
+ self.return_code = 127
504
+ if self.config.name in str(exc):
505
+ message = 'Executable not found.'
506
+ if self.install_cmd:
507
+ message += f' Install it with [bold green4]secator install tools {self.config.name}[/].'
508
+ error = Error(message=message)
509
+ else:
510
+ error = Error.from_exception(exc)
511
+ error._source = self.unique_name
512
+ error._uuid = str(uuid.uuid4())
513
+ yield error
514
+
515
+ def stop_process(self):
516
+ """Sends SIGINT to running process, if any."""
517
+ if not self.process:
518
+ return
519
+ self.debug(f'Sending SIGINT to process {self.process.pid}.', sub='error')
520
+ self.process.send_signal(signal.SIGINT)
423
521
 
424
- # Yield items
425
- if items:
426
- yield from items
522
+ def stats(self):
523
+ """Gather stats about the current running process, if any."""
524
+ if not self.process or not self.process.pid:
525
+ return
526
+ proc = psutil.Process(self.process.pid)
527
+ stats = Command.get_process_info(proc, children=True)
528
+ for info in stats:
529
+ name = info['name']
530
+ pid = info['pid']
531
+ cpu_percent = info['cpu_percent']
532
+ mem_percent = info['memory_percent']
533
+ net_conns = info.get('net_connections') or []
534
+ extra_data = {k: v for k, v in info.items() if k not in ['cpu_percent', 'memory_percent', 'net_connections']}
535
+ yield Stat(
536
+ name=name,
537
+ pid=pid,
538
+ cpu=cpu_percent,
539
+ memory=mem_percent,
540
+ net_conns=len(net_conns),
541
+ extra_data=extra_data
542
+ )
427
543
 
428
- except KeyboardInterrupt:
429
- self.process.kill()
430
- self.killed = True
544
+ @staticmethod
545
+ def get_process_info(process, children=False):
546
+ """Get process information from psutil.
431
547
 
432
- # Retrieve the return code and output
433
- self._wait_for_end()
548
+ Args:
549
+ process (subprocess.Process): Process.
550
+ children (bool): Whether to gather stats about children processes too.
551
+ """
552
+ try:
553
+ data = {
554
+ k: v._asdict() if hasattr(v, '_asdict') else v
555
+ for k, v in process.as_dict().items()
556
+ if k not in ['memory_maps', 'open_files', 'environ']
557
+ }
558
+ yield data
559
+ except (psutil.Error, FileNotFoundError):
560
+ return
561
+ if children:
562
+ for subproc in process.children(recursive=True):
563
+ yield from Command.get_process_info(subproc, children=False)
434
564
 
435
565
  def run_item_loaders(self, line):
436
- """Run item loaders on a string."""
437
- items = []
566
+ """Run item loaders against an output line.
567
+
568
+ Args:
569
+ line (str): Output line.
570
+ """
571
+ if self.no_process:
572
+ return
438
573
  for item_loader in self.item_loaders:
439
- result = None
440
574
  if (callable(item_loader)):
441
- result = item_loader(self, line)
575
+ yield from item_loader(self, line)
442
576
  elif item_loader:
443
- result = item_loader.run(line)
444
- if isinstance(result, dict):
445
- result = [result]
446
- if result:
447
- items.extend(result)
448
- return items
577
+ name = item_loader.__class__.__name__.replace('Serializer', '').lower()
578
+ default_callback = lambda self, x: [(yield x)] # noqa: E731
579
+ callback = getattr(self, f'on_{name}_loaded', None) or default_callback
580
+ for item in item_loader.run(line):
581
+ yield from callback(self, item)
449
582
 
450
583
  def _prompt_sudo(self, command):
451
584
  """
@@ -455,27 +588,31 @@ class Command(Runner):
455
588
  command (str): The initial command to be executed.
456
589
 
457
590
  Returns:
458
- str or None: The sudo password if required; otherwise, None.
591
+ tuple: (sudo password, error).
459
592
  """
460
593
  sudo_password = None
461
594
 
462
595
  # Check if sudo is required by the command
463
596
  if not re.search(r'\bsudo\b', command):
464
- return None
597
+ return None, []
465
598
 
466
599
  # Check if sudo can be executed without a password
467
- if subprocess.run(['sudo', '-n', 'true'], capture_output=True).returncode == 0:
468
- return None
600
+ try:
601
+ if subprocess.run(['sudo', '-n', 'true'], capture_output=False).returncode == 0:
602
+ return None, None
603
+ except ValueError:
604
+ self._print('[bold orange3]Could not run sudo check test.[/][bold green]Passing.[/]')
469
605
 
470
606
  # Check if we have a tty
471
607
  if not os.isatty(sys.stdin.fileno()):
472
- self._print("No TTY detected. Sudo password prompt requires a TTY to proceed.", color='bold red')
473
- sys.exit(1)
608
+ error = "No TTY detected. Sudo password prompt requires a TTY to proceed."
609
+ return -1, error
474
610
 
475
611
  # If not, prompt the user for a password
476
- self._print('[bold red]Please enter sudo password to continue.[/]')
612
+ self._print('[bold red]Please enter sudo password to continue.[/]', rich=True)
477
613
  for _ in range(3):
478
- self._print('\[sudo] password: ')
614
+ user = getpass.getuser()
615
+ self._print(rf'\[sudo] password for {user}: ▌', rich=True)
479
616
  sudo_password = getpass.getpass()
480
617
  result = subprocess.run(
481
618
  ['sudo', '-S', '-p', '', 'true'],
@@ -484,31 +621,43 @@ class Command(Runner):
484
621
  capture_output=True
485
622
  )
486
623
  if result.returncode == 0:
487
- return sudo_password # Password is correct
624
+ return sudo_password, None # Password is correct
488
625
  self._print("Sorry, try again.")
489
- self._print("Sudo password verification failed after 3 attempts.")
490
- return None
626
+ error = "Sudo password verification failed after 3 attempts."
627
+ return -1, error
491
628
 
492
629
  def _wait_for_end(self):
493
630
  """Wait for process to finish and process output and return code."""
631
+ if not self.process:
632
+ return
633
+ for line in self.process.stdout.readlines():
634
+ yield from self.process_line(line)
494
635
  self.process.wait()
495
636
  self.return_code = self.process.returncode
637
+ self.process.stdout.close()
638
+ self.return_code = 0 if self.ignore_return_code else self.return_code
639
+ self.output = self.output.strip()
640
+ self.killed = self.return_code == -2 or self.killed
641
+ self.debug(f'Command {self.cmd} finished with return code {self.return_code}', sub='command')
496
642
 
497
- if self.no_capture:
498
- self.output = ''
499
- else:
500
- self.output = self.output.strip()
501
- self.process.stdout.close()
502
-
503
- if self.ignore_return_code:
504
- self.return_code = 0
505
-
506
- if self.return_code == -2 or self.killed:
643
+ if self.killed:
507
644
  error = 'Process was killed manually (CTRL+C / CTRL+X)'
508
- self.errors.append(error)
645
+ yield Error(
646
+ message=error,
647
+ _source=self.unique_name,
648
+ _uuid=str(uuid.uuid4())
649
+ )
650
+
509
651
  elif self.return_code != 0:
510
652
  error = f'Command failed with return code {self.return_code}.'
511
- self.errors.append(error)
653
+ last_lines = self.output.split('\n')
654
+ last_lines = last_lines[max(0, len(last_lines) - 2):]
655
+ yield Error(
656
+ message=error,
657
+ traceback='\n'.join(last_lines),
658
+ _source=self.unique_name,
659
+ _uuid=str(uuid.uuid4())
660
+ )
512
661
 
513
662
  @staticmethod
514
663
  def _process_opts(
@@ -518,15 +667,19 @@ class Command(Runner):
518
667
  opt_value_map={},
519
668
  opt_prefix='-',
520
669
  command_name=None):
521
- """Process a dict of options using a config, option key map / value map
522
- and option character like '-' or '--'.
670
+ """Process a dict of options using a config, option key map / value map and option character like '-' or '--'.
523
671
 
524
672
  Args:
525
673
  opts (dict): Command options as input on the CLI.
526
674
  opts_conf (dict): Options config (Click options definition).
675
+ opt_key_map (dict[str, str | Callable]): A dict to map option key with their actual values.
676
+ opt_value_map (dict, str | Callable): A dict to map option values with their actual values.
677
+ opt_prefix (str, default: '-'): Option prefix.
678
+ command_name (str | None, default: None): Command name.
527
679
  """
528
680
  opts_str = ''
529
681
  for opt_name, opt_conf in opts_conf.items():
682
+ debug('before get_opt_value', obj={'name': opt_name, 'conf': opt_conf}, obj_after=False, sub='command.options', verbose=True) # noqa: E501
530
683
 
531
684
  # Get opt value
532
685
  default_val = opt_conf.get('default')
@@ -537,25 +690,35 @@ class Command(Runner):
537
690
  opt_prefix=command_name,
538
691
  default=default_val)
539
692
 
693
+ debug('after get_opt_value', obj={'name': opt_name, 'value': opt_val, 'conf': opt_conf}, obj_after=False, sub='command.options', verbose=True) # noqa: E501
694
+
540
695
  # Skip option if value is falsy
541
696
  if opt_val in [None, False, []]:
542
- # logger.debug(f'Option {opt_name} was passed but is falsy. Skipping.')
697
+ debug('skipped (falsy)', obj={'name': opt_name, 'value': opt_val}, obj_after=False, sub='command.options', verbose=True) # noqa: E501
543
698
  continue
544
699
 
700
+ # Apply process function on opt value
701
+ if 'process' in opt_conf:
702
+ func = opt_conf['process']
703
+ opt_val = func(opt_val)
704
+
545
705
  # Convert opt value to expected command opt value
546
706
  mapped_opt_val = opt_value_map.get(opt_name)
547
- if callable(mapped_opt_val):
548
- opt_val = mapped_opt_val(opt_val)
549
- elif mapped_opt_val:
550
- opt_val = mapped_opt_val
707
+ if mapped_opt_val:
708
+ if callable(mapped_opt_val):
709
+ opt_val = mapped_opt_val(opt_val)
710
+ else:
711
+ opt_val = mapped_opt_val
551
712
 
552
713
  # Convert opt name to expected command opt name
553
714
  mapped_opt_name = opt_key_map.get(opt_name)
554
- if mapped_opt_name == OPT_NOT_SUPPORTED:
555
- # logger.debug(f'Option {opt_name} was passed but is unsupported. Skipping.')
556
- continue
557
- elif mapped_opt_name is not None:
558
- opt_name = mapped_opt_name
715
+ if mapped_opt_name is not None:
716
+ if mapped_opt_name == OPT_NOT_SUPPORTED:
717
+ debug('skipped (unsupported)', obj={'name': opt_name, 'value': opt_val}, sub='command.options', verbose=True) # noqa: E501
718
+ continue
719
+ else:
720
+ opt_name = mapped_opt_name
721
+ debug('mapped key / value', obj={'name': opt_name, 'value': opt_val}, obj_after=False, sub='command.options', verbose=True) # noqa: E501
559
722
 
560
723
  # Avoid shell injections and detect opt prefix
561
724
  opt_name = str(opt_name).split(' ')[0] # avoid cmd injection
@@ -570,31 +733,66 @@ class Command(Runner):
570
733
  # Append opt name + opt value to option string.
571
734
  # Note: does not append opt value if value is True (flag)
572
735
  opts_str += f' {opt_name}'
736
+ shlex_quote = opt_conf.get('shlex', True)
573
737
  if opt_val is not True:
574
- opt_val = shlex.quote(str(opt_val))
738
+ if shlex_quote:
739
+ opt_val = shlex.quote(str(opt_val))
575
740
  opts_str += f' {opt_val}'
741
+ debug('final', obj={'name': opt_name, 'value': opt_val}, sub='command.options', obj_after=False, verbose=True)
576
742
 
577
743
  return opts_str.strip()
578
744
 
745
+ @staticmethod
746
+ def _validate_chunked_input(self, inputs):
747
+ """Command does not suport multiple inputs in non-worker mode. Consider using .delay() instead."""
748
+ if len(inputs) > 1 and self.sync and self.file_flag is None:
749
+ return False
750
+ return True
751
+
752
+ @staticmethod
753
+ def _validate_input_nonempty(self, inputs):
754
+ """Input is empty."""
755
+ if not self.input_required:
756
+ return True
757
+ if not inputs or len(inputs) == 0:
758
+ return False
759
+ return True
760
+
761
+ # @staticmethod
762
+ # def _validate_input_types_valid(self, input):
763
+ # pass
764
+
765
+ @staticmethod
766
+ def _get_opt_default(opt_name, opts_conf):
767
+ for k, v in opts_conf.items():
768
+ if k == opt_name:
769
+ return v.get('default', None)
770
+ return None
771
+
579
772
  @staticmethod
580
773
  def _get_opt_value(opts, opt_name, opts_conf={}, opt_prefix='', default=None):
581
- aliases = [
582
- opts.get(f'{opt_prefix}_{opt_name}'),
583
- opts.get(f'{opt_prefix}.{opt_name}'),
584
- opts.get(opt_name),
774
+ default = default or Command._get_opt_default(opt_name, opts_conf)
775
+ opt_names = [
776
+ f'{opt_prefix}.{opt_name}',
777
+ f'{opt_prefix}_{opt_name}',
778
+ opt_name,
585
779
  ]
586
- alias = [conf.get('short') for _, conf in opts_conf.items() if conf.get('short') in opts]
780
+ opt_values = [opts.get(o) for o in opt_names]
781
+ alias = [conf.get('short') for _, conf in opts_conf.items() if conf.get('short') in opts and _ == opt_name]
587
782
  if alias:
588
- aliases.append(opts.get(alias[0]))
589
- if OPT_NOT_SUPPORTED in aliases:
783
+ opt_values.append(opts.get(alias[0]))
784
+ if OPT_NOT_SUPPORTED in opt_values:
785
+ debug('skipped (unsupported)', obj={'name': opt_name}, obj_after=False, sub='command.options', verbose=True)
590
786
  return None
591
- return next((v for v in aliases if v is not None), default)
787
+ value = next((v for v in opt_values if v is not None), default)
788
+ debug('got opt value', obj={'name': opt_name, 'value': value, 'aliases': opt_names, 'values': opt_values}, obj_after=False, sub='command.options', verbose=True) # noqa: E501
789
+ return value
592
790
 
593
791
  def _build_cmd(self):
594
792
  """Build command string."""
595
793
 
596
794
  # Add JSON flag to cmd
597
- if self.output_json and self.json_flag:
795
+ if self.json_flag:
598
796
  self.cmd += f' {self.json_flag}'
599
797
 
600
798
  # Add options to cmd
@@ -624,47 +822,39 @@ class Command(Runner):
624
822
  string or a list to the cmd.
625
823
  """
626
824
  cmd = self.cmd
627
- input = self.input
825
+ inputs = self.inputs
628
826
 
629
- # If input is None, return the previous command
630
- if not input:
827
+ # If inputs is empty, return the previous command
828
+ if not inputs:
631
829
  return
632
830
 
633
- # If input is a list but has one element, use the standard string input
634
- if isinstance(input, list) and len(input) == 1:
635
- input = input[0]
831
+ # If inputs has a single element but the tool does not support an input flag, use echo-piped_input input.
832
+ # If the tool's input flag is set to None, assume it is a positional argument at the end of the command.
833
+ # Otherwise use the input flag to pass the input.
834
+ if len(inputs) == 1:
835
+ input = shlex.quote(inputs[0])
836
+ if self.input_flag == OPT_PIPE_INPUT:
837
+ cmd = f'echo {input} | {cmd}'
838
+ elif not self.input_flag:
839
+ cmd += f' {input}'
840
+ else:
841
+ cmd += f' {self.input_flag} {input}'
636
842
 
637
- # If input is a list and the tool has input_flag set to OPT_PIPE_INPUT, use cat-piped input.
843
+ # If inputs has multiple elements and the tool has input_flag set to OPT_PIPE_INPUT, use cat-piped_input input.
638
844
  # Otherwise pass the file path to the tool.
639
- if isinstance(input, list):
845
+ else:
640
846
  fpath = f'{self.reports_folder}/.inputs/{self.unique_name}.txt'
641
847
 
642
848
  # Write the input to a file
643
849
  with open(fpath, 'w') as f:
644
- f.write('\n'.join(input))
850
+ f.write('\n'.join(inputs))
645
851
 
646
852
  if self.file_flag == OPT_PIPE_INPUT:
647
853
  cmd = f'cat {fpath} | {cmd}'
648
854
  elif self.file_flag:
649
855
  cmd += f' {self.file_flag} {fpath}'
650
- else:
651
- self._print(f'{self.__class__.__name__} does not support multiple inputs.', color='bold red')
652
- self.input_valid = False
653
856
 
654
- self.input_path = fpath
655
-
656
- # If input is a string but the tool does not support an input flag, use echo-piped input.
657
- # If the tool's input flag is set to None, assume it is a positional argument at the end of the command.
658
- # Otherwise use the input flag to pass the input.
659
- else:
660
- input = shlex.quote(input)
661
- if self.input_flag == OPT_PIPE_INPUT:
662
- cmd = f'echo {input} | {cmd}'
663
- elif not self.input_flag:
664
- cmd += f' {input}'
665
- else:
666
- cmd += f' {self.input_flag} {input}'
857
+ self.inputs_path = fpath
667
858
 
668
859
  self.cmd = cmd
669
860
  self.shell = ' | ' in self.cmd
670
- self.input = input