secator 0.1.0__py2.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 (99) hide show
  1. secator/.gitignore +162 -0
  2. secator/__init__.py +0 -0
  3. secator/celery.py +421 -0
  4. secator/cli.py +927 -0
  5. secator/config.py +137 -0
  6. secator/configs/__init__.py +0 -0
  7. secator/configs/profiles/__init__.py +0 -0
  8. secator/configs/profiles/aggressive.yaml +7 -0
  9. secator/configs/profiles/default.yaml +9 -0
  10. secator/configs/profiles/stealth.yaml +7 -0
  11. secator/configs/scans/__init__.py +0 -0
  12. secator/configs/scans/domain.yaml +18 -0
  13. secator/configs/scans/host.yaml +14 -0
  14. secator/configs/scans/network.yaml +17 -0
  15. secator/configs/scans/subdomain.yaml +8 -0
  16. secator/configs/scans/url.yaml +12 -0
  17. secator/configs/workflows/__init__.py +0 -0
  18. secator/configs/workflows/cidr_recon.yaml +28 -0
  19. secator/configs/workflows/code_scan.yaml +11 -0
  20. secator/configs/workflows/host_recon.yaml +41 -0
  21. secator/configs/workflows/port_scan.yaml +34 -0
  22. secator/configs/workflows/subdomain_recon.yaml +33 -0
  23. secator/configs/workflows/url_crawl.yaml +29 -0
  24. secator/configs/workflows/url_dirsearch.yaml +29 -0
  25. secator/configs/workflows/url_fuzz.yaml +35 -0
  26. secator/configs/workflows/url_nuclei.yaml +11 -0
  27. secator/configs/workflows/url_vuln.yaml +55 -0
  28. secator/configs/workflows/user_hunt.yaml +10 -0
  29. secator/configs/workflows/wordpress.yaml +14 -0
  30. secator/decorators.py +346 -0
  31. secator/definitions.py +183 -0
  32. secator/exporters/__init__.py +12 -0
  33. secator/exporters/_base.py +3 -0
  34. secator/exporters/csv.py +29 -0
  35. secator/exporters/gdrive.py +118 -0
  36. secator/exporters/json.py +14 -0
  37. secator/exporters/table.py +7 -0
  38. secator/exporters/txt.py +24 -0
  39. secator/hooks/__init__.py +0 -0
  40. secator/hooks/mongodb.py +212 -0
  41. secator/output_types/__init__.py +24 -0
  42. secator/output_types/_base.py +95 -0
  43. secator/output_types/exploit.py +50 -0
  44. secator/output_types/ip.py +33 -0
  45. secator/output_types/port.py +45 -0
  46. secator/output_types/progress.py +35 -0
  47. secator/output_types/record.py +34 -0
  48. secator/output_types/subdomain.py +42 -0
  49. secator/output_types/tag.py +46 -0
  50. secator/output_types/target.py +30 -0
  51. secator/output_types/url.py +76 -0
  52. secator/output_types/user_account.py +41 -0
  53. secator/output_types/vulnerability.py +97 -0
  54. secator/report.py +95 -0
  55. secator/rich.py +123 -0
  56. secator/runners/__init__.py +12 -0
  57. secator/runners/_base.py +873 -0
  58. secator/runners/_helpers.py +154 -0
  59. secator/runners/command.py +674 -0
  60. secator/runners/scan.py +67 -0
  61. secator/runners/task.py +107 -0
  62. secator/runners/workflow.py +137 -0
  63. secator/serializers/__init__.py +8 -0
  64. secator/serializers/dataclass.py +33 -0
  65. secator/serializers/json.py +15 -0
  66. secator/serializers/regex.py +17 -0
  67. secator/tasks/__init__.py +10 -0
  68. secator/tasks/_categories.py +304 -0
  69. secator/tasks/cariddi.py +102 -0
  70. secator/tasks/dalfox.py +66 -0
  71. secator/tasks/dirsearch.py +88 -0
  72. secator/tasks/dnsx.py +56 -0
  73. secator/tasks/dnsxbrute.py +34 -0
  74. secator/tasks/feroxbuster.py +89 -0
  75. secator/tasks/ffuf.py +85 -0
  76. secator/tasks/fping.py +44 -0
  77. secator/tasks/gau.py +43 -0
  78. secator/tasks/gf.py +34 -0
  79. secator/tasks/gospider.py +71 -0
  80. secator/tasks/grype.py +78 -0
  81. secator/tasks/h8mail.py +80 -0
  82. secator/tasks/httpx.py +104 -0
  83. secator/tasks/katana.py +128 -0
  84. secator/tasks/maigret.py +78 -0
  85. secator/tasks/mapcidr.py +32 -0
  86. secator/tasks/msfconsole.py +176 -0
  87. secator/tasks/naabu.py +52 -0
  88. secator/tasks/nmap.py +341 -0
  89. secator/tasks/nuclei.py +97 -0
  90. secator/tasks/searchsploit.py +53 -0
  91. secator/tasks/subfinder.py +40 -0
  92. secator/tasks/wpscan.py +177 -0
  93. secator/utils.py +404 -0
  94. secator/utils_test.py +183 -0
  95. secator-0.1.0.dist-info/METADATA +379 -0
  96. secator-0.1.0.dist-info/RECORD +99 -0
  97. secator-0.1.0.dist-info/WHEEL +5 -0
  98. secator-0.1.0.dist-info/entry_points.txt +2 -0
  99. secator-0.1.0.dist-info/licenses/LICENSE +60 -0
