secator 0.10.1a12__py3-none-any.whl → 0.15.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.

Files changed (73) hide show
  1. secator/celery.py +10 -5
  2. secator/celery_signals.py +2 -11
  3. secator/cli.py +309 -69
  4. secator/config.py +3 -2
  5. secator/configs/profiles/aggressive.yaml +6 -5
  6. secator/configs/profiles/default.yaml +6 -7
  7. secator/configs/profiles/insane.yaml +8 -0
  8. secator/configs/profiles/paranoid.yaml +8 -0
  9. secator/configs/profiles/polite.yaml +8 -0
  10. secator/configs/profiles/sneaky.yaml +8 -0
  11. secator/configs/profiles/tor.yaml +5 -0
  12. secator/configs/workflows/host_recon.yaml +11 -2
  13. secator/configs/workflows/url_dirsearch.yaml +5 -0
  14. secator/configs/workflows/url_params_fuzz.yaml +25 -0
  15. secator/configs/workflows/wordpress.yaml +4 -1
  16. secator/decorators.py +64 -34
  17. secator/definitions.py +8 -4
  18. secator/installer.py +84 -49
  19. secator/output_types/__init__.py +2 -1
  20. secator/output_types/certificate.py +78 -0
  21. secator/output_types/stat.py +3 -0
  22. secator/output_types/user_account.py +1 -1
  23. secator/report.py +2 -2
  24. secator/rich.py +1 -1
  25. secator/runners/_base.py +50 -11
  26. secator/runners/_helpers.py +15 -3
  27. secator/runners/command.py +85 -21
  28. secator/runners/scan.py +6 -3
  29. secator/runners/task.py +1 -0
  30. secator/runners/workflow.py +22 -4
  31. secator/tasks/_categories.py +25 -17
  32. secator/tasks/arjun.py +92 -0
  33. secator/tasks/bbot.py +33 -4
  34. secator/tasks/bup.py +4 -2
  35. secator/tasks/cariddi.py +17 -4
  36. secator/tasks/dalfox.py +4 -2
  37. secator/tasks/dirsearch.py +4 -2
  38. secator/tasks/dnsx.py +5 -2
  39. secator/tasks/dnsxbrute.py +4 -1
  40. secator/tasks/feroxbuster.py +5 -2
  41. secator/tasks/ffuf.py +7 -3
  42. secator/tasks/fping.py +4 -1
  43. secator/tasks/gau.py +5 -2
  44. secator/tasks/gf.py +4 -2
  45. secator/tasks/gitleaks.py +79 -0
  46. secator/tasks/gospider.py +5 -2
  47. secator/tasks/grype.py +5 -2
  48. secator/tasks/h8mail.py +4 -2
  49. secator/tasks/httpx.py +6 -3
  50. secator/tasks/katana.py +6 -3
  51. secator/tasks/maigret.py +4 -2
  52. secator/tasks/mapcidr.py +5 -3
  53. secator/tasks/msfconsole.py +8 -6
  54. secator/tasks/naabu.py +16 -5
  55. secator/tasks/nmap.py +31 -29
  56. secator/tasks/nuclei.py +18 -10
  57. secator/tasks/searchsploit.py +8 -3
  58. secator/tasks/subfinder.py +6 -3
  59. secator/tasks/testssl.py +276 -0
  60. secator/tasks/trivy.py +98 -0
  61. secator/tasks/wafw00f.py +85 -0
  62. secator/tasks/wpprobe.py +96 -0
  63. secator/tasks/wpscan.py +8 -4
  64. secator/template.py +61 -67
  65. secator/utils.py +31 -18
  66. secator/utils_test.py +34 -10
  67. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/METADATA +11 -3
  68. secator-0.15.1.dist-info/RECORD +128 -0
  69. secator/configs/profiles/stealth.yaml +0 -7
  70. secator-0.10.1a12.dist-info/RECORD +0 -116
  71. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/WHEEL +0 -0
  72. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/entry_points.txt +0 -0
  73. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/licenses/LICENSE +0 -0
