secator 0.15.0__py3-none-any.whl → 0.16.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 (106) hide show
  1. secator/celery.py +40 -24
  2. secator/celery_signals.py +71 -68
  3. secator/celery_utils.py +43 -27
  4. secator/cli.py +520 -280
  5. secator/cli_helper.py +394 -0
  6. secator/click.py +87 -0
  7. secator/config.py +67 -39
  8. secator/configs/profiles/http_headless.yaml +6 -0
  9. secator/configs/profiles/http_record.yaml +6 -0
  10. secator/configs/profiles/tor.yaml +1 -1
  11. secator/configs/scans/domain.yaml +4 -2
  12. secator/configs/scans/host.yaml +1 -1
  13. secator/configs/scans/network.yaml +1 -4
  14. secator/configs/scans/subdomain.yaml +13 -1
  15. secator/configs/scans/url.yaml +1 -2
  16. secator/configs/workflows/cidr_recon.yaml +6 -4
  17. secator/configs/workflows/code_scan.yaml +1 -1
  18. secator/configs/workflows/host_recon.yaml +29 -3
  19. secator/configs/workflows/subdomain_recon.yaml +67 -16
  20. secator/configs/workflows/url_crawl.yaml +44 -15
  21. secator/configs/workflows/url_dirsearch.yaml +4 -4
  22. secator/configs/workflows/url_fuzz.yaml +25 -17
  23. secator/configs/workflows/url_params_fuzz.yaml +7 -0
  24. secator/configs/workflows/url_vuln.yaml +33 -8
  25. secator/configs/workflows/user_hunt.yaml +2 -1
  26. secator/configs/workflows/wordpress.yaml +5 -3
  27. secator/cve.py +718 -0
  28. secator/decorators.py +0 -454
  29. secator/definitions.py +49 -30
  30. secator/exporters/_base.py +2 -2
  31. secator/exporters/console.py +2 -2
  32. secator/exporters/table.py +4 -3
  33. secator/exporters/txt.py +1 -1
  34. secator/hooks/mongodb.py +2 -4
  35. secator/installer.py +77 -49
  36. secator/loader.py +116 -0
  37. secator/output_types/_base.py +3 -0
  38. secator/output_types/certificate.py +63 -63
  39. secator/output_types/error.py +4 -5
  40. secator/output_types/info.py +2 -2
  41. secator/output_types/ip.py +3 -1
  42. secator/output_types/progress.py +5 -9
  43. secator/output_types/state.py +17 -17
  44. secator/output_types/tag.py +3 -0
  45. secator/output_types/target.py +10 -2
  46. secator/output_types/url.py +19 -7
  47. secator/output_types/vulnerability.py +11 -7
  48. secator/output_types/warning.py +2 -2
  49. secator/report.py +27 -15
  50. secator/rich.py +18 -10
  51. secator/runners/_base.py +447 -234
  52. secator/runners/_helpers.py +133 -24
  53. secator/runners/command.py +182 -102
  54. secator/runners/scan.py +33 -5
  55. secator/runners/task.py +13 -7
  56. secator/runners/workflow.py +105 -72
  57. secator/scans/__init__.py +2 -2
  58. secator/serializers/dataclass.py +20 -20
  59. secator/tasks/__init__.py +4 -4
  60. secator/tasks/_categories.py +39 -27
  61. secator/tasks/arjun.py +9 -5
  62. secator/tasks/bbot.py +53 -21
  63. secator/tasks/bup.py +19 -5
  64. secator/tasks/cariddi.py +24 -3
  65. secator/tasks/dalfox.py +26 -7
  66. secator/tasks/dirsearch.py +10 -4
  67. secator/tasks/dnsx.py +70 -25
  68. secator/tasks/feroxbuster.py +11 -3
  69. secator/tasks/ffuf.py +42 -6
  70. secator/tasks/fping.py +20 -8
  71. secator/tasks/gau.py +3 -1
  72. secator/tasks/gf.py +5 -4
  73. secator/tasks/gitleaks.py +2 -2
  74. secator/tasks/gospider.py +7 -1
  75. secator/tasks/grype.py +5 -4
  76. secator/tasks/h8mail.py +2 -1
  77. secator/tasks/httpx.py +18 -5
  78. secator/tasks/katana.py +35 -15
  79. secator/tasks/maigret.py +4 -4
  80. secator/tasks/mapcidr.py +3 -3
  81. secator/tasks/msfconsole.py +4 -4
  82. secator/tasks/naabu.py +5 -4
  83. secator/tasks/nmap.py +12 -14
  84. secator/tasks/nuclei.py +3 -3
  85. secator/tasks/searchsploit.py +6 -5
  86. secator/tasks/subfinder.py +2 -2
  87. secator/tasks/testssl.py +264 -263
  88. secator/tasks/trivy.py +5 -5
  89. secator/tasks/wafw00f.py +21 -3
  90. secator/tasks/wpprobe.py +90 -83
  91. secator/tasks/wpscan.py +6 -5
  92. secator/template.py +218 -104
  93. secator/thread.py +15 -15
  94. secator/tree.py +196 -0
  95. secator/utils.py +131 -123
  96. secator/utils_test.py +60 -19
  97. secator/workflows/__init__.py +2 -2
  98. {secator-0.15.0.dist-info → secator-0.16.0.dist-info}/METADATA +37 -36
  99. secator-0.16.0.dist-info/RECORD +132 -0
  100. secator/configs/profiles/default.yaml +0 -8
  101. secator/configs/workflows/url_nuclei.yaml +0 -11
  102. secator/tasks/dnsxbrute.py +0 -42
  103. secator-0.15.0.dist-info/RECORD +0 -128
  104. {secator-0.15.0.dist-info → secator-0.16.0.dist-info}/WHEEL +0 -0
  105. {secator-0.15.0.dist-info → secator-0.16.0.dist-info}/entry_points.txt +0 -0
  106. {secator-0.15.0.dist-info → secator-0.16.0.dist-info}/licenses/LICENSE +0 -0