@@ -0,0 +1,674 @@
1
+ import getpass
2
+ import logging
3
+ import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+
9
+ from time import sleep
10
+
11
+ from fp.fp import FreeProxy
12
+
13
+ from secator.config import ConfigLoader
14
+ from secator.definitions import (DEFAULT_HTTP_PROXY,
15
+ DEFAULT_FREEPROXY_TIMEOUT,
16
+ DEFAULT_PROXYCHAINS_COMMAND,
17
+ DEFAULT_SOCKS5_PROXY, OPT_NOT_SUPPORTED,
18
+ OPT_PIPE_INPUT, DEFAULT_INPUT_CHUNK_SIZE)
19
+ from secator.rich import console
20
+ from secator.runners import Runner
21
+ from secator.serializers import JSONSerializer
22
+ from secator.utils import debug
23
+
24
+ # from rich.markup import escape
25
+ # from rich.text import Text
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class Command(Runner):
32
+ """Base class to execute an external command."""
33
+ # Base cmd
34
+ cmd = None
35
+
36
+ # Meta options
37
+ meta_opts = {}
38
+
39
+ # Additional command options
40
+ opts = {}
41
+
42
+ # Option prefix char
43
+ opt_prefix = '-'
44
+
45
+ # Option key map to transform option names
46
+ opt_key_map = {}
47
+
48
+ # Option value map to transform option values
49
+ opt_value_map = {}
50
+
51
+ # Output map to transform JSON output keys
52
+ output_map = {}
53
+
54
+ # Run in shell if True (not recommended)
55
+ shell = False
56
+
57
+ # Current working directory
58
+ cwd = None
59
+
60
+ # Output encoding
61
+ encoding = 'utf-8'
62
+
63
+ # Environment variables
64
+ env = {}
65
+
66
+ # Flag to take the input
67
+ input_flag = None
68
+
69
+ # Input path (if a file is constructed)
70
+ input_path = None
71
+
72
+ # Input chunk size (default None)
73
+ input_chunk_size = DEFAULT_INPUT_CHUNK_SIZE
74
+
75
+ # Flag to take a file as input
76
+ file_flag = None
77
+
78
+ # Flag to enable output JSON
79
+ json_flag = None
80
+
81
+ # Flag to show version
82
+ version_flag = None
83
+
84
+ # Install command
85
+ install_cmd = None
86
+
87
+ # Serializer
88
+ item_loader = None
89
+ item_loaders = [JSONSerializer(),]
90
+
91
+ # Ignore return code
92
+ ignore_return_code = False
93
+
94
+ # Return code
95
+ return_code = -1
96
+
97
+ # Error
98
+ error = ''
99
+
100
+ # Output
101
+ output = ''
102
+
103
+ # Proxy options
104
+ proxychains = False
105
+ proxy_socks5 = False
106
+ proxy_http = False
107
+
108
+ # Profile
109
+ profile = 'cpu'
110
+
111
+ def __init__(self, input=None, **run_opts):
112
+ # Build runnerconfig on-the-fly
113
+ config = ConfigLoader(input={
114
+ 'name': self.__class__.__name__,
115
+ 'type': 'task',
116
+ 'description': run_opts.get('description', None)
117
+ })
118
+
119
+ # Run parent init
120
+ hooks = run_opts.pop('hooks', {})
121
+ results = run_opts.pop('results', [])
122
+ context = run_opts.pop('context', {})
123
+ super().__init__(
124
+ config=config,
125
+ targets=input,
126
+ results=results,
127
+ run_opts=run_opts,
128
+ hooks=hooks,
129
+ context=context)
130
+
131
+ # Current working directory for cmd
132
+ self.cwd = self.run_opts.get('cwd', None)
133
+
134
+ # No capturing of stdout / stderr.
135
+ self.no_capture = self.run_opts.get('no_capture', False)
136
+
137
+ # Proxy config (global)
138
+ self.proxy = self.run_opts.pop('proxy', False)
139
+ self.configure_proxy()
140
+
141
+ # Build command input
142
+ self._build_cmd_input()
143
+
144
+ # Build command
145
+ self._build_cmd()
146
+
147
+ # Build item loaders
148
+ instance_func = getattr(self, 'item_loader', None)
149
+ item_loaders = self.item_loaders.copy()
150
+ if instance_func:
151
+ item_loaders.append(instance_func)
152
+ self.item_loaders = item_loaders
153
+
154
+ # Print built cmd
155
+ if self.print_cmd and not self.has_children:
156
+ if self.sync and self.description:
157
+ self._print(f'\n:wrench: {self.description} ...', color='bold gold3', rich=True)
158
+ self._print(self.cmd, color='bold cyan', rich=True)
159
+
160
+ # Print built input
161
+ if self.print_input_file and self.input_path:
162
+ input_str = '\n '.join(self.input).strip()
163
+ debug(f'[dim magenta]File input:[/]\n [italic medium_turquoise]{input_str}[/]')
164
+
165
+ # Print run options
166
+ if self.print_run_opts:
167
+ input_str = '\n '.join([
168
+ f'[dim blue]{k}[/] -> [dim green]{v}[/]' for k, v in self.run_opts.items() if v is not None]).strip()
169
+ debug(f'[dim magenta]Run opts:[/]\n {input_str}')
170
+
171
+ # Print format options
172
+ if self.print_fmt_opts:
173
+ input_str = '\n '.join([
174
+ f'[dim blue]{k}[/] -> [dim green]{v}[/]' for k, v in self.opts_to_print.items() if v is not None]).strip()
175
+ debug(f'[dim magenta]Print opts:[/]\n {input_str}')
176
+
177
+ # Print hooks
178
+ if self.print_hooks:
179
+ input_str = ''
180
+ for hook_name, hook_funcs in self.hooks.items():
181
+ hook_funcs_str = ', '.join([f'[dim green]{h.__module__}.{h.__qualname__}[/]' for h in hook_funcs])
182
+ if hook_funcs:
183
+ input_str += f'[dim blue]{hook_name}[/] -> {hook_funcs_str}\n '
184
+ input_str = input_str.strip()
185
+ if input_str:
186
+ debug(f'[dim magenta]Hooks:[/]\n {input_str}')
187
+
188
+ def toDict(self):
189
+ res = super().toDict()
190
+ res.update({
191
+ 'cmd': self.cmd,
192
+ 'cwd': self.cwd,
193
+ 'return_code': self.return_code
194
+ })
195
+ return res
196
+
197
+ @classmethod
198
+ def delay(cls, *args, **kwargs):
199
+ # TODO: Move this to TaskBase
200
+ from secator.celery import run_command
201
+ results = kwargs.get('results', [])
202
+ name = cls.__name__
203
+ return run_command.apply_async(args=[results, name] + list(args), kwargs={'opts': kwargs}, queue=cls.profile)
204
+
205
+ @classmethod
206
+ def s(cls, *args, **kwargs):
207
+ # TODO: Move this to TaskBase
208
+ from secator.celery import run_command
209
+ return run_command.s(cls.__name__, *args, opts=kwargs).set(queue=cls.profile)
210
+
211
+ @classmethod
212
+ def si(cls, results, *args, **kwargs):
213
+ # TODO: Move this to TaskBase
214
+ from secator.celery import run_command
215
+ return run_command.si(results, cls.__name__, *args, opts=kwargs).set(queue=cls.profile)
216
+
217
+ def get_opt_value(self, opt_name):
218
+ return Command._get_opt_value(
219
+ self.run_opts,
220
+ opt_name,
221
+ dict(self.opts, **self.meta_opts),
222
+ opt_prefix=self.config.name)
223
+
224
+ @classmethod
225
+ def get_supported_opts(cls):
226
+ def convert(d):
227
+ for k, v in d.items():
228
+ if hasattr(v, '__name__') and v.__name__ in ['str', 'int', 'float']:
229
+ d[k] = v.__name__
230
+ return d
231
+
232
+ opts = {k: convert(v) for k, v in cls.opts.items()}
233
+ for k, v in opts.items():
234
+ v['meta'] = cls.__name__
235
+ v['supported'] = True
236
+
237
+ 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}
238
+ for k, v in meta_opts.items():
239
+ v['meta'] = 'meta'
240
+ if cls.opt_key_map.get(k) is OPT_NOT_SUPPORTED:
241
+ v['supported'] = False
242
+ else:
243
+ v['supported'] = True
244
+ opts = dict(opts)
245
+ opts.update(meta_opts)
246
+ return opts
247
+
248
+ #---------------#
249
+ # Class methods #
250
+ #---------------#
251
+
252
+ @classmethod
253
+ def install(cls):
254
+ """Install command by running the content of cls.install_cmd."""
255
+ console.print(f':heavy_check_mark: Installing {cls.__name__}...', style='bold yellow')
256
+ if not cls.install_cmd:
257
+ console.print(f'{cls.__name__} install is not supported yet. Please install it manually.', style='bold red')
258
+ return
259
+ ret = cls.run_command(
260
+ cls.install_cmd,
261
+ name=cls.__name__,
262
+ print_cmd=True,
263
+ print_line=True,
264
+ cls_attributes={'shell': True}
265
+ )
266
+ if ret.return_code != 0:
267
+ console.print(f':exclamation_mark: Failed to install {cls.__name__}.', style='bold red')
268
+ else:
269
+ console.print(f':tada: {cls.__name__} installed successfully !', style='bold green')
270
+ return ret
271
+
272
+ @classmethod
273
+ def run_command(cls, cmd, name=None, cls_attributes={}, **kwargs):
274
+ """Run adhoc command. Can be used without defining an inherited class to run a command, while still enjoying
275
+ all the good stuff in this class.
276
+ """
277
+ name = name or cmd.split(' ')[0]
278
+ cmd_instance = type(name, (Command,), {'cmd': cmd})(**kwargs)
279
+ for k, v in cls_attributes.items():
280
+ setattr(cmd_instance, k, v)
281
+ cmd_instance.print_line = not kwargs.get('quiet', False)
282
+ cmd_instance.print_item = not kwargs.get('quiet', False)
283
+ cmd_instance.run()
284
+ return cmd_instance
285
+
286
+ def configure_proxy(self):
287
+ """Configure proxy. Start with global settings like 'proxychains' or 'random', or fallback to tool-specific
288
+ proxy settings.
289
+
290
+ TODO: Move this to a subclass of Command, or to a configurable attribute to pass to derived classes as it's not
291
+ related to core functionality.
292
+ """
293
+ opt_key_map = self.opt_key_map
294
+ proxy_opt = opt_key_map.get('proxy', False)
295
+ support_proxy_opt = proxy_opt and proxy_opt != OPT_NOT_SUPPORTED
296
+ proxychains_flavor = getattr(self, 'proxychains_flavor', DEFAULT_PROXYCHAINS_COMMAND)
297
+ proxy = False
298
+
299
+ if self.proxy in ['auto', 'proxychains'] and self.proxychains:
300
+ self.cmd = f'{proxychains_flavor} {self.cmd}'
301
+ proxy = 'proxychains'
302
+
303
+ elif self.proxy and support_proxy_opt:
304
+ if self.proxy in ['auto', 'socks5'] and self.proxy_socks5 and DEFAULT_SOCKS5_PROXY:
305
+ proxy = DEFAULT_SOCKS5_PROXY
306
+ elif self.proxy in ['auto', 'http'] and self.proxy_http and DEFAULT_HTTP_PROXY:
307
+ proxy = DEFAULT_HTTP_PROXY
308
+ elif self.proxy == 'random':
309
+ proxy = FreeProxy(timeout=DEFAULT_FREEPROXY_TIMEOUT, rand=True, anonym=True).get()
310
+ elif self.proxy.startswith(('http://', 'socks5://')):
311
+ proxy = self.proxy
312
+
313
+ if proxy != 'proxychains':
314
+ self.run_opts['proxy'] = proxy
315
+
316
+ if proxy != 'proxychains' and self.proxy and not proxy:
317
+ self._print(
318
+ f'[bold red]Ignoring proxy "{self.proxy}" for {self.__class__.__name__} (not supported).[/]', rich=True)
319
+
320
+ #----------#
321
+ # Internal #
322
+ #----------#
323
+ def yielder(self):
324
+ """Run command and yields its output in real-time. Also saves the command line, return code and output to the
325
+ database.
326
+
327
+ Args:
328
+ cmd (str): Command to run.
329
+ cwd (str, Optional): Working directory to run from.
330
+ shell (bool, Optional): Run command in a shell.
331
+ history_file (str): History file path.
332
+ mapper_func (Callable, Optional): Function to map output before yielding.
333
+ encoding (str, Optional): Output encoding.
334
+ ctx (dict, Optional): Scan context.
335
+
336
+ Yields:
337
+ str: Command stdout / stderr.
338
+ dict: Parsed JSONLine object.
339
+ """
340
+ # Set status to 'RUNNING'
341
+ self.status = 'RUNNING'
342
+
343
+ # Callback before running command
344
+ self.run_hooks('on_start')
345
+
346
+ # Check for sudo requirements and prepare the password if needed
347
+ sudo_password = self._prompt_sudo(self.cmd)
348
+
349
+ # Prepare cmds
350
+ command = self.cmd if self.shell else shlex.split(self.cmd)
351
+
352
+ # Output and results
353
+ self.return_code = 0
354
+ self.killed = False
355
+
356
+ # Run the command using subprocess
357
+ try:
358
+ env = os.environ
359
+ env.update(self.env)
360
+ process = subprocess.Popen(
361
+ command,
362
+ stdin=subprocess.PIPE if sudo_password else None,
363
+ stdout=sys.stdout if self.no_capture else subprocess.PIPE,
364
+ stderr=sys.stderr if self.no_capture else subprocess.STDOUT,
365
+ universal_newlines=True,
366
+ shell=self.shell,
367
+ env=env,
368
+ cwd=self.cwd)
369
+
370
+ # If sudo password is provided, send it to stdin
371
+ if sudo_password:
372
+ process.stdin.write(f"{sudo_password}\n")
373
+ process.stdin.flush()
374
+
375
+ except FileNotFoundError as e:
376
+ if self.config.name in str(e):
377
+ error = 'Executable not found.'
378
+ if self.install_cmd:
379
+ error += f' Install it with `secator install tools {self.config.name}`.'
380
+ else:
381
+ error = str(e)
382
+ celery_id = self.context.get('celery_id', '')
383
+ if celery_id:
384
+ error += f' [{celery_id}]'
385
+ self.errors.append(error)
386
+ self.return_code = 1
387
+ return
388
+
389
+ try:
390
+ # No capture mode, wait for command to finish and return
391
+ if self.no_capture:
392
+ self._wait_for_end(process)
393
+ return
394
+
395
+ # Process the output in real-time
396
+ for line in iter(lambda: process.stdout.readline(), b''):
397
+ sleep(0) # for async to give up control
398
+ if not line:
399
+ break
400
+
401
+ # Strip line endings
402
+ line = line.rstrip()
403
+
404
+ # Some commands output ANSI text, so we need to remove those ANSI chars
405
+ if self.encoding == 'ansi':
406
+ # ansi_regex = r'\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[K]?'
407
+ # line = re.sub(ansi_regex, '', line.strip())
408
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
409
+ line = ansi_escape.sub('', line)
410
+ line = line.replace('\\x0d\\x0a', '\n')
411
+
412
+ # Run on_line hooks
413
+ line = self.run_hooks('on_line', line)
414
+
415
+ # Run item_loader to try parsing as dict
416
+ items = None
417
+ if self.output_json:
418
+ items = self.run_item_loaders(line)
419
+
420
+ # Yield line if no items parsed
421
+ if not items:
422
+ yield line
423
+
424
+ # Turn results into list if not already a list
425
+ elif not isinstance(items, list):
426
+ items = [items]
427
+
428
+ # Yield items
429
+ if items:
430
+ yield from items
431
+
432
+ except KeyboardInterrupt:
433
+ process.kill()
434
+ self.killed = True
435
+
436
+ # Retrieve the return code and output
437
+ self._wait_for_end(process)
438
+
439
+ def run_item_loaders(self, line):
440
+ """Run item loaders on a string."""
441
+ items = []
442
+ for item_loader in self.item_loaders:
443
+ result = None
444
+ if (callable(item_loader)):
445
+ result = item_loader(self, line)
446
+ elif item_loader:
447
+ result = item_loader.run(line)
448
+ if isinstance(result, dict):
449
+ result = [result]
450
+ if result:
451
+ items.extend(result)
452
+ return items
453
+
454
+ def _prompt_sudo(self, command):
455
+ """
456
+ Checks if the command requires sudo and prompts for the password if necessary.
457
+
458
+ Args:
459
+ command (str): The initial command to be executed.
460
+
461
+ Returns:
462
+ str or None: The sudo password if required; otherwise, None.
463
+ """
464
+ sudo_password = None
465
+
466
+ # Check if sudo is required by the command
467
+ if not re.search(r'\bsudo\b', command):
468
+ return None
469
+
470
+ # Check if sudo can be executed without a password
471
+ if subprocess.run(['sudo', '-n', 'true'], capture_output=True).returncode == 0:
472
+ return None
473
+
474
+ # Check if we have a tty
475
+ if not os.isatty(sys.stdin.fileno()):
476
+ self._print("No TTY detected. Sudo password prompt requires a TTY to proceed.", color='bold red')
477
+ sys.exit(1)
478
+
479
+ # If not, prompt the user for a password
480
+ self._print('[bold red]Please enter sudo password to continue.[/]')
481
+ for _ in range(3):
482
+ self._print('\[sudo] password: ')
483
+ sudo_password = getpass.getpass()
484
+ result = subprocess.run(
485
+ ['sudo', '-S', '-p', '', 'true'],
486
+ input=sudo_password + "\n",
487
+ text=True,
488
+ capture_output=True
489
+ )
490
+ if result.returncode == 0:
491
+ return sudo_password # Password is correct
492
+ self._print("Sorry, try again.")
493
+ self._print("Sudo password verification failed after 3 attempts.")
494
+ return None
495
+
496
+ def _wait_for_end(self, process):
497
+ """Wait for process to finish and process output and return code."""
498
+ process.wait()
499
+ self.return_code = process.returncode
500
+
501
+ if self.no_capture:
502
+ self.output = ''
503
+ else:
504
+ self.output = self.output.strip()
505
+ process.stdout.close()
506
+
507
+ if self.ignore_return_code:
508
+ self.return_code = 0
509
+
510
+ if self.return_code == -2 or self.killed:
511
+ error = 'Process was killed manually (CTRL+C / CTRL+X)'
512
+ self.errors.append(error)
513
+ elif self.return_code != 0:
514
+ error = f'Command failed with return code {self.return_code}.'
515
+ self.errors.append(error)
516
+
517
+ @staticmethod
518
+ def _process_opts(
519
+ opts,
520
+ opts_conf,
521
+ opt_key_map={},
522
+ opt_value_map={},
523
+ opt_prefix='-',
524
+ command_name=None):
525
+ """Process a dict of options using a config, option key map / value map
526
+ and option character like '-' or '--'.
527
+
528
+ Args:
529
+ opts (dict): Command options as input on the CLI.
530
+ opts_conf (dict): Options config (Click options definition).
531
+ """
532
+ opts_str = ''
533
+ for opt_name, opt_conf in opts_conf.items():
534
+
535
+ # Get opt value
536
+ default_val = opt_conf.get('default')
537
+ opt_val = Command._get_opt_value(
538
+ opts,
539
+ opt_name,
540
+ opts_conf,
541
+ opt_prefix=command_name,
542
+ default=default_val)
543
+
544
+ # Skip option if value is falsy
545
+ if opt_val in [None, False, []]:
546
+ # logger.debug(f'Option {opt_name} was passed but is falsy. Skipping.')
547
+ continue
548
+
549
+ # Convert opt value to expected command opt value
550
+ mapped_opt_val = opt_value_map.get(opt_name)
551
+ if callable(mapped_opt_val):
552
+ opt_val = mapped_opt_val(opt_val)
553
+ elif mapped_opt_val:
554
+ opt_val = mapped_opt_val
555
+
556
+ # Convert opt name to expected command opt name
557
+ mapped_opt_name = opt_key_map.get(opt_name)
558
+ if mapped_opt_name == OPT_NOT_SUPPORTED:
559
+ # logger.debug(f'Option {opt_name} was passed but is unsupported. Skipping.')
560
+ continue
561
+ elif mapped_opt_name is not None:
562
+ opt_name = mapped_opt_name
563
+
564
+ # Avoid shell injections and detect opt prefix
565
+ opt_name = str(opt_name).split(' ')[0] # avoid cmd injection
566
+
567
+ # Replace '_' with '-'
568
+ opt_name = opt_name.replace('_', '-')
569
+
570
+ # Add opt prefix if not already there
571
+ if len(opt_name) > 0 and opt_name[0] not in ['-', '--']:
572
+ opt_name = f'{opt_prefix}{opt_name}'
573
+
574
+ # Append opt name + opt value to option string.
575
+ # Note: does not append opt value if value is True (flag)
576
+ opts_str += f' {opt_name}'
577
+ if opt_val is not True:
578
+ opt_val = shlex.quote(str(opt_val))
579
+ opts_str += f' {opt_val}'
580
+
581
+ return opts_str.strip()
582
+
583
+ @staticmethod
584
+ def _get_opt_value(opts, opt_name, opts_conf={}, opt_prefix='', default=None):
585
+ aliases = [
586
+ opts.get(f'{opt_prefix}_{opt_name}'),
587
+ opts.get(f'{opt_prefix}.{opt_name}'),
588
+ opts.get(opt_name),
589
+ ]
590
+ alias = [conf.get('short') for _, conf in opts_conf.items() if conf.get('short') in opts]
591
+ if alias:
592
+ aliases.append(opts.get(alias[0]))
593
+ if OPT_NOT_SUPPORTED in aliases:
594
+ return None
595
+ return next((v for v in aliases if v is not None), default)
596
+
597
+ def _build_cmd(self):
598
+ """Build command string."""
599
+
600
+ # Add JSON flag to cmd
601
+ if self.output_json and self.json_flag:
602
+ self.cmd += f' {self.json_flag}'
603
+
604
+ # Add options to cmd
605
+ opts_str = Command._process_opts(
606
+ self.run_opts,
607
+ self.opts,
608
+ self.opt_key_map,
609
+ self.opt_value_map,
610
+ self.opt_prefix,
611
+ command_name=self.config.name)
612
+ if opts_str:
613
+ self.cmd += f' {opts_str}'
614
+
615
+ # Add meta options to cmd
616
+ meta_opts_str = Command._process_opts(
617
+ self.run_opts,
618
+ self.meta_opts,
619
+ self.opt_key_map,
620
+ self.opt_value_map,
621
+ self.opt_prefix,
622
+ command_name=self.config.name)
623
+ if meta_opts_str:
624
+ self.cmd += f' {meta_opts_str}'
625
+
626
+ def _build_cmd_input(self):
627
+ """Many commands take as input a string or a list. This function facilitate this based on whether we pass a
628
+ string or a list to the cmd.
629
+ """
630
+ cmd = self.cmd
631
+ input = self.input
632
+
633
+ # If input is None, return the previous command
634
+ if not input:
635
+ return
636
+
637
+ # If input is a list but has one element, use the standard string input
638
+ if isinstance(input, list) and len(input) == 1:
639
+ input = input[0]
640
+
641
+ # If input is a list and the tool has input_flag set to OPT_PIPE_INPUT, use cat-piped input.
642
+ # Otherwise pass the file path to the tool.
643
+ if isinstance(input, list):
644
+ fpath = f'{self.reports_folder}/.inputs/{self.unique_name}.txt'
645
+
646
+ # Write the input to a file
647
+ with open(fpath, 'w') as f:
648
+ f.write('\n'.join(input))
649
+
650
+ if self.file_flag == OPT_PIPE_INPUT:
651
+ cmd = f'cat {fpath} | {cmd}'
652
+ elif self.file_flag:
653
+ cmd += f' {self.file_flag} {fpath}'
654
+ else:
655
+ self._print(f'{self.__class__.__name__} does not support multiple inputs.', color='bold red')
656
+ self.input_valid = False
657
+
658
+ self.input_path = fpath
659
+
660
+ # If input is a string but the tool does not support an input flag, use echo-piped input.
661
+ # If the tool's input flag is set to None, assume it is a positional argument at the end of the command.
662
+ # Otherwise use the input flag to pass the input.
663
+ else:
664
+ input = shlex.quote(input)
665
+ if self.input_flag == OPT_PIPE_INPUT:
666
+ cmd = f'echo {input} | {cmd}'
667
+ elif not self.input_flag:
668
+ cmd += f' {input}'
669
+ else:
670
+ cmd += f' {self.input_flag} {input}'
671
+
672
+ self.cmd = cmd
673
+ self.shell = ' | ' in self.cmd
674
+ self.input = input