secator 0.15.1__py3-none-any.whl → 0.16.1__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.
- secator/celery.py +40 -24
- secator/celery_signals.py +71 -68
- secator/celery_utils.py +43 -27
- secator/cli.py +520 -280
- secator/cli_helper.py +394 -0
- secator/click.py +87 -0
- secator/config.py +67 -39
- secator/configs/profiles/http_headless.yaml +6 -0
- secator/configs/profiles/http_record.yaml +6 -0
- secator/configs/profiles/tor.yaml +1 -1
- secator/configs/scans/domain.yaml +4 -2
- secator/configs/scans/host.yaml +1 -1
- secator/configs/scans/network.yaml +1 -4
- secator/configs/scans/subdomain.yaml +13 -1
- secator/configs/scans/url.yaml +1 -2
- secator/configs/workflows/cidr_recon.yaml +6 -4
- secator/configs/workflows/code_scan.yaml +1 -1
- secator/configs/workflows/host_recon.yaml +29 -3
- secator/configs/workflows/subdomain_recon.yaml +67 -16
- secator/configs/workflows/url_crawl.yaml +44 -15
- secator/configs/workflows/url_dirsearch.yaml +4 -4
- secator/configs/workflows/url_fuzz.yaml +25 -17
- secator/configs/workflows/url_params_fuzz.yaml +7 -0
- secator/configs/workflows/url_vuln.yaml +33 -8
- secator/configs/workflows/user_hunt.yaml +4 -2
- secator/configs/workflows/wordpress.yaml +5 -3
- secator/cve.py +718 -0
- secator/decorators.py +0 -454
- secator/definitions.py +49 -30
- secator/exporters/_base.py +2 -2
- secator/exporters/console.py +2 -2
- secator/exporters/table.py +4 -3
- secator/exporters/txt.py +1 -1
- secator/hooks/mongodb.py +2 -4
- secator/installer.py +77 -49
- secator/loader.py +116 -0
- secator/output_types/_base.py +3 -0
- secator/output_types/certificate.py +63 -63
- secator/output_types/error.py +4 -5
- secator/output_types/info.py +2 -2
- secator/output_types/ip.py +3 -1
- secator/output_types/progress.py +5 -9
- secator/output_types/state.py +17 -17
- secator/output_types/tag.py +3 -0
- secator/output_types/target.py +10 -2
- secator/output_types/url.py +19 -7
- secator/output_types/vulnerability.py +11 -7
- secator/output_types/warning.py +2 -2
- secator/report.py +27 -15
- secator/rich.py +18 -10
- secator/runners/_base.py +446 -233
- secator/runners/_helpers.py +133 -24
- secator/runners/command.py +182 -102
- secator/runners/scan.py +33 -5
- secator/runners/task.py +13 -7
- secator/runners/workflow.py +105 -72
- secator/scans/__init__.py +2 -2
- secator/serializers/dataclass.py +20 -20
- secator/tasks/__init__.py +4 -4
- secator/tasks/_categories.py +39 -27
- secator/tasks/arjun.py +9 -5
- secator/tasks/bbot.py +53 -21
- secator/tasks/bup.py +19 -5
- secator/tasks/cariddi.py +24 -3
- secator/tasks/dalfox.py +26 -7
- secator/tasks/dirsearch.py +10 -4
- secator/tasks/dnsx.py +70 -25
- secator/tasks/feroxbuster.py +11 -3
- secator/tasks/ffuf.py +42 -6
- secator/tasks/fping.py +20 -8
- secator/tasks/gau.py +3 -1
- secator/tasks/gf.py +3 -3
- secator/tasks/gitleaks.py +2 -2
- secator/tasks/gospider.py +7 -1
- secator/tasks/grype.py +5 -4
- secator/tasks/h8mail.py +2 -1
- secator/tasks/httpx.py +18 -5
- secator/tasks/katana.py +35 -15
- secator/tasks/maigret.py +4 -4
- secator/tasks/mapcidr.py +3 -3
- secator/tasks/msfconsole.py +4 -4
- secator/tasks/naabu.py +2 -2
- secator/tasks/nmap.py +12 -14
- secator/tasks/nuclei.py +3 -3
- secator/tasks/searchsploit.py +4 -5
- secator/tasks/subfinder.py +2 -2
- secator/tasks/testssl.py +264 -263
- secator/tasks/trivy.py +5 -5
- secator/tasks/wafw00f.py +21 -3
- secator/tasks/wpprobe.py +90 -83
- secator/tasks/wpscan.py +6 -5
- secator/template.py +218 -104
- secator/thread.py +15 -15
- secator/tree.py +196 -0
- secator/utils.py +131 -123
- secator/utils_test.py +60 -19
- secator/workflows/__init__.py +2 -2
- {secator-0.15.1.dist-info → secator-0.16.1.dist-info}/METADATA +36 -36
- secator-0.16.1.dist-info/RECORD +132 -0
- secator/configs/profiles/default.yaml +0 -8
- secator/configs/workflows/url_nuclei.yaml +0 -11
- secator/tasks/dnsxbrute.py +0 -42
- secator-0.15.1.dist-info/RECORD +0 -128
- {secator-0.15.1.dist-info → secator-0.16.1.dist-info}/WHEEL +0 -0
- {secator-0.15.1.dist-info → secator-0.16.1.dist-info}/entry_points.txt +0 -0
- {secator-0.15.1.dist-info → secator-0.16.1.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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
|
659
|
+
if 'config' in CONFIG.debug:
|
|
632
660
|
CONFIG.print()
|