secator/cli_helper.py ADDED
@@ -0,0 +1,394 @@
1
+ import datetime
2
+ import os
3
+ import re
4
+ import sys
5
+
6
+ from collections import OrderedDict
7
+ from contextlib import nullcontext
8
+
9
+ import psutil
10
+ import rich_click as click
11
+ from rich_click.rich_click import _get_rich_console
12
+
13
+ from secator.config import CONFIG
14
+ from secator.click import CLICK_LIST
15
+ from secator.definitions import ADDONS_ENABLED
16
+ from secator.runners import Scan, Task, Workflow
17
+ from secator.template import get_config_options
18
+ from secator.tree import build_runner_tree
19
+ from secator.utils import (deduplicate, expand_input, get_command_category)
20
+ from secator.loader import get_configs_by_type
21
+
22
+
23
+ WORKSPACES = next(os.walk(CONFIG.dirs.reports))[1]
24
+ WORKSPACES_STR = '|'.join([f'[dim yellow3]{_}[/]' for _ in WORKSPACES])
25
+ PROFILES_STR = ','.join([f'[dim yellow3]{_.name}[/]' for _ in get_configs_by_type('profile')])
26
+ DRIVERS_STR = ','.join([f'[dim yellow3]{_}[/]' for _ in ['mongodb', 'gcs']])
27
+ DRIVER_DEFAULTS_STR = ','.join(CONFIG.drivers.defaults) if CONFIG.drivers.defaults else None
28
+ PROFILE_DEFAULTS_STR = ','.join(CONFIG.profiles.defaults) if CONFIG.profiles.defaults else None
29
+ EXPORTERS_STR = ','.join([f'[dim yellow3]{_}[/]' for _ in ['csv', 'gdrive', 'json', 'table', 'txt']])
30
+
31
+ CLI_OUTPUT_OPTS = {
32
+ 'output': {'type': str, 'default': None, 'help': f'Output options [{EXPORTERS_STR}] [dim orange4](comma-separated)[/]', 'short': 'o'}, # noqa: E501
33
+ 'fmt': {'default': '', 'short': 'fmt', 'internal_name': 'print_format', 'help': 'Output formatting string'},
34
+ 'json': {'is_flag': True, 'short': 'json', 'internal_name': 'print_json', 'default': False, 'help': 'Print items as JSON lines'}, # noqa: E501
35
+ 'raw': {'is_flag': True, 'short': 'raw', 'internal_name': 'print_raw', 'default': False, 'help': 'Print items in raw format'}, # noqa: E501
36
+ 'stat': {'is_flag': True, 'short': 'stat', 'internal_name': 'print_stat', 'default': False, 'help': 'Print runtime statistics'}, # noqa: E501
37
+ 'quiet': {'is_flag': True, 'short': 'q', 'default': not CONFIG.cli.show_command_output, 'opposite': 'verbose', 'help': 'Hide or show original command output'}, # noqa: E501
38
+ 'yaml': {'is_flag': True, 'short': 'yaml', 'default': False, 'help': 'Show runner yaml'},
39
+ 'tree': {'is_flag': True, 'short': 'tree', 'default': False, 'help': 'Show runner tree'},
40
+ 'dry_run': {'is_flag': True, 'short': 'dry', 'default': False, 'help': 'Show dry run'},
41
+ 'process': {'is_flag': True, 'short': 'ps', 'default': True, 'help': 'Enable / disable secator processing', 'reverse': True}, # noqa: E501
42
+ 'version': {'is_flag': True, 'help': 'Show runner version'},
43
+ }
44
+
45
+ CLI_EXEC_OPTS = {
46
+ 'workspace': {'type': str, 'default': 'default', 'help': f'Workspace [{WORKSPACES_STR}|[dim orange4]<new>[/]]', 'short': 'ws'}, # noqa: E501
47
+ 'profiles': {'type': str, 'help': f'Profiles [{PROFILES_STR}] [dim orange4](comma-separated)[/]', 'default': PROFILE_DEFAULTS_STR, 'short': 'pf'}, # noqa: E501
48
+ 'driver': {'type': str, 'help': f'Drivers [{DRIVERS_STR}] [dim orange4](comma-separated)[/]', 'default': DRIVER_DEFAULTS_STR}, # noqa: E501
49
+ 'sync': {'is_flag': True, 'help': 'Run tasks locally or in worker', 'opposite': 'worker'},
50
+ 'no_poll': {'is_flag': True, 'short': 'np', 'default': False, 'help': 'Do not live poll for tasks results when running in worker'}, # noqa: E501
51
+ 'enable_pyinstrument': {'is_flag': True, 'short': 'pyinstrument', 'default': False, 'help': 'Enable pyinstrument profiling'}, # noqa: E501
52
+ 'enable_memray': {'is_flag': True, 'short': 'memray', 'default': False, 'help': 'Enable memray profiling'},
53
+ }
54
+
55
+ CLI_TYPE_MAPPING = {
56
+ 'str': str,
57
+ 'list': CLICK_LIST,
58
+ 'int': int,
59
+ 'float': float,
60
+ # 'choice': click.Choice,
61
+ # 'file': click.Path(exists=True, file_okay=True, dir_okay=False, readable=True),
62
+ # 'dir': click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
63
+ # 'path': click.Path(exists=True, file_okay=True, dir_okay=True, readable=True),
64
+ # 'url': click.URL,
65
+ }
66
+
67
+ DEFAULT_CLI_OPTIONS = list(CLI_OUTPUT_OPTS.keys()) + list(CLI_EXEC_OPTS.keys())
68
+
69
+
70
+ def decorate_command_options(opts):
71
+ """Add click.option decorator to decorate click command.
72
+
73
+ Args:
74
+ opts (dict): Dict of command options.
75
+
76
+ Returns:
77
+ function: Decorator.
78
+ """
79
+ def decorator(f):
80
+ reversed_opts = OrderedDict(list(opts.items())[::-1])
81
+ for opt_name, opt_conf in reversed_opts.items():
82
+ conf = opt_conf.copy()
83
+ short_opt = conf.pop('short', None)
84
+ internal = conf.pop('internal', False)
85
+ display = conf.pop('display', True)
86
+ internal_name = conf.pop('internal_name', None)
87
+ if internal and not display:
88
+ continue
89
+ conf.pop('shlex', None)
90
+ conf.pop('meta', None)
91
+ conf.pop('supported', None)
92
+ conf.pop('process', None)
93
+ conf.pop('pre_process', None)
94
+ conf.pop('requires_sudo', None)
95
+ conf.pop('prefix', None)
96
+ applies_to = conf.pop('applies_to', None)
97
+ default_from = conf.pop('default_from', None)
98
+ reverse = conf.pop('reverse', False)
99
+ opposite = conf.pop('opposite', None)
100
+ long = f'--{opt_name}'
101
+ short = f'-{short_opt}' if short_opt else f'-{opt_name}'
102
+ if reverse:
103
+ if opposite:
104
+ long += f'/--{opposite}'
105
+ short += f'/-{opposite}' if len(short) > 2 else f'/-{opposite[0]}'
106
+ conf['help'] = conf['help'].replace(opt_name, f'{opt_name} / {opposite}')
107
+ else:
108
+ long += f'/--no-{opt_name}'
109
+ short += f'/-n{short_opt}' if short_opt else f'/-n{opt_name}'
110
+ if applies_to:
111
+ applies_to_str = ", ".join(f'[bold yellow3]{_}[/]' for _ in applies_to)
112
+ conf['help'] += rf' \[[dim]{applies_to_str}[/]]'
113
+ if default_from:
114
+ conf['help'] += rf' \[[dim]default from: [dim yellow3]{default_from}[/][/]]'
115
+ args = [long, short]
116
+ if internal_name:
117
+ args.append(internal_name)
118
+ f = click.option(*args, **conf)(f)
119
+ return f
120
+ return decorator
121
+
122
+
123
+ def generate_cli_subcommand(cli_endpoint, func, **opts):
124
+ return cli_endpoint.command(**opts)(func)
125
+
126
+
127
+ def register_runner(cli_endpoint, config):
128
+ name = config.name
129
+ input_required = True
130
+ command_opts = {
131
+ 'no_args_is_help': True,
132
+ 'context_settings': {
133
+ 'ignore_unknown_options': False,
134
+ 'allow_extra_args': False
135
+ }
136
+ }
137
+
138
+ if cli_endpoint.name == 'scan':
139
+ runner_cls = Scan
140
+ input_required = False # allow targets from stdin
141
+ short_help = config.description or ''
142
+ short_help += f' [dim]alias: {config.alias}' if config.alias else ''
143
+ command_opts.update({
144
+ 'name': name,
145
+ 'short_help': short_help,
146
+ 'no_args_is_help': False
147
+ })
148
+ input_types = config.input_types
149
+
150
+ elif cli_endpoint.name == 'workflow':
151
+ runner_cls = Workflow
152
+ input_required = False # allow targets from stdin
153
+ short_help = config.description or ''
154
+ short_help = f'{short_help:<55} [dim](alias)[/][bold cyan] {config.alias}' if config.alias else ''
155
+ command_opts.update({
156
+ 'name': name,
157
+ 'short_help': short_help,
158
+ 'no_args_is_help': False
159
+ })
160
+ input_types = config.input_types
161
+
162
+ elif cli_endpoint.name == 'task':
163
+ runner_cls = Task
164
+ input_required = False # allow targets from stdin
165
+ task_cls = Task.get_task_class(config.name)
166
+ task_category = get_command_category(task_cls)
167
+ short_help = f'[magenta]{task_category:<25}[/] {task_cls.__doc__}'
168
+ command_opts.update({
169
+ 'name': name,
170
+ 'short_help': short_help,
171
+ 'no_args_is_help': False
172
+ })
173
+ input_types = task_cls.input_types
174
+
175
+ else:
176
+ raise ValueError(f"Unrecognized runner endpoint name {cli_endpoint.name}")
177
+ input_types_str = '|'.join(input_types) if input_types else 'targets'
178
+ options = get_config_options(
179
+ config,
180
+ exec_opts=CLI_EXEC_OPTS,
181
+ output_opts=CLI_OUTPUT_OPTS,
182
+ type_mapping=CLI_TYPE_MAPPING
183
+ )
184
+
185
+ # TODO: maybe allow this in the future
186
+ # def get_unknown_opts(ctx):
187
+ # return {
188
+ # (ctx.args[i][2:]
189
+ # if str(ctx.args[i]).startswith("--") \
190
+ # else ctx.args[i][1:]): ctx.args[i+1]
191
+ # for i in range(0, len(ctx.args), 2)
192
+ # }
193
+
194
+ @click.argument('inputs', metavar=input_types_str, required=input_required)
195
+ @decorate_command_options(options)
196
+ @click.pass_context
197
+ def func(ctx, **opts):
198
+ console = _get_rich_console()
199
+ version = opts['version']
200
+ sync = opts['sync']
201
+ ws = opts.pop('workspace')
202
+ driver = opts.pop('driver', '')
203
+ quiet = opts['quiet']
204
+ dry_run = opts['dry_run']
205
+ yaml = opts['yaml']
206
+ tree = opts['tree']
207
+ context = {'workspace_name': ws}
208
+ enable_pyinstrument = opts['enable_pyinstrument']
209
+ enable_memray = opts['enable_memray']
210
+ contextmanager = nullcontext()
211
+ process = None
212
+
213
+ # Set dry run
214
+ ctx.obj['dry_run'] = dry_run
215
+
216
+ # Show version
217
+ if version:
218
+ if not cli_endpoint.name == 'task':
219
+ console.print(f'[bold red]Version information is not available for {cli_endpoint.name}.[/]')
220
+ sys.exit(1)
221
+ data = task_cls.get_version_info()
222
+ current = data['version']
223
+ latest = data['latest_version']
224
+ installed = data['installed']
225
+ if not installed:
226
+ console.print(f'[bold red]{task_cls.__name__} is not installed.[/]')
227
+ else:
228
+ console.print(f'{task_cls.__name__} version: [bold green]{current}[/] (recommended: [bold green]{latest}[/])')
229
+ sys.exit(0)
230
+
231
+ # Show runner yaml
232
+ if yaml:
233
+ config.print()
234
+ sys.exit(0)
235
+
236
+ # Show runner tree
237
+ if tree:
238
+ tree = build_runner_tree(config)
239
+ console.print(tree.render_tree())
240
+ sys.exit(0)
241
+
242
+ # TODO: maybe allow this in the future
243
+ # unknown_opts = get_unknown_opts(ctx)
244
+ # opts.update(unknown_opts)
245
+
246
+ # Expand input
247
+ inputs = opts.pop('inputs')
248
+ inputs = expand_input(inputs, ctx)
249
+
250
+ # Build hooks from driver name
251
+ hooks = []
252
+ drivers = driver.split(',') if driver else []
253
+ drivers = list(set(CONFIG.drivers.defaults + drivers))
254
+ supported_drivers = ['mongodb', 'gcs']
255
+ for driver in drivers:
256
+ if driver in supported_drivers:
257
+ if not ADDONS_ENABLED[driver]:
258
+ console.print(f'[bold red]Missing "{driver}" addon: please run `secator install addons {driver}`[/].')
259
+ sys.exit(1)
260
+ from secator.utils import import_dynamic
261
+ driver_hooks = import_dynamic(f'secator.hooks.{driver}', 'HOOKS')
262
+ if driver_hooks is None:
263
+ console.print(f'[bold red]Missing "secator.hooks.{driver}.HOOKS".[/]')
264
+ sys.exit(1)
265
+ hooks.append(driver_hooks)
266
+ else:
267
+ supported_drivers_str = ', '.join([f'[bold green]{_}[/]' for _ in supported_drivers])
268
+ console.print(f'[bold red]Driver "{driver}" is not supported.[/]')
269
+ console.print(f'Supported drivers: {supported_drivers_str}')
270
+ sys.exit(1)
271
+
272
+ if enable_pyinstrument or enable_memray:
273
+ if not ADDONS_ENABLED["trace"]:
274
+ console.print(
275
+ '[bold red]Missing "trace" addon: please run `secator install addons trace`[/].'
276
+ )
277
+ sys.exit(1)
278
+ import memray
279
+ output_file = f'trace_memray_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.bin'
280
+ contextmanager = memray.Tracker(output_file)
281
+
282
+ from secator.utils import deep_merge_dicts
283
+ hooks = deep_merge_dicts(*hooks)
284
+
285
+ # Enable sync or not
286
+ if sync or dry_run:
287
+ sync = True
288
+ else:
289
+ from secator.celery import is_celery_worker_alive
290
+ worker_alive = is_celery_worker_alive()
291
+ if not worker_alive and not sync:
292
+ sync = True
293
+ else:
294
+ sync = False
295
+ broker_protocol = CONFIG.celery.broker_url.split('://')[0]
296
+ backend_protocol = CONFIG.celery.result_backend.split('://')[0]
297
+ if CONFIG.celery.broker_url and \
298
+ (broker_protocol == 'redis' or backend_protocol == 'redis') \
299
+ and not ADDONS_ENABLED['redis']:
300
+ _get_rich_console().print('[bold red]Missing `redis` addon: please run `secator install addons redis`[/].')
301
+ sys.exit(1)
302
+
303
+ from secator.utils import debug
304
+ debug('Run options', obj=opts, sub='cli')
305
+
306
+ # Set run options
307
+ opts.update({
308
+ 'print_cmd': True,
309
+ 'print_item': True,
310
+ 'print_line': True,
311
+ 'print_progress': True,
312
+ 'print_profiles': True,
313
+ 'print_start': True,
314
+ 'print_target': True,
315
+ 'print_end': True,
316
+ 'print_remote_info': not sync,
317
+ 'piped_input': ctx.obj['piped_input'],
318
+ 'piped_output': ctx.obj['piped_output'],
319
+ 'caller': 'cli',
320
+ 'sync': sync,
321
+ 'quiet': quiet
322
+ })
323
+
324
+ # Start runner
325
+ with contextmanager:
326
+ if enable_memray:
327
+ process = psutil.Process()
328
+ console.print(
329
+ f"[bold yellow3]Initial RAM Usage: {process.memory_info().rss / 1024 ** 2} MB[/]"
330
+ )
331
+ item_count = 0
332
+ runner = runner_cls(
333
+ config, inputs, run_opts=opts, hooks=hooks, context=context
334
+ )
335
+ for item in runner:
336
+ del item
337
+ item_count += 1
338
+ if process and item_count % 100 == 0:
339
+ console.print(
340
+ f"[bold yellow3]RAM Usage: {process.memory_info().rss / 1024 ** 2} MB[/]"
341
+ )
342
+
343
+ if enable_memray:
344
+ console.print(f"[bold green]Memray output file: {output_file}[/]")
345
+ os.system(f"memray flamegraph {output_file}")
346
+
347
+ generate_cli_subcommand(cli_endpoint, func, **command_opts)
348
+ generate_rich_click_opt_groups(cli_endpoint, name, input_types, options)
349
+
350
+
351
+ def generate_rich_click_opt_groups(cli_endpoint, name, input_types, options):
352
+ sortorder = {
353
+ 'Execution': 0,
354
+ 'Output': 1,
355
+ 'Meta': 2,
356
+ 'Config.*': 3,
357
+ 'Shared task': 4,
358
+ 'Task.*': 5,
359
+ 'Workflow.*': 6,
360
+ 'Scan.*': 7,
361
+ }
362
+
363
+ def match_sort_order(prefix):
364
+ for k, v in sortorder.items():
365
+ if re.match(k, prefix):
366
+ return v
367
+ return 8
368
+
369
+ prefixes = deduplicate([opt['prefix'] for opt in options.values()])
370
+ prefixes = sorted(prefixes, key=match_sort_order)
371
+ opt_group = [
372
+ {
373
+ 'name': 'Targets',
374
+ 'options': input_types,
375
+ },
376
+ ]
377
+ for prefix in prefixes:
378
+ prefix_opts = [
379
+ opt for opt, conf in options.items()
380
+ if conf['prefix'] == prefix
381
+ ]
382
+ if prefix not in ['Execution', 'Output']:
383
+ prefix_opts = sorted(prefix_opts)
384
+ opt_names = [f'--{opt_name}' for opt_name in prefix_opts]
385
+ if prefix == 'Output':
386
+ opt_names.append('--help')
387
+ opt_group.append({
388
+ 'name': prefix + ' options',
389
+ 'options': opt_names
390
+ })
391
+ aliases = [cli_endpoint.name, *cli_endpoint.aliases]
392
+ for alias in aliases:
393
+ endpoint_name = f'secator {alias} {name}'
394
+ click.rich_click.OPTION_GROUPS[endpoint_name] = opt_group
secator/click.py ADDED
@@ -0,0 +1,87 @@
1
+ from collections import OrderedDict
2
+
3
+ import rich_click as click
4
+ from rich_click.rich_click import _get_rich_console
5
+ from rich_click.rich_group import RichGroup
6
+
7
+
8
+ class ListParamType(click.ParamType):
9
+ """Custom click param type to convert comma-separated strings to lists."""
10
+ name = "list"
11
+
12
+ def convert(self, value, param, ctx):
13
+ if value is None:
14
+ return []
15
+ if isinstance(value, list):
16
+ return value
17
+ return [v.strip() for v in value.split(',') if v.strip()]
18
+
19
+
20
+ CLICK_LIST = ListParamType()
21
+
22
+
23
+ class OrderedGroup(RichGroup):
24
+ def __init__(self, name=None, commands=None, **attrs):
25
+ super(OrderedGroup, self).__init__(name, commands, **attrs)
26
+ self.commands = commands or OrderedDict()
27
+
28
+ def command(self, *args, **kwargs):
29
+ """Behaves the same as `click.Group.command()` but supports aliases.
30
+ """
31
+ def decorator(f):
32
+ aliases = kwargs.pop("aliases", None)
33
+ if aliases:
34
+ max_width = _get_rich_console().width
35
+ aliases_str = ', '.join(f'[bold cyan]{alias}[/]' for alias in aliases)
36
+ padding = max_width // 4
37
+
38
+ name = kwargs.pop("name", None)
39
+ if not name:
40
+ raise click.UsageError("`name` command argument is required when using aliases.")
41
+
42
+ f.__doc__ = f.__doc__ or '\0'.ljust(padding+1)
43
+ f.__doc__ = f'{f.__doc__:<{padding}}[dim](aliases)[/] {aliases_str}'
44
+ base_command = super(OrderedGroup, self).command(
45
+ name, *args, **kwargs
46
+ )(f)
47
+ for alias in aliases:
48
+ cmd = super(OrderedGroup, self).command(alias, *args, hidden=True, **kwargs)(f)
49
+ cmd.help = f"Alias for '{name}'.\n\n{cmd.help}"
50
+ cmd.params = base_command.params
51
+
52
+ else:
53
+ cmd = super(OrderedGroup, self).command(*args, **kwargs)(f)
54
+
55
+ return cmd
56
+ return decorator
57
+
58
+ def group(self, *args, **kwargs):
59
+ """Behaves the same as `click.Group.group()` but supports aliases.
60
+ """
61
+ def decorator(f):
62
+ aliases = kwargs.pop('aliases', [])
63
+ aliased_group = []
64
+ if aliases:
65
+ max_width = _get_rich_console().width
66
+ aliases_str = ', '.join(f'[bold cyan]{alias}[/]' for alias in aliases)
67
+ padding = max_width // 4
68
+ f.__doc__ = f.__doc__ or '\0'.ljust(padding+1)
69
+ f.__doc__ = f'{f.__doc__:<{padding}}[dim](aliases)[/] {aliases_str}'
70
+ for alias in aliases:
71
+ grp = super(OrderedGroup, self).group(
72
+ alias, *args, hidden=True, **kwargs)(f)
73
+ aliased_group.append(grp)
74
+
75
+ # create the main group
76
+ grp = super(OrderedGroup, self).group(*args, **kwargs)(f)
77
+ grp.aliases = aliases
78
+
79
+ # for all of the aliased groups, share the main group commands
80
+ for aliased in aliased_group:
81
+ aliased.commands = grp.commands
82
+
83
+ return grp
84
+ return decorator
85
+
86
+ def list_commands(self, ctx):
87
+ return self.commands
secator/config.py CHANGED
@@ -4,7 +4,9 @@ from subprocess import call, DEVNULL
4
4
  from typing import Dict, List