secator/tasks/trivy.py ADDED
@@ -0,0 +1,98 @@
1
+ import click
2
+ import os
3
+ import yaml
4
+
5
+ from secator.config import CONFIG
6
+ from secator.decorators import task
7
+ from secator.definitions import (THREADS, OUTPUT_PATH, OPT_NOT_SUPPORTED, HEADER, DELAY, FOLLOW_REDIRECT,
8
+ DOCKER_IMAGE, PATH, GIT_REPOSITORY, PROXY, RATE_LIMIT, RETRIES, TIMEOUT,
9
+ USER_AGENT)
10
+ from secator.tasks._categories import Vuln
11
+ from secator.output_types import Vulnerability, Tag, Info, Error
12
+
13
+
14
+ @task()
15
+ class trivy(Vuln):
16
+ """Comprehensive and versatile security scanner."""
17
+ cmd = 'trivy'
18
+ tags = ['vuln', 'scan']
19
+ input_flag = None
20
+ input_types = [DOCKER_IMAGE, PATH, GIT_REPOSITORY]
21
+ json_flag = '-f json'
22
+ opts = {
23
+ "mode": {"type": click.Choice(['image', 'fs', 'repo']), 'default': 'image', 'help': 'Trivy mode', 'required': True} # noqa: E501
24
+ }
25
+ opt_key_map = {
26
+ THREADS: OPT_NOT_SUPPORTED,
27
+ HEADER: OPT_NOT_SUPPORTED,
28
+ DELAY: OPT_NOT_SUPPORTED,
29
+ FOLLOW_REDIRECT: OPT_NOT_SUPPORTED,
30
+ PROXY: OPT_NOT_SUPPORTED,
31
+ RATE_LIMIT: OPT_NOT_SUPPORTED,
32
+ RETRIES: OPT_NOT_SUPPORTED,
33
+ TIMEOUT: OPT_NOT_SUPPORTED,
34
+ USER_AGENT: OPT_NOT_SUPPORTED
35
+ }
36
+ output_types = [Tag, Vulnerability]
37
+ install_version = 'v0.61.1'
38
+ install_cmd = (
39
+ 'curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh |'
40
+ f'sudo sh -s -- -b {CONFIG.dirs.bin} [install_version]'
41
+ )
42
+ install_github_handle = 'aquasecurity/trivy'
43
+
44
+ @staticmethod
45
+ def on_cmd(self):
46
+ mode = self.get_opt_value('mode')
47
+ output_path = self.get_opt_value(OUTPUT_PATH)
48
+ if not output_path:
49
+ output_path = f'{self.reports_folder}/.outputs/{self.unique_name}.json'
50
+ self.output_path = output_path
51
+ self.cmd = self.cmd.replace(f' -mode {mode}', '').replace('trivy', f'trivy {mode}')
52
+ self.cmd += f' -o {self.output_path}'
53
+
54
+ @staticmethod
55
+ def on_cmd_done(self):
56
+ if not os.path.exists(self.output_path):
57
+ yield Error(message=f'Could not find JSON results in {self.output_path}')
58
+ return
59
+
60
+ yield Info(message=f'JSON results saved to {self.output_path}')
61
+ with open(self.output_path, 'r') as f:
62
+ results = yaml.safe_load(f.read()).get('Results', [])
63
+ for item in results:
64
+ for vuln in item.get('Vulnerabilities', []):
65
+ vuln_id = vuln['VulnerabilityID']
66
+ extra_data = {}
67
+ if 'PkgName' in vuln:
68
+ extra_data['product'] = vuln['PkgName']
69
+ if 'InstalledVersion' in vuln:
70
+ extra_data['version'] = vuln['InstalledVersion']
71
+ cvss = vuln.get('CVSS', {})
72
+ cvss_score = -1
73
+ for _, cvss_data in cvss.items():
74
+ cvss_score = cvss_data.get('V3Score', -1) or cvss_data.get('V2Score', -1)
75
+ data = {
76
+ 'name': vuln_id,
77
+ 'id': vuln_id,
78
+ 'provider': vuln.get('DataSource', {}).get('ID', ''),
79
+ 'description': vuln.get('Description'),
80
+ 'matched_at': self.inputs[0],
81
+ 'confidence': 'high',
82
+ 'severity': vuln['Severity'].lower(),
83
+ 'cvss_score': cvss_score,
84
+ 'reference': vuln.get('PrimaryURL', ''),
85
+ 'references': vuln.get('References', []),
86
+ 'extra_data': extra_data
87
+ }
88
+ if vuln_id.startswith('CVE'):
89
+ remote_data = Vuln.lookup_cve(vuln_id)
90
+ if remote_data:
91
+ data.update(remote_data)
92
+ yield Vulnerability(**data)
93
+ for secret in item.get('Secrets', []):
94
+ yield Tag(
95
+ name=secret['RuleID'],
96
+ match=secret['Match'],
97
+ extra_data={k: v for k, v in secret.items() if k not in ['RuleID', 'Match']}
98
+ )
@@ -0,0 +1,85 @@
1
+ import os
2
+ import yaml
3
+
4
+ from secator.decorators import task
5
+ from secator.runners import Command
6
+ from secator.definitions import (OUTPUT_PATH, HEADER, PROXY, URL, TIMEOUT)
7
+ from secator.output_types import Tag, Info, Error
8
+ from secator.tasks._categories import OPTS
9
+
10
+
11
+ @task()
12
+ class wafw00f(Command):
13
+ """Web Application Firewall Fingerprinting tool."""
14
+ cmd = 'wafw00f'
15
+ tags = ['waf', 'scan']
16
+ input_types = [URL]
17
+ input_flag = None
18
+ file_flag = '-i'
19
+ json_flag = '-f json'
20
+ encoding = 'ansi'
21
+ opt_prefix = '--'
22
+ meta_opts = {
23
+ PROXY: OPTS[PROXY],
24
+ HEADER: OPTS[HEADER],
25
+ TIMEOUT: OPTS[TIMEOUT]
26
+ }
27
+ opts = {
28
+ 'list': {'is_flag': True, 'default': False, 'help': 'List all WAFs that WAFW00F is able to detect'},
29
+ 'waf_type': {'type': str, 'short': 'wt', 'help': 'Test for one specific WAF'},
30
+ 'find_all': {'is_flag': True, 'short': 'ta', 'default': False, 'help': 'Find all WAFs which match the signatures, do not stop testing on the first one'}, # noqa: E501
31
+ 'no_follow_redirects': {'is_flag': True, 'short': 'nfr', 'default': False, 'help': 'Do not follow redirections given by 3xx responses'}, # noqa: E501
32
+ }
33
+ opt_key_map = {
34
+ HEADER: 'headers',
35
+ PROXY: 'proxy',
36
+ 'waf_type': 'test',
37
+ 'find_all': 'findall',
38
+ 'no_follow_redirects': 'noredirect',
39
+ }
40
+ output_types = [Tag]
41
+ install_version = 'v2.3.1'
42
+ install_cmd = 'pipx install git+https://github.com/EnableSecurity/wafw00f.git@[install_version] --force'
43
+ install_github_handle = 'EnableSecurity/wafw00f'
44
+ proxy_http = True
45
+
46
+ @staticmethod
47
+ def on_cmd(self):
48
+ self.output_path = self.get_opt_value(OUTPUT_PATH)
49
+ if not self.output_path:
50
+ self.output_path = f'{self.reports_folder}/.outputs/{self.unique_name}.json'
51
+ self.cmd += f' -o {self.output_path}'
52
+
53
+ self.headers = self.get_opt_value(HEADER)
54
+ if self.headers:
55
+ header_file = f'{self.reports_folder}/.inputs/headers.txt'
56
+ with open(header_file, 'w') as f:
57
+ for header in self.headers.split(';;'):
58
+ f.write(f'{header}\n')
59
+ self.cmd = self.cmd.replace(self.headers, header_file)
60
+
61
+ @staticmethod
62
+ def on_cmd_done(self):
63
+ # Skip parsing if list mode
64
+ list_mode = self.get_opt_value('list')
65
+ if list_mode:
66
+ return
67
+
68
+ if not os.path.exists(self.output_path):
69
+ yield Error(message=f'Could not find JSON results in {self.output_path}')
70
+ return
71
+
72
+ yield Info(message=f'JSON results saved to {self.output_path}')
73
+ with open(self.output_path, 'r') as f:
74
+ results = yaml.safe_load(f.read())
75
+
76
+ if len(results) > 0 and results[0]['detected']:
77
+ waf_name = results[0]['firewall']
78
+ url = results[0]['url']
79
+ match = results[0]['trigger_url']
80
+ manufacter = results[0]['manufacturer']
81
+ yield Tag(
82
+ name=waf_name + ' WAF',
83
+ match=url,
84
+ extra_data={'waf_name': waf_name, 'manufacter': manufacter, 'trigger_url': match}
85
+ )
@@ -0,0 +1,96 @@
1
+ import click
2
+ import yaml
3
+
4
+ from secator.decorators import task
5
+ from secator.runners import Command
6
+ from secator.definitions import OUTPUT_PATH, THREADS, URL
7
+ from secator.output_types import Vulnerability, Tag, Info, Warning
8
+ from secator.tasks._categories import OPTS
9
+
10
+
11
+ @task()
12
+ class wpprobe(Command):
13
+ """Fast wordpress plugin enumeration tool."""
14
+ cmd = 'wpprobe'
15
+ tags = ['vuln', 'scan', 'wordpress']
16
+ file_flag = '-f'
17
+ input_flag = '-u'
18
+ input_types = [URL]
19
+ opt_prefix = '-'
20
+ opts = {
21
+ 'mode': {'type': click.Choice(['scan', 'update', 'update-db']), 'default': 'scan', 'help': 'WPProbe mode', 'required': True, 'internal': True}, # noqa: E501
22
+ 'output_path': {'type': str, 'default': None, 'help': 'Output JSON file path', 'internal': True, 'display': False}, # noqa: E501
23
+ }
24
+ meta_opts = {
25
+ THREADS: OPTS[THREADS]
26
+ }
27
+ opt_key_map = {
28
+ THREADS: 't'
29
+ }
30
+ output_types = [Vulnerability, Tag]
31
+ install_version = 'v0.5.6'
32
+ install_cmd = 'go install github.com/Chocapikk/wpprobe@[install_version]'
33
+ install_github_handle = 'Chocapikk/wpprobe'
34
+ install_post = {
35
+ '*': 'wpprobe update && wpprobe update-db'
36
+ }
37
+
38
+ @staticmethod
39
+ def on_cmd(self):
40
+ mode = self.get_opt_value('mode')
41
+ if mode == 'update' or mode == 'update-db':
42
+ self.cmd = f'{wpprobe.cmd} {mode}'
43
+ return
44
+ self.cmd = self.cmd.replace(wpprobe.cmd, f'{wpprobe.cmd} {mode}')
45
+ output_path = self.get_opt_value(OUTPUT_PATH)
46
+ if not output_path:
47
+ output_path = f'{self.reports_folder}/.outputs/{self.unique_name}.json'
48
+ self.output_path = output_path
49
+ self.cmd += f' -o {self.output_path}'
50
+
51
+ @staticmethod
52
+ def on_cmd_done(self):
53
+ if not self.get_opt_value('mode') == 'scan':
54
+ return
55
+ yield Info(message=f'JSON results saved to {self.output_path}')
56
+ with open(self.output_path, 'r') as f:
57
+ results = yaml.safe_load(f.read())
58
+ if not results or 'url' not in results:
59
+ yield Warning(message='No results found !')
60
+ return
61
+ url = results['url']
62
+ for plugin_name, plugin_data in results['plugins'].items():
63
+ for plugin_data_version in plugin_data:
64
+ plugin_version = plugin_data_version['version']
65
+ yield Tag(
66
+ name=f'Wordpress plugin - {plugin_name} {plugin_version}',
67
+ match=url,
68
+ extra_data={
69
+ 'name': plugin_name,
70
+ 'version': plugin_version
71
+ }
72
+ )
73
+ severities = plugin_data_version.get('severities', {})
74
+ for severity, severity_data in severities.items():
75
+ if severity == 'None':
76
+ severity = 'unknown'
77
+ for item in severity_data:
78
+ for vuln in item['vulnerabilities']:
79
+ auth_type = item.get('auth_type')
80
+ extra_data = {
81
+ 'plugin_name': plugin_name,
82
+ 'plugin_version': plugin_version,
83
+ }
84
+ if auth_type:
85
+ extra_data['auth_type'] = auth_type
86
+ yield Vulnerability(
87
+ name=vuln['title'],
88
+ id=vuln['cve'],
89
+ severity=severity,
90
+ cvss_score=vuln['cvss_score'],
91
+ tags=[plugin_name],
92
+ reference=vuln['cve_link'],
93
+ extra_data=extra_data,
94
+ matched_at=url,
95
+ confidence='high'
96
+ )
secator/tasks/wpscan.py CHANGED
@@ -16,10 +16,11 @@ from secator.tasks._categories import VulnHttp
16
16
  @task()