5
5
  from typing_extensions import Annotated, Self
6
6
 
7
+ import validators
7
8
  import requests
9
+ import shutil
8
10
  import yaml
9
11
  from dotenv import find_dotenv, load_dotenv
10
12
  from dotmap import DotMap
@@ -52,11 +54,6 @@ class Directories(StrictModel):
52
54
  return self
53
55
 
54
56
 
55
- class Debug(StrictModel):
56
- level: int = 0
57
- component: str = ''
58
-
59
-
60
57
  class Celery(StrictModel):
61
58
  broker_url: str = 'filesystem://'
62
59
  broker_pool_limit: int = 10
@@ -75,12 +72,16 @@ class Celery(StrictModel):
75
72
  worker_send_task_events: bool = False
76
73
  worker_kill_after_task: bool = False
77
74
  worker_kill_after_idle_seconds: int = -1
75
+ worker_command_verbose: bool = False
78
76
 
79
77
 
80
78
  class Cli(StrictModel):
81
79
  github_token: str = os.environ.get('GITHUB_TOKEN', '')
82
80
  record: bool = False
83
81
  stdin_timeout: int = 1000
82
+ show_http_response_headers: bool = False
83
+ show_command_output: bool = False
84
+ exclude_http_response_headers: List[str] = ["connection", "content_type", "content_length", "date", "server"]
84
85
 