17
17
  class wpscan(VulnHttp):
18
18
  """Wordpress security scanner."""
19
- cmd = 'wpscan --random-user-agent --force --verbose --disable-tls-checks --ignore-main-redirect'
19
+ cmd = 'wpscan --force --verbose'
20
+ tags = ['vuln', 'scan', 'wordpress']
20
21
  file_flag = None
21
22
  input_flag = '--url'
22
- input_type = URL
23
+ input_types = [URL]
23
24
  json_flag = '-f json'
24
25
  opt_prefix = '--'
25
26
  opts = {
@@ -30,7 +31,9 @@ class wpscan(VulnHttp):
30
31
  'passwords': {'type': str, 'help': 'List of passwords to use during the password attack.'},
31
32
  'usernames': {'type': str, 'help': 'List of usernames to use during the password attack.'},
32
33
  'login_uri': {'type': str, 'short': 'lu', 'help': 'URI of the login page if different from /wp-login.php'},
33
- 'detection_mode': {'type': str, 'short': 'dm', 'help': 'Detection mode between mixed, passive, and aggressive'}
34
+ 'detection_mode': {'type': str, 'short': 'dm', 'help': 'Detection mode between mixed, passive, and aggressive'},
35
+ 'random_user_agent': {'is_flag': True, 'short': 'rua', 'help': 'Random user agent'},
36
+ 'disable_tls_checks': {'is_flag': True, 'short': 'dtc', 'help': 'Disable TLS checks'}
34
37
  }
35
38
  opt_key_map = {
36
39
  HEADER: OPT_NOT_SUPPORTED,
@@ -72,7 +75,8 @@ class wpscan(VulnHttp):
72
75
  'pacman': ['make', 'ruby-erb'],
73
76
  '*': ['make']
74
77
  }
75
- install_cmd = f'gem install wpscan --user-install -n {CONFIG.dirs.bin}'
78
+ install_version = '3.8.28'
79
+ install_cmd = f'gem install wpscan -v [install_version] --user-install -n {CONFIG.dirs.bin}'
76
80
  install_post = {
77
81
  'kali': (
78
82
  f'gem uninstall nokogiri --user-install -n {CONFIG.dirs.bin} --force --executables && '
secator/template.py CHANGED
@@ -8,85 +8,45 @@ from dotmap import DotMap
8
8
 
9
9
  from secator.config import CONFIG, CONFIGS_FOLDER
10
10
  from secator.rich import console
11
- from secator.utils import convert_functions_to_strings
11
+ from secator.utils import convert_functions_to_strings, debug
12
+ from secator.output_types import Error
12
13
 
13
-
14
- TEMPLATES_DIR_KEYS = ['workflow', 'scan', 'profile']
15
-
16
-
17
- def load_template(name):
18
- """Load a config by name.
19
-
20
- Args:
21
- name: Name of the config, for instances profiles/aggressive or workflows/domain_scan.
22
-
23
- Returns:
24
- dict: Loaded config.
25
- """
26
- path = CONFIGS_FOLDER / f'{name}.yaml'
27
- if not path.exists():
28
- console.log(f'Config "{name}" could not be loaded.')
29
- return
30
- with path.open('r') as f:
31
- return yaml.load(f.read(), Loader=yaml.Loader)
32
-
33
-
34
- def find_templates():
35
- results = {'scan': [], 'workflow': [], 'profile': []}
36
- dirs_type = [CONFIGS_FOLDER]
37
- if CONFIG.dirs.templates:
38
- dirs_type.append(CONFIG.dirs.templates)
39
- paths = []
40
- for dir in dirs_type:
41
- dir_paths = [
42
- Path(path)
43
- for path in glob.glob(str(dir).rstrip('/') + '/**/*.y*ml', recursive=True)
44
- ]
45
- paths.extend(dir_paths)
46
- for path in paths:
47
- with path.open('r') as f:
48
- try:
49
- config = yaml.load(f.read(), yaml.Loader)
50
- type = config.get('type')
51
- if type:
52
- results[type].append(path)
53
- except yaml.YAMLError as exc:
54
- console.log(f'Unable to load config at {path}')
55
- console.log(str(exc))
56
- return results
14
+ TEMPLATES = []
57
15
 
58
16
 
59
17
  class TemplateLoader(DotMap):
60
18
 
61
19
  def __init__(self, input={}, name=None, **kwargs):
62
20
  if name:
63
- name = name.replace('-', '_') # so that workflows have a nice '-' in CLI
64
- config = self._load_from_name(name)
65
- elif isinstance(input, str) or isinstance(input, Path):
66
- config = self._load_from_file(input)
67
- else:
21
+ if '/' not in name:
22
+ console.print(Error(message=f'Cannot load {name}: you should specify a type for the template when loading by name (e.g. workflow/<workflow_name>)')) # noqa: E501
23
+ return
24
+ _type, name = name.split('/')
25
+ config = next((p for p in TEMPLATES if p['type'] == _type and p['name'] == name in str(p)), None)
26
+ if not config:
27
+ console.print(Error(message=f'Template {name} not found in loaded templates'))
28
+ config = {}
29
+ elif isinstance(input, dict):
68
30
  config = input
69
- super().__init__(config)
31
+ elif isinstance(input, Path) or Path(input).exists():
32
+ config = self._load_from_path(input)
33
+ config['_path'] = str(input)
34
+ elif isinstance(input, str):
35
+ config = self._load(input)
36
+ super().__init__(config, **kwargs)
70
37
 
71
- def _load_from_file(self, path):
72
- if isinstance(path, str):
73
- path = Path(path)
38
+ def add_to_templates(self):
39
+ TEMPLATES.append(self)
40
+
41
+ def _load_from_path(self, path):
74
42
  if not path.exists():
75
- console.log(f'Config path {path} does not exists', style='bold red')
43
+ console.print(Error(message=f'Config path {path} does not exists'))
76
44
  return
77
45
  with path.open('r') as f:
78
- return yaml.load(f.read(), Loader=yaml.Loader)
79
-
80
- def _load_from_name(self, name):
81
- return load_template(name)
46
+ return self._load(f.read())
82
47
 
83
- @classmethod
84
- def load_all(cls):
85
- configs = find_templates()
86
- return TemplateLoader({
87
- key: [TemplateLoader(path) for path in configs[key]]
88
- for key in TEMPLATES_DIR_KEYS
89
- })
48
+ def _load(self, input):
49
+ return yaml.load(input, Loader=yaml.Loader)
90
50
 
91
51
  @property
92
52
  def supported_opts(self):
@@ -98,6 +58,17 @@ class TemplateLoader(DotMap):
98
58
  """Property to access tasks easily."""
99
59
  return self._extract_tasks()
100
60
 
61
+ def print(self):
62
+ """Print config as highlighted yaml."""
63
+ config = self.toDict()
64
+ _path = config.pop('_path', None)
65
+ if _path:
66
+ console.print(f'[italic green]{_path}[/]\n')
67
+ yaml_str = yaml.dump(config, indent=4, sort_keys=False)
68
+ from rich.syntax import Syntax
69
+ yaml_highlight = Syntax(yaml_str, 'yaml', line_numbers=True)
70
+ console.print(yaml_highlight)
71
+
101
72
  def _collect_supported_opts(self):
102
73
  """Collect supported options from the tasks extracted from the config."""
103
74
  tasks = self._extract_tasks()
@@ -141,7 +112,7 @@ class TemplateLoader(DotMap):
141
112
  # For each workflow in the scan, load it and incorporate it with a unique prefix
142
113
  for wf_name, _ in self.workflows.items():
143
114
  name = wf_name.split('/')[0]
144
- config = TemplateLoader(name=f'workflows/{name}')
115
+ config = TemplateLoader(name=f'workflow/{name}')
145
116
  wf_tasks = config.flat_tasks
146
117
  # Prefix tasks from this workflow with its name to prevent collision
147
118
  for task_key, task_val in wf_tasks.items():
@@ -153,3 +124,26 @@ class TemplateLoader(DotMap):
153
124
  parse_config(self.tasks)
154
125
 
155
126
  return dict(tasks)
127
+
128
+
129
+ def find_templates():
130
+ results = []
131
+ dirs = [CONFIGS_FOLDER]
132
+ if CONFIG.dirs.templates:
133
+ dirs.append(CONFIG.dirs.templates)
134
+ paths = []
135
+ for dir in dirs:
136
+ config_paths = [
137
+ Path(path)
138
+ for path in glob.glob(str(dir).rstrip('/') + '/**/*.y*ml', recursive=True)
139
+ ]
140
+ debug(f'Found {len(config_paths)} templates in {dir}', sub='template')
141
+ paths.extend(config_paths)
142
+ for path in paths:
143
+ config = TemplateLoader(input=path)
144
+ debug(f'Loaded template from {path}', sub='template')
145
+ results.append(config)
146
+ return results
147
+
148
+
149
+ TEMPLATES = find_templates()
secator/utils.py CHANGED
@@ -164,7 +164,8 @@ def discover_internal_tasks():
164
164
  # Sort task_classes by category
165
165
  task_classes = sorted(
166
166
  task_classes,
167
- key=lambda x: (get_command_category(x), x.__name__))
167
+ # key=lambda x: (get_command_category(x), x.__name__)
168
+ key=lambda x: x.__name__)
168
169
 
169
170
  return task_classes
170
171
 
@@ -262,9 +263,9 @@ def get_command_category(command):
262
263
  Returns:
263
264
  str: Command category.
264
265
  """
265
- base_cls = command.__bases__[0].__name__.replace('Command', '').replace('Runner', 'misc')
266
- category = re.sub(r'(?<!^)(?=[A-Z])', '/', base_cls).lower()
267
- return category
266
+ if not command.tags:
267
+ return 'misc'
268
+ return '/'.join(command.tags)
268
269
 
269
270
 
270
271
  def merge_opts(*options):
@@ -309,6 +310,8 @@ def pluralize(word):
309
310
  """
310
311
  if word.endswith('y'):
311
312
  return word.rstrip('y') + 'ies'
313
+ elif word.endswith('s'):
314
+ return word + 'es'
312
315
  return f'{word}s'
313
316
 
314
317
 
@@ -373,11 +376,15 @@ def rich_to_ansi(text):
373
376
  Returns:
374
377
  str: Converted text (ANSI).
375
378
  """
376
- from rich.console import Console
377
- tmp_console = Console(file=None, highlight=False)
378
- with tmp_console.capture() as capture:
379
- tmp_console.print(text, end='', soft_wrap=True)
380
- return capture.get()
379
+ try:
380
+ from rich.console import Console
381
+ tmp_console = Console(file=None, highlight=False)
382
+ with tmp_console.capture() as capture:
383
+ tmp_console.print(text, end='', soft_wrap=True)
384
+ return capture.get()
385
+ except Exception:
386
+ console.print(f'[bold red]Could not convert rich text to ansi: {text}[/]', highlight=False, markup=False)
387
+ return text
381
388
 
382
389
 
383
390
  def rich_escape(obj):
@@ -414,15 +421,16 @@ def format_object(obj, obj_breaklines=False):
414
421
 
415
422
  def debug(msg, sub='', id='', obj=None, lazy=None, obj_after=True, obj_breaklines=False, verbose=False):
416
423
  """Print debug log if DEBUG >= level."""
417
- if not DEBUG_COMPONENT or DEBUG_COMPONENT == [""]:
418
- return
419
-
420
- if sub:
421
- if verbose and sub not in DEBUG_COMPONENT:
422
- sub = f'debug.{sub}'
423
- if not any(sub.startswith(s) for s in DEBUG_COMPONENT):
424
+ if not DEBUG_COMPONENT == ['all']:
425
+ if not DEBUG_COMPONENT or DEBUG_COMPONENT == [""]:
424
426
  return
425
427
 
428
+ if sub:
429
+ if verbose and sub not in DEBUG_COMPONENT:
430
+ sub = f'debug.{sub}'
431
+ if not any(sub.startswith(s) for s in DEBUG_COMPONENT):
432
+ return
433
+
426
434
  if lazy:
427
435
  msg = lazy(msg)
428
436
 
@@ -459,6 +467,10 @@ def escape_mongodb_url(url):
459
467
  return url
460
468
 
461
469
 
470
+ def caml_to_snake(s):
471
+ return re.sub(r'(?<!^)(?=[A-Z])', '_', s).lower()
472
+
473
+
462
474
  def print_version():
463
475
  """Print secator version information."""
464
476
  from secator.installer import get_version_info
@@ -773,8 +785,9 @@ def process_wordlist(val):
773
785
  val = default_wordlist
774
786
  template_wordlist = getattr(CONFIG.wordlists.templates, val)
775
787
  if template_wordlist:
776
- return template_wordlist
777
- elif Path(val).exists():
788
+ val = template_wordlist
789
+
790
+ if Path(val).exists():
778
791
  return val
779
792
  else:
780
793
  return download_file(