85
86
 
86
87
  class Runners(StrictModel):
@@ -93,8 +94,6 @@ class Runners(StrictModel):
93
94
  skip_exploit_search: bool = False
94
95
  skip_cve_low_confidence: bool = False
95
96
  remove_duplicates: bool = False
96
- show_chunk_progress: bool = False
97
- show_command_output: bool = False
98
97
 
99
98
 
100
99
  class Security(StrictModel):
@@ -124,6 +123,14 @@ class Scans(StrictModel):
124
123
  exporters: List[str] = ['json', 'csv', 'txt']
125
124
 
126
125
 
126
+ class Profiles(StrictModel):
127
+ defaults: List[str] = []
128
+
129
+
130
+ class Drivers(StrictModel):
131
+ defaults: List[str] = []
132
+
133
+
127
134
  class Payloads(StrictModel):
128
135
  templates: Dict[str, str] = {
129
136
  'lse': 'https://github.com/diego-treitos/linux-smart-enumeration/releases/latest/download/lse.sh',
@@ -174,8 +181,8 @@ class Addons(StrictModel):
174
181
 
175
182
 
176
183
  class SecatorConfig(StrictModel):
184
+ debug: str = ''
177
185
  dirs: Directories = Directories()
178
- debug: Debug = Debug()
179
186
  celery: Celery = Celery()
180
187
  cli: Cli = Cli()
181
188
  runners: Runners = Runners()
@@ -185,6 +192,8 @@ class SecatorConfig(StrictModel):
185
192
  scans: Scans = Scans()
186
193
  payloads: Payloads = Payloads()
187
194
  wordlists: Wordlists = Wordlists()
195
+ profiles: Profiles = Profiles()
196
+ drivers: Drivers = Drivers()
188
197
  addons: Addons = Addons()
189
198
  security: Security = Security()
190
199
  offline_mode: bool = False
@@ -288,13 +297,25 @@ class Config(DotMap):
288
297
  elif isinstance(existing_value, Path):
289
298
  value = Path(value)
290
299
  except ValueError:
291
- # from secator.utils import debug
292
- # debug(f'Could not cast value {value} to expected type {type(existing_value).__name__}: {str(e)}', sub='config')
293
300
  pass
294
301
  finally:
295
- target[final_key] = value
296
302
  if set_partial:
297
- partial[final_key] = value
303
+ if value is None or value == target[final_key]:
304
+ if final_key in partial:
305
+ del partial[final_key]
306
+ return
307
+ else:
308
+ partial[final_key] = value
309
+ target[final_key] = value
310
+
311
+ def unset(self, key, set_partial=True):
312
+ """Unset a value in the configuration using a dotted path.
313
+
314
+ Args:
315
+ key (str): Dotted key path.
316
+ set_partial (bool): Set in partial config.
317
+ """
318
+ self.set(key, None, set_partial=set_partial)
298
319
 
299
320
  def save(self, target_path: Path = None, partial=True):
300
321
  """Save config as YAML on disk.
@@ -535,6 +556,7 @@ def download_file(url_or_path, target_folder: Path, offline_mode: bool, type: st
535
556
  Returns:
536
557
  path (Path): Path to downloaded file / folder.
537
558
  """
559
+ from secator.output_types import Info, Error
538
560
  if url_or_path.startswith('git+'):
539
561
  # Clone Git repository
540
562
  git_url = url_or_path[4:] # remove 'git+' prefix
@@ -543,7 +565,7 @@ def download_file(url_or_path, target_folder: Path, offline_mode: bool, type: st
543
565
  repo_name = repo_name[:-4]
544
566
  target_path = target_folder / repo_name
545
567
  if not target_path.exists():
546
- console.print(f'[bold turquoise4]Cloning git {type} [bold magenta]{repo_name}[/] ...[/] ', end='')
568
+ console.print(repr(Info(message=f'[bold turquoise4]Cloning git {type} [bold magenta]{repo_name}[/] ...[/] ')), highlight=False, end='') # noqa: E501
547
569
  if offline_mode:
548
570
  console.print('[bold orange1]skipped [dim][offline[/].[/]')
549
571
  return
@@ -551,47 +573,53 @@ def download_file(url_or_path, target_folder: Path, offline_mode: bool, type: st
551
573
  call(['git', 'clone', git_url, str(target_path)], stderr=DEVNULL, stdout=DEVNULL)
552
574
  console.print('[bold green]ok.[/]')
553
575
  except Exception as e:
576
+ error = Error.from_exception(e)
554
577
  console.print(f'[bold red]failed ({str(e)}).[/]')
578
+ console.print(error)
555
579
  return target_path.resolve()
556
580
  elif Path(url_or_path).exists():
557
- # Create a symbolic link for a local file
581
+ # Move local file to target folder
558
582
  local_path = Path(url_or_path)
559
583
  target_path = target_folder / local_path.name
560
584
  if not name:
561
585
  name = url_or_path.split('/')[-1]
562
- if not CONFIG.security.allow_local_file_access:
563
- console.print(f'[bold red]Cannot reference local file {url_or_path}(disabled for security reasons)[/]')
564
- return
565
- if not target_path.exists():
566
- console.print(f'[bold turquoise4]Symlinking {type} [bold magenta]{name}[/] ...[/] ', end='')
567
- try:
568
- target_path.symlink_to(local_path)
569
- console.print('[bold green]ok.[/]')
570
- except Exception as e:
571
- console.print(f'[bold red]failed ({str(e)}).[/]')
586
+ try:
587
+ local_path.resolve().relative_to(CONFIG.dirs.data.resolve())
588
+ except ValueError:
589
+ if not CONFIG.security.allow_local_file_access:
590
+ console.print(Error(message=f'File {local_path.resolve()} is not in {CONFIG.dirs.data} and security.allow_local_file_access is disabled.')) # noqa: E501
591
+ return None
592
+ from secator.output_types import Info
593
+ console.print(repr(Info(message=f'[bold turquoise4]Copying {type} [bold magenta]{name}[/] to {target_folder} ...[/] ')), highlight=False, end='') # noqa: E501
594
+ shutil.copyfile(local_path, target_folder / name)
595
+ target_path = target_folder / local_path.name
596
+ console.print('[bold green]ok.[/]')
572
597
  return target_path.resolve()
573
- else:
598
+ elif validators.url(url_or_path):
574
599
  # Download file from URL
575
600
  ext = url_or_path.split('.')[-1]
576
601
  if not name:
577
602
  name = url_or_path.split('/')[-1]
578
603
  filename = f'{name}.{ext}' if not name.endswith(ext) else name
579
604
  target_path = target_folder / filename
580
- if not target_path.exists():
581
- try:
582
- console.print(f'[bold turquoise4]Downloading {type} [bold magenta]{filename}[/] ...[/] ', end='')
583
- if offline_mode:
584
- console.print('[bold orange1]skipped [dim](offline)[/].[/]')
585
- return
586
- resp = requests.get(url_or_path, timeout=3)
587
- resp.raise_for_status()
588
- with open(target_path, 'wb') as f:
589
- f.write(resp.content)
590
- console.print('[bold green]ok.[/]')
591
- except requests.RequestException as e:
592
- console.print(f'[bold red]failed ({str(e)}).[/]')
605
+ try:
606
+ if offline_mode:
593
607
  return
608
+ if target_path.exists():
609
+ return target_path.resolve()
610
+ console.print(repr(Info(message=f'[bold turquoise4]Downloading {type} [bold magenta]{filename}[/] ...[/] ')), highlight=False, end='') # noqa: E501
611
+ resp = requests.get(url_or_path, timeout=3)
612
+ resp.raise_for_status()
613
+ with open(target_path, 'wb') as f:
614
+ f.write(resp.content)
615
+ console.print('[bold green]ok.[/]')
616
+ except requests.RequestException as e:
617
+ console.print(f'[bold red]failed ({str(e)}).[/]')
618
+ return
594
619
  return target_path.resolve()
620
+ else:
621
+ console.print(Error(message=f'Invalid {type} [bold magenta]{url_or_path}[/]: not a valid git repository, URL or local path.')) # noqa: E501
622
+ return None
595
623
 
596
624
 
597
625
  # Load default_config
@@ -628,5 +656,5 @@ for name, dir in CONFIG.dirs.items():
628
656
  # download_files(CONFIG.payloads.templates, CONFIG.dirs.payloads, CONFIG.offline_mode, 'payload')
629
657
 
630
658
  # Print config
631
- if CONFIG.debug.component == 'config':
659
+ if 'config' in CONFIG.debug:
632
660
  CONFIG.print()