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/runners/_base.py CHANGED
@@ -1,21 +1,28 @@
1
+ import gc
1
2
  import json
2
3
  import logging
3
4
  import sys
5
+ import textwrap
4
6
  import uuid
7
+
5
8
  from datetime import datetime
6
9
  from pathlib import Path
7
10
  from time import time
8
11
 
12
+ from dotmap import DotMap
9
13
  import humanize
10
14
 
11
- from secator.definitions import ADDONS_ENABLED
15
+ from secator.definitions import ADDONS_ENABLED, STATE_COLORS
12
16
  from secator.celery_utils import CeleryData
13
17
  from secator.config import CONFIG
14
- from secator.output_types import FINDING_TYPES, OutputType, Progress, Info, Warning, Error, Target, State
18
+ from secator.output_types import FINDING_TYPES, OUTPUT_TYPES, OutputType, Progress, Info, Warning, Error, Target, State
15
19
  from secator.report import Report
16
20
  from secator.rich import console, console_stdout
17
21
  from secator.runners._helpers import (get_task_folder_id, run_extractors)
18
- from secator.utils import (debug, import_dynamic, rich_to_ansi, should_update)
22
+ from secator.utils import (debug, import_dynamic, rich_to_ansi, should_update, autodetect_type)
23
+ from secator.tree import build_runner_tree
24
+ from secator.loader import get_configs_by_type
25
+
19
26
 
20
27
  logger = logging.getLogger(__name__)
21
28
 
@@ -36,6 +43,16 @@ VALIDATORS = [
36
43
  ]
37
44
 
38
45
 
46
+ def format_runner_name(runner):
47
+ """Format runner name."""
48
+ colors = {
49
+ 'task': 'bold gold3',
50
+ 'workflow': 'bold dark_orange3',
51
+ 'scan': 'bold red',
52
+ }
53
+ return f'[{colors[runner.config.type]}]{runner.unique_name}[/]'
54
+
55
+
39
56
  class Runner:
40
57
  """Runner class.
41
58
 
@@ -61,118 +78,171 @@ class Runner:
61
78
  # Default exporters
62
79
  default_exporters = []
63
80
 
81
+ # Profiles
82
+ profiles = []
83
+
64
84
  # Run hooks
65
85
  enable_hooks = True
66
86
 
67
87
  def __init__(self, config, inputs=[], results=[], run_opts={}, hooks={}, validators={}, context={}):
68
- self.uuids = []
69
- self.results = []
70
- self.output = ''
71
-
72
88
  # Runner config
73
- self.config = config
89
+ self.config = DotMap(config.toDict())
74
90
  self.name = run_opts.get('name', config.name)
75
- self.description = run_opts.get('description', config.description)
91
+ self.description = run_opts.get('description', config.description or '')
76
92
  self.workspace_name = context.get('workspace_name', 'default')
77
93
  self.run_opts = run_opts.copy()
78
94
  self.sync = run_opts.get('sync', True)
95
+ self.context = context
96
+
97
+ # Runner state
98
+ self.uuids = set()
99
+ self.results = []
100
+ self.results_count = 0
101
+ self.threads = []
102
+ self.output = ''
103
+ self.started = False
79
104
  self.done = False
80
105
  self.start_time = datetime.fromtimestamp(time())
106
+ self.end_time = None
81
107
  self.last_updated_db = None
82
108
  self.last_updated_celery = None
83
109
  self.last_updated_progress = None
84
- self.end_time = None
85
- self._hooks = hooks
86
110
  self.progress = 0
87
- self.context = context
88
- self.delay = run_opts.get('delay', False)
89
111
  self.celery_result = None
90
112
  self.celery_ids = []
91
113
  self.celery_ids_map = {}
92
- self.caller = self.run_opts.get('caller', None)
93
- self.threads = []
94
- self.no_poll = self.run_opts.get('no_poll', False)
95
- self.quiet = self.run_opts.get('quiet', False)
96
- self.started = False
97
- self.enable_reports = self.run_opts.get('enable_reports', not self.sync)
98
- self._reports_folder = self.run_opts.get('reports_folder', None)
114
+ self.revoked = False
115
+ self.results_buffer = []
116
+ self._hooks = hooks
99
117
 
100
118
  # Runner process options
101
- self.no_process = self.run_opts.get('no_process', False)
119
+ self.no_poll = self.run_opts.get('no_poll', False)
120
+ self.no_process = not self.run_opts.get('process', True)
102
121
  self.piped_input = self.run_opts.get('piped_input', False)
103
122
  self.piped_output = self.run_opts.get('piped_output', False)
104
- self.enable_duplicate_check = self.run_opts.get('enable_duplicate_check', True)
105
123
  self.dry_run = self.run_opts.get('dry_run', False)
124
+ self.has_parent = self.run_opts.get('has_parent', False)
125
+ self.has_children = self.run_opts.get('has_children', False)
126
+ self.caller = self.run_opts.get('caller', None)
127
+ self.quiet = self.run_opts.get('quiet', False)
128
+ self._reports_folder = self.run_opts.get('reports_folder', None)
129
+ self.raise_on_error = self.run_opts.get('raise_on_error', False)
130
+
131
+ # Runner toggles
132
+ self.enable_duplicate_check = self.run_opts.get('enable_duplicate_check', True)
133
+ self.enable_profiles = self.run_opts.get('enable_profiles', True)
134
+ self.enable_reports = self.run_opts.get('enable_reports', not self.sync) and not self.dry_run and not self.no_process and not self.no_poll # noqa: E501
135
+ self.enable_hooks = self.run_opts.get('enable_hooks', True) and not self.dry_run and not self.no_process and not self.no_poll # noqa: E501
106
136
 
107
137
  # Runner print opts
108
- self.print_item = self.run_opts.get('print_item', False)
138
+ self.print_item = self.run_opts.get('print_item', False) and not self.dry_run
109
139
  self.print_line = self.run_opts.get('print_line', False) and not self.quiet
110
140
  self.print_remote_info = self.run_opts.get('print_remote_info', False) and not self.piped_input and not self.piped_output # noqa: E501
141
+ self.print_start = self.run_opts.get('print_start', False) and not self.dry_run # noqa: E501
142
+ self.print_end = self.run_opts.get('print_end', False) and not self.dry_run # noqa: E501
143
+ self.print_target = self.run_opts.get('print_target', False) and not self.dry_run and not self.has_parent
111
144
  self.print_json = self.run_opts.get('print_json', False)
112
- self.print_raw = self.run_opts.get('print_raw', False) or self.piped_output
145
+ self.print_raw = self.run_opts.get('print_raw', False) or (self.piped_output and not self.print_json)
113
146
  self.print_fmt = self.run_opts.get('fmt', '')
114
- self.print_progress = self.run_opts.get('print_progress', False) and not self.quiet and not self.print_raw
115
- self.print_target = self.run_opts.get('print_target', False) and not self.quiet and not self.print_raw
116
- self.print_stat = self.run_opts.get('print_stat', False) and not self.quiet and not self.print_raw
117
- self.raise_on_error = self.run_opts.get('raise_on_error', False)
118
- self.print_opts = {k: v for k, v in self.__dict__.items() if k.startswith('print_') if v}
147
+ self.print_stat = self.run_opts.get('print_stat', False)
148
+ self.print_profiles = self.run_opts.get('print_profiles', False)
119
149
 
120
150
  # Chunks
121
- self.has_parent = self.run_opts.get('has_parent', False)
122
- self.has_children = self.run_opts.get('has_children', False)
123
151
  self.chunk = self.run_opts.get('chunk', None)
124
152
  self.chunk_count = self.run_opts.get('chunk_count', None)
125
153
  self.unique_name = self.name.replace('/', '_')
126
154
  self.unique_name = f'{self.unique_name}_{self.chunk}' if self.chunk else self.unique_name
127
155
 
156
+ # Opt aliases
157
+ self.opt_aliases = []
158
+ if self.config.node_id:
159
+ self.opt_aliases.append(self.config.node_id.replace('.', '_'))
160
+ if self.config.node_name:
161
+ self.opt_aliases.append(self.config.node_name)
162
+ self.opt_aliases.append(self.unique_name)
163
+
164
+ # Begin initialization
165
+ self.debug(f'begin initialization of {self.unique_name}', sub='init')
166
+
167
+ # Hooks
168
+ self.resolved_hooks = {name: [] for name in HOOKS + getattr(self, 'hooks', [])}
169
+ self.debug('registering hooks', obj=list(self.resolved_hooks.keys()), sub='init')
170
+ self.register_hooks(hooks)
171
+
172
+ # Validators
173
+ self.resolved_validators = {name: [] for name in VALIDATORS + getattr(self, 'validators', [])}
174
+ self.debug('registering validators', obj={'validators': list(self.resolved_validators.keys())}, sub='init')
175
+ self.resolved_validators['validate_input'].append(self._validate_inputs)
176
+ self.register_validators(validators)
177
+
128
178
  # Add prior results to runner results
129
- [self.add_result(result, print=False, output=False) for result in results]
179
+ self.debug(f'adding {len(results)} prior results to runner', sub='init')
180
+ for result in results:
181
+ self.add_result(result, print=False, output=False, hooks=False, queue=not self.has_parent)
130
182
 
131
183
  # Determine inputs
184
+ self.debug(f'resolving inputs with dynamic opts ({len(self.dynamic_opts)})', obj=self.dynamic_opts, sub='init')
132
185
  self.inputs = [inputs] if not isinstance(inputs, list) else inputs
186
+ self.inputs = list(set(self.inputs))
133
187
  targets = [Target(name=target) for target in self.inputs]
134
- self.filter_results(results + targets)
188
+ for target in targets:
189
+ self.add_result(target, print=False, output=False)
135
190
 
136
- # Debug
137
- self.debug('Inputs', obj=self.inputs, sub='init')
138
- self.debug('Run opts', obj={k: v for k, v in self.run_opts.items() if v is not None}, sub='init')
139
- self.debug('Print opts', obj={k: v for k, v in self.print_opts.items() if v is not None}, sub='init')
191
+ # Run extractors on results and targets
192
+ self._run_extractors(results + targets)
193
+ self.debug(f'inputs ({len(self.inputs)})', obj=self.inputs, sub='init')
194
+ self.debug(f'run opts ({len(self.resolved_opts)})', obj=self.resolved_opts, sub='init')
195
+ self.debug(f'print opts ({len(self.resolved_print_opts)})', obj=self.resolved_print_opts, sub='init')
140
196
 
141
197
  # Load profiles
142
- profiles_str = run_opts.get('profiles', [])
143
- self.load_profiles(profiles_str)
198
+ profiles_str = run_opts.get('profiles') or []
199
+ self.debug('resolving profiles', obj={'profiles': profiles_str}, sub='init')
200
+ self.profiles = self.resolve_profiles(profiles_str)
144
201
 
145
202
  # Determine exporters
146
203
  exporters_str = self.run_opts.get('output') or self.default_exporters
204
+ self.debug('resolving exporters', obj={'exporters': exporters_str}, sub='init')
147
205
  self.exporters = self.resolve_exporters(exporters_str)
148
206
 
149
207
  # Profiler
150
- self.enable_profiler = self.run_opts.get('enable_profiler', False) and ADDONS_ENABLED['trace']
151
- if self.enable_profiler:
208
+ self.enable_pyinstrument = self.run_opts.get('enable_pyinstrument', False) and ADDONS_ENABLED['trace']
209
+ if self.enable_pyinstrument:
210
+ self.debug('enabling profiler', sub='init')
152
211
  from pyinstrument import Profiler
153
212
  self.profiler = Profiler(async_mode=False, interval=0.0001)
154
213
  try:
155
214
  self.profiler.start()
156
215
  except RuntimeError:
157
- self.enable_profiler = False
216
+ self.enable_pyinstrument = False
158
217
  pass
159
218
 
160
- # Hooks
161
- self.hooks = {name: [] for name in HOOKS + getattr(self, 'hooks', [])}
162
- self.register_hooks(hooks)
163
-
164
- # Validators
165
- self.validators = {name: [] for name in VALIDATORS + getattr(self, 'validators', [])}
166
- self.register_validators(validators)
167
-
168
219
  # Input post-process
169
- self.run_hooks('before_init')
220
+ self.run_hooks('before_init', sub='init')
170
221
 
171
222
  # Check if input is valid
172
- self.inputs_valid = self.run_validators('validate_input', self.inputs)
223
+ self.inputs_valid = self.run_validators('validate_input', self.inputs, sub='init')
224
+
225
+ # Print targets
226
+ if self.print_target:
227
+ pluralize = 'targets' if len(self.self_targets) > 1 else 'target'
228
+ self._print(Info(message=f'Loaded {len(self.self_targets)} {pluralize} for {format_runner_name(self)}:'), rich=True)
229
+ for target in self.self_targets:
230
+ self._print(f' {repr(target)}', rich=True)
173
231
 
174
232
  # Run hooks
175
- self.run_hooks('on_init')
233
+ self.run_hooks('on_init', sub='init')
234
+
235
+ @property
236
+ def resolved_opts(self):
237
+ return {k: v for k, v in self.run_opts.items() if v is not None and not k.startswith('print_') and not k.endswith('_')} # noqa: E501
238
+
239
+ @property
240
+ def resolved_print_opts(self):
241
+ return {k: v for k, v in self.__dict__.items() if k.startswith('print_') if v}
242
+
243
+ @property
244
+ def dynamic_opts(self):
245
+ return {k: v for k, v in self.run_opts.items() if k.endswith('_')}
176
246
 
177
247
  @property
178
248
  def elapsed(self):
@@ -188,6 +258,10 @@ class Runner:
188
258
  def targets(self):
189
259
  return [r for r in self.results if isinstance(r, Target)]
190
260
 
261
+ @property
262
+ def self_targets(self):
263
+ return [r for r in self.results if isinstance(r, Target) and r._source.startswith(self.unique_name)]
264
+
191
265
  @property
192
266
  def infos(self):
193
267
  return [r for r in self.results if isinstance(r, Info)]
@@ -230,6 +304,8 @@ class Runner:
230
304
  def status(self):
231
305
  if not self.started:
232
306
  return 'PENDING'
307
+ if self.revoked:
308
+ return 'REVOKED'
233
309
  if not self.done:
234
310
  return 'RUNNING'
235
311
  return 'FAILURE' if len(self.self_errors) > 0 else 'SUCCESS'
@@ -247,7 +323,7 @@ class Runner:
247
323
  'chunk_info': f'{self.chunk}/{self.chunk_count}' if self.chunk and self.chunk_count else '',
248
324
  'celery_id': self.context['celery_id'],
249
325
  'count': self.self_findings_count,
250
- 'descr': self.config.description or '',
326
+ 'descr': self.description
251
327
  }
252
328
 
253
329
  @property
@@ -260,13 +336,31 @@ class Runner:
260
336
  path_inputs = path / '.inputs'
261
337
  path_outputs = path / '.outputs'
262
338
  if not path.exists():
263
- self.debug(f'Creating reports folder {path}')
339
+ self.debug(f'creating reports folder {path}', sub='start')
264
340
  path.mkdir(parents=True, exist_ok=True)
265
341
  path_inputs.mkdir(exist_ok=True)
266
342
  path_outputs.mkdir(exist_ok=True)
267
343
  self._reports_folder = path.resolve()
268
344
  return self._reports_folder
269
345
 
346
+ @property
347
+ def id(self):
348
+ """Get id from context.
349
+
350
+ Returns:
351
+ str: Id.
352
+ """
353
+ return self.context.get('task_id', '') or self.context.get('workflow_id', '') or self.context.get('scan_id', '')
354
+
355
+ @property
356
+ def ancestor_id(self):
357
+ """Get ancestor id from context.
358
+
359
+ Returns:
360
+ str: Ancestor id.
361
+ """
362
+ return self.context.get('ancestor_id')
363
+
270
364
  def run(self):
271
365
  """Run method.
272
366
 
@@ -286,75 +380,143 @@ class Runner:
286
380
  if self.sync:
287
381
  self.mark_started()
288
382
 
383
+ # Yield results buffer
384
+ yield from self.results_buffer
385
+ self.results_buffer = []
386
+
289
387
  # If any errors happened during validation, exit
290
- if self.errors:
291
- yield from self.errors
292
- self.mark_completed()
388
+ if self.self_errors:
389
+ self._finalize()
293
390
  return
294
391
 
295
392
  # Loop and process items
296
393
  for item in self.yielder():
297
394
  yield from self._process_item(item)
298
- self.run_hooks('on_interval')
299
-
300
- # Wait for threads to finish
301
- yield from self.join_threads()
395
+ self.run_hooks('on_interval', sub='item')
302
396
 
303
397
  except BaseException as e:
304
- self.debug(f'encountered exception {type(e).__name__}. Stopping remote tasks.', sub='error')
398
+ self.debug(f'encountered exception {type(e).__name__}. Stopping remote tasks.', sub='run')
305
399
  error = Error.from_exception(e)
306
- error._source = self.unique_name
307
- error._uuid = str(uuid.uuid4())
308
- self.add_result(error, print=True)
309
- self.stop_celery_tasks()
310
- yield from self.join_threads()
311
- yield error
312
- self.mark_completed()
400
+ self.add_result(error)
401
+ self.revoked = True
402
+ if not self.sync: # yield latest results from Celery
403
+ self.stop_celery_tasks()
404
+ for item in self.yielder():
405
+ yield from self._process_item(item)
406
+ self.run_hooks('on_interval', sub='item')
313
407
 
314
408
  finally:
315
- if self.dry_run:
316
- return
317
- if self.sync:
318
- self.mark_completed()
319
- if self.enable_reports:
320
- self.export_reports()
409
+ yield from self.results_buffer
410
+ self.results_buffer = []
411
+ self._finalize()
412
+
413
+ def _finalize(self):
414
+ """Finalize the runner."""
415
+ self.join_threads()
416
+ gc.collect()
417
+ if self.sync:
418
+ self.mark_completed()
419
+ if self.enable_reports:
420
+ self.export_reports()
321
421
 
322
422
  def join_threads(self):
323
423
  """Wait for all running threads to complete."""
324
424
  if not self.threads:
325
425
  return
326
- self.debug(f'waiting for {len(self.threads)} threads to complete')
426
+ self.debug(f'waiting for {len(self.threads)} threads to complete', sub='end')
327
427
  for thread in self.threads:
328
428
  error = thread.join()
329
429
  if error:
330
- error._source = self.unique_name
331
- error._uuid = str(uuid.uuid4())
332
- self.add_result(error, print=True)
333
- yield error
334
-
335
- def filter_results(self, results):
336
- """Filter results based on the runner's config."""
337
- if not self.chunk:
338
- inputs, run_opts, errors = run_extractors(results, self.run_opts, self.inputs, self.dry_run)
339
- for error in errors:
340
- self.add_result(error, print=True)
341
- self.inputs = list(set(inputs))
342
- self.run_opts = run_opts
343
-
344
- def add_result(self, item, print=False, output=True):
430
+ self.add_result(error)
431
+
432
+ def _run_extractors(self, results):
433
+ """Run extractors on results and targets."""
434
+ self.debug('running extractors', sub='init')
435
+ ctx = {'opts': DotMap(self.run_opts), 'targets': self.inputs, 'ancestor_id': self.ancestor_id}
436
+ inputs, run_opts, errors = run_extractors(
437
+ results,
438
+ self.run_opts,
439
+ self.inputs,
440
+ ctx=ctx,
441
+ dry_run=self.dry_run)
442
+ for error in errors:
443
+ self.add_result(error)
444
+ self.inputs = sorted(list(set(inputs)))
445
+ self.debug(f'extracted {len(self.inputs)} inputs', sub='init')
446
+ self.run_opts = run_opts
447
+
448
+ def add_result(self, item, print=True, output=True, hooks=True, queue=True):
345
449
  """Add item to runner results.
346
450
 
347
451
  Args:
348
452
  item (OutputType): Item.
349
453
  print (bool): Whether to print it or not.
350
454
  output (bool): Whether to add it to the output or not.
455
+ hooks (bool): Whether to run hooks on the item.
456
+ queue (bool): Whether to queue the item for later processing.
351
457
  """
352
- self.uuids.append(item._uuid)
458
+ if item._uuid and item._uuid in self.uuids:
459
+ return
460
+
461
+ # Keep existing ancestor id in context
462
+ ancestor_id = item._context.get('ancestor_id', None)
463
+
464
+ # Set context
465
+ item._context.update(self.context)
466
+ item._context['ancestor_id'] = ancestor_id or self.ancestor_id
467
+
468
+ # Set uuid
469
+ if not item._uuid:
470
+ item._uuid = str(uuid.uuid4())
471
+
472
+ # Set source
473
+ if not item._source:
474
+ item._source = self.unique_name
475
+
476
+ # Check for state updates
477
+ if isinstance(item, State) and self.celery_result and item.task_id == self.celery_result.id:
478
+ self.debug(f'update runner state from remote state: {item.state}', sub='item')
479
+ if item.state in ['FAILURE', 'SUCCESS', 'REVOKED']:
480
+ self.started = True
481
+ self.done = True
482
+ self.progress = 100
483
+ self.end_time = datetime.fromtimestamp(time())
484
+ elif item.state in ['RUNNING']:
485
+ self.started = True
486
+ self.start_time = datetime.fromtimestamp(time())
487
+ self.end_time = None
488
+ self.last_updated_celery = item._timestamp
489
+ return
490
+
491
+ # If progress item, update runner progress
492
+ elif isinstance(item, Progress) and item._source == self.unique_name:
493
+ self.debug(f'update runner progress: {item.percent}', sub='item', verbose=True)
494
+ if not should_update(CONFIG.runners.progress_update_frequency, self.last_updated_progress, item._timestamp):
495
+ return
496
+ self.progress = item.percent
497
+ self.last_updated_progress = item._timestamp
498
+
499
+ # If info item and task_id is defined, update runner celery_ids
500
+ elif isinstance(item, Info) and item.task_id and item.task_id not in self.celery_ids:
501
+ self.debug(f'update runner celery_ids from remote: {item.task_id}', sub='item')
502
+ self.celery_ids.append(item.task_id)
503
+
504
+ # If output type, run on_item hooks
505
+ elif isinstance(item, tuple(OUTPUT_TYPES)) and hooks:
506
+ item = self.run_hooks('on_item', item, sub='item')
507
+ if not item:
508
+ return
509
+
510
+ # Add item to results
511
+ self.uuids.add(item._uuid)
353
512
  self.results.append(item)
513
+ self.results_count += 1
354
514
  if output:
355
515
  self.output += repr(item) + '\n'
356
516
  if print:
357
517
  self._print_item(item)
518
+ if queue:
519
+ self.results_buffer.append(item)
358
520
 
359
521
  def add_subtask(self, task_id, task_name, task_description):
360
522
  """Add a Celery subtask to the current runner for tracking purposes.
@@ -386,9 +548,9 @@ class Runner:
386
548
 
387
549
  # Item is an output type
388
550
  if isinstance(item, OutputType):
389
- self.debug(item, lazy=lambda x: repr(x), sub='item', allow_no_process=False, verbose=True)
390
551
  _type = item._type
391
552
  print_this_type = getattr(self, f'print_{_type}', True)
553
+ self.debug(item, lazy=lambda x: repr(x), sub='item', allow_no_process=False, verbose=print_this_type)
392
554
  if not print_this_type:
393
555
  return
394
556
 
@@ -421,13 +583,14 @@ class Runner:
421
583
  # Repr output
422
584
  if item_out:
423
585
  item_repr = repr(item)
424
- if isinstance(item, OutputType) and self.print_remote_info:
586
+ if self.print_remote_info and item._source:
425
587
  item_repr += rich_to_ansi(rf' \[[dim]{item._source}[/]]')
588
+ # item_repr += f' ({self.__class__.__name__}) ({item._uuid}) ({item._context.get("ancestor_id")})' # for debugging
426
589
  self._print(item_repr, out=item_out)
427
590
 
428
591
  # Item is a line
429
592
  elif isinstance(item, str):
430
- self.debug(item, sub='line', allow_no_process=False, verbose=True)
593
+ self.debug(item, sub='line.print', allow_no_process=False, verbose=True)
431
594
  if self.print_line or force:
432
595
  self._print(item, out=sys.stderr, end='\n', rich=False)
433
596
 
@@ -446,13 +609,15 @@ class Runner:
446
609
  if sub:
447
610
  new_sub += f'.{sub}'
448
611
  kwargs['sub'] = new_sub
612
+ if self.id and not self.sync:
613
+ kwargs['id'] = self.id
449
614
  debug(*args, **kwargs)
450
615
 
451
616
  def mark_duplicates(self):
452
617
  """Check for duplicates and mark items as duplicates."""
453
618
  if not self.enable_duplicate_check:
454
619
  return
455
- self.debug('running duplicate check', id=self.config.name, sub='duplicates')
620
+ self.debug('running duplicate check', sub='end')
456
621
  # dupe_count = 0
457
622
  import concurrent.futures
458
623
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=100)
@@ -463,7 +628,7 @@ class Runner:
463
628
  # if duplicates:
464
629
  # duplicates_str = '\n\t'.join(duplicates)
465
630
  # self.debug(f'Duplicates ({dupe_count}):\n\t{duplicates_str}', sub='duplicates', verbose=True)
466
- # self.debug(f'duplicate check completed: {dupe_count} found', id=self.config.name, sub='duplicates')
631
+ # self.debug(f'duplicate check completed: {dupe_count} found', sub='duplicates')
467
632
 
468
633
  def check_duplicate(self, item):
469
634
  """Check if an item is a duplicate in the list of results and mark it like so.
@@ -471,7 +636,7 @@ class Runner:
471
636
  Args:
472
637
  item (OutputType): Secator output type.
473
638
  """
474
- self.debug('running duplicate check for item', obj=item.toDict(), obj_breaklines=True, sub='duplicates', verbose=True)
639
+ self.debug('running duplicate check for item', obj=item.toDict(), obj_breaklines=True, sub='item.duplicate', verbose=True) # noqa: E501
475
640
  others = [f for f in self.results if f == item and f._uuid != item._uuid]
476
641
  if others:
477
642
  main = max(item, *others)
@@ -480,22 +645,21 @@ class Runner:
480
645
  main._related.extend([dupe._uuid for dupe in dupes])
481
646
  main._related = list(dict.fromkeys(main._related))
482
647
  if main._uuid != item._uuid:
483
- self.debug(f'found {len(others)} duplicates for', obj=item.toDict(), obj_breaklines=True, sub='duplicates', verbose=True) # noqa: E501
648
+ self.debug(f'found {len(others)} duplicates for', obj=item.toDict(), obj_breaklines=True, sub='item.duplicate', verbose=True) # noqa: E501
484
649
  item._duplicate = True
485
- item = self.run_hooks('on_item', item)
650
+ item = self.run_hooks('on_item', item, sub='item.duplicate')
486
651
  if item._uuid not in main._related:
487
652
  main._related.append(item._uuid)
488
- main = self.run_hooks('on_duplicate', main)
489
- item = self.run_hooks('on_duplicate', item)
653
+ main = self.run_hooks('on_duplicate', main, sub='item.duplicate')
654
+ item = self.run_hooks('on_duplicate', item, sub='item.duplicate')
490
655
 
491
656
  for dupe in dupes:
492
657
  if not dupe._duplicate:
493
658
  self.debug(
494
659
  'found new duplicate', obj=dupe.toDict(), obj_breaklines=True,
495
- sub='duplicates', verbose=True)
496
- # dupe_count += 1
660
+ sub='item.duplicate', verbose=True)
497
661
  dupe._duplicate = True
498
- dupe = self.run_hooks('on_duplicate', dupe)
662
+ dupe = self.run_hooks('on_duplicate', dupe, sub='item.duplicate')
499
663
 
500
664
  def yielder(self):
501
665
  """Base yielder implementation.
@@ -506,15 +670,30 @@ class Runner:
506
670
  Yields:
507
671
  secator.output_types.OutputType: Secator output type.
508
672
  """
673
+ # If existing celery result, yield from it
674
+ if self.celery_result:
675
+ yield from CeleryData.iter_results(
676
+ self.celery_result,
677
+ ids_map=self.celery_ids_map,
678
+ description=True,
679
+ revoked=self.revoked,
680
+ print_remote_info=self.print_remote_info,
681
+ print_remote_title=f'[bold gold3]{self.__class__.__name__.capitalize()}[/] [bold magenta]{self.name}[/] results'
682
+ )
683
+ return
684
+
509
685
  # Build Celery workflow
686
+ self.debug('building celery workflow', sub='start')
510
687
  workflow = self.build_celery_workflow()
688
+ self.print_target = False
511
689
 
512
690
  # Run workflow and get results
513
691
  if self.sync:
514
692
  self.print_item = False
693
+ self.debug('running workflow in sync mode', sub='start')
515
694
  results = workflow.apply().get()
516
- yield from results
517
695
  else:
696
+ self.debug('running workflow in async mode', sub='start')
518
697
  self.celery_result = workflow()
519
698
  self.celery_ids.append(str(self.celery_result.id))
520
699
  yield Info(
@@ -522,7 +701,6 @@ class Runner:
522
701
  task_id=self.celery_result.id
523
702
  )
524
703
  if self.no_poll:
525
- self.enable_hooks = False
526
704
  self.enable_reports = False
527
705
  self.no_process = True
528
706
  return
@@ -557,7 +735,7 @@ class Runner:
557
735
  'end_time': self.end_time,
558
736
  'elapsed': self.elapsed.total_seconds(),
559
737
  'elapsed_human': self.elapsed_human,
560
- 'run_opts': {k: v for k, v in self.run_opts.items() if k not in self.print_opts},
738
+ 'run_opts': self.resolved_opts,
561
739
  }
562
740
  data.update({
563
741
  'config': self.config.toDict(),
@@ -576,74 +754,76 @@ class Runner:
576
754
  })
577
755
  return data
578
756
 
579
- def run_hooks(self, hook_type, *args):
757
+ def run_hooks(self, hook_type, *args, sub='hooks'):
580
758
  """"Run hooks of a certain type.
581
759
 
582
760
  Args:
583
761
  hook_type (str): Hook type.
584
762
  args (list): List of arguments to pass to the hook.
763
+ sub (str): Debug id.
585
764
 
586
765
  Returns:
587
766
  any: Hook return value.
588
767
  """
589
768
  result = args[0] if len(args) > 0 else None
590
- _id = self.context.get('task_id', '') or self.context.get('workflow_id', '') or self.context.get('scan_id', '')
591
- for hook in self.hooks[hook_type]:
592
- name = f'{self.__class__.__name__}.{hook_type}'
769
+ if self.no_process:
770
+ self.debug('hook skipped (no_process)', obj={'name': hook_type}, sub=sub, verbose=True) # noqa: E501
771
+ return result
772
+ if self.dry_run:
773
+ self.debug('hook skipped (dry_run)', obj={'name': hook_type}, sub=sub, verbose=True) # noqa: E501
774
+ return result
775
+ for hook in self.resolved_hooks[hook_type]:
593
776
  fun = self.get_func_path(hook)
594
777
  try:
595
778
  if hook_type == 'on_interval' and not should_update(CONFIG.runners.backend_update_frequency, self.last_updated_db):
596
- self.debug('', obj={f'{name} [dim yellow]->[/] {fun}': '[dim gray11]skipped[/]'}, id=_id, sub='hooks', verbose=True) # noqa: E501
779
+ self.debug('hook skipped (backend update frequency)', obj={'name': hook_type, 'fun': fun}, sub=sub, verbose=True) # noqa: E501
597
780
  return
598
781
  if not self.enable_hooks or self.no_process:
599
- self.debug('', obj={f'{name} [dim yellow]->[/] {fun}': '[dim gray11]skipped[/]'}, id=_id, sub='hooks', verbose=True) # noqa: E501
782
+ self.debug('hook skipped (disabled hooks or no_process)', obj={'name': hook_type, 'fun': fun}, sub=sub, verbose=True) # noqa: E501
600
783
  continue
601
- # self.debug('', obj={f'{name} [dim yellow]->[/] {fun}': '[dim yellow]started[/]'}, id=_id, sub='hooks', verbose=True) # noqa: E501
602
784
  result = hook(self, *args)
603
- self.debug('', obj={f'{name} [dim yellow]->[/] {fun}': '[dim green]success[/]'}, id=_id, sub='hooks', verbose=True) # noqa: E501
785
+ self.debug('hook success', obj={'name': hook_type, 'fun': fun}, sub=sub, verbose='item' in sub) # noqa: E501
786
+ if isinstance(result, Error):
787
+ self.add_result(result, hooks=False)
604
788
  except Exception as e:
605
- self.debug('', obj={f'{name} [dim yellow]->[/] {fun}': '[dim red]failed[/]'}, id=_id, sub='hooks', verbose=True) # noqa: E501
606
- error = Error.from_exception(e)
607
- error.message = f'Hook "{fun}" execution failed.'
608
- error._source = self.unique_name
609
- error._uuid = str(uuid.uuid4())
610
- self.add_result(error, print=True)
789
+ self.debug('hook failed', obj={'name': hook_type, 'fun': fun}, sub=sub) # noqa: E501
790
+ error = Error.from_exception(e, message=f'Hook "{fun}" execution failed')
611
791
  if self.raise_on_error:
612
792
  raise e
793
+ self.add_result(error, hooks=False)
613
794
  return result
614
795
 
615
- def run_validators(self, validator_type, *args, error=True):
796
+ def run_validators(self, validator_type, *args, error=True, sub='validators'):
616
797
  """Run validators of a certain type.
617
798
 
618
799
  Args:
619
800
  validator_type (str): Validator type. E.g: on_start.
620
801
  args (list): List of arguments to pass to the validator.
621
802
  error (bool): Whether to add an error to runner results if the validator failed.
803
+ sub (str): Debug id.
622
804
 
623
805
  Returns:
624
806
  bool: Validator return value.
625
807
  """
626
808
  if self.no_process:
809
+ self.debug('validator skipped (no_process)', obj={'name': validator_type}, sub=sub, verbose=True) # noqa: E501
810
+ return True
811
+ if self.dry_run:
812
+ self.debug('validator skipped (dry_run)', obj={'name': validator_type}, sub=sub, verbose=True) # noqa: E501
627
813
  return True
628
- _id = self.context.get('task_id', '') or self.context.get('workflow_id', '') or self.context.get('scan_id', '')
629
- for validator in self.validators[validator_type]:
630
- name = f'{self.__class__.__name__}.{validator_type}'
814
+ for validator in self.resolved_validators[validator_type]:
631
815
  fun = self.get_func_path(validator)
632
816
  if not validator(self, *args):
633
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: '[dim red]failed[/]'}, id=_id, verbose=True, sub='validators') # noqa: E501
817
+ self.debug('validator failed', obj={'name': validator_type, 'fun': fun}, sub=sub) # noqa: E501
634
818
  doc = validator.__doc__
635
819
  if error:
636
820
  message = 'Validator failed'
637
821
  if doc:
638
822
  message += f': {doc}'
639
- error = Error(
640
- message=message,
641
- _source=self.unique_name,
642
- _uuid=str(uuid.uuid4())
643
- )
644
- self.add_result(error, print=True)
823
+ err = Error(message=message)
824
+ self.add_result(err)
645
825
  return False
646
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: '[dim green]success[/]'}, id=_id, verbose=True, sub='validators') # noqa: E501
826
+ self.debug('validator success', obj={'name': validator_type, 'fun': fun}, sub=sub) # noqa: E501
647
827
  return True
648
828
 
649
829
  def register_hooks(self, hooks):
@@ -652,23 +832,21 @@ class Runner:
652
832
  Args:
653
833
  hooks (dict[str, List[Callable]]): List of hooks to register.
654
834
  """
655
- for key in self.hooks:
835
+ for key in self.resolved_hooks:
656
836
  # Register class + derived class hooks
657
837
  class_hook = getattr(self, key, None)
658
838
  if class_hook:
659
- name = f'{self.__class__.__name__}.{key}'
660
839
  fun = self.get_func_path(class_hook)
661
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: 'registered'}, sub='hooks')
662
- self.hooks[key].append(class_hook)
840
+ self.debug('hook registered', obj={'name': key, 'fun': fun}, sub='init')
841
+ self.resolved_hooks[key].append(class_hook)
663
842
 
664
843
  # Register user hooks
665
844
  user_hooks = hooks.get(self.__class__, {}).get(key, [])
666
845
  user_hooks.extend(hooks.get(key, []))
667
846
  for hook in user_hooks:
668
- name = f'{self.__class__.__name__}.{key}'
669
847
  fun = self.get_func_path(hook)
670
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: 'registered (user)'}, sub='hooks')
671
- self.hooks[key].extend(user_hooks)
848
+ self.debug('hook registered', obj={'name': key, 'fun': fun}, sub='init')
849
+ self.resolved_hooks[key].extend(user_hooks)
672
850
 
673
851
  def register_validators(self, validators):
674
852
  """Register validators.
@@ -677,21 +855,19 @@ class Runner:
677
855
  validators (dict[str, List[Callable]]): Validators to register.
678
856
  """
679
857
  # Register class + derived class hooks
680
- for key in self.validators:
858
+ for key in self.resolved_validators:
681
859
  class_validator = getattr(self, key, None)
682
860
  if class_validator:
683
- name = f'{self.__class__.__name__}.{key}'
684
861
  fun = self.get_func_path(class_validator)
685
- self.validators[key].append(class_validator)
686
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: 'registered'}, sub='validators')
862
+ self.resolved_validators[key].append(class_validator)
863
+ self.debug('validator registered', obj={'name': key, 'fun': fun}, sub='init')
687
864
 
688
865
  # Register user hooks
689
866
  user_validators = validators.get(key, [])
690
867
  for validator in user_validators:
691
- name = f'{self.__class__.__name__}.{key}'
692
868
  fun = self.get_func_path(validator)
693
- self.debug('', obj={name + ' [dim yellow]->[/] ' + fun: 'registered (user)'}, sub='validators')
694
- self.validators[key].extend(user_validators)
869
+ self.debug('validator registered', obj={'name': key, 'fun': fun}, sub='init')
870
+ self.resolved_validators[key].extend(user_validators)
695
871
 
696
872
  def mark_started(self):
697
873
  """Mark runner as started."""
@@ -699,9 +875,9 @@ class Runner:
699
875
  return
700
876
  self.started = True
701
877
  self.start_time = datetime.fromtimestamp(time())
702
- self.debug(f'started (sync: {self.sync}, hooks: {self.enable_hooks})')
878
+ self.debug(f'started (sync: {self.sync}, hooks: {self.enable_hooks}), chunk: {self.chunk}, chunk_count: {self.chunk_count}', sub='start') # noqa: E501
703
879
  self.log_start()
704
- self.run_hooks('on_start')
880
+ self.run_hooks('on_start', sub='start')
705
881
 
706
882
  def mark_completed(self):
707
883
  """Mark runner as completed."""
@@ -711,28 +887,50 @@ class Runner:
711
887
  self.done = True
712
888
  self.progress = 100
713
889
  self.end_time = datetime.fromtimestamp(time())
714
- self.debug(f'completed (sync: {self.sync}, reports: {self.enable_reports}, hooks: {self.enable_hooks})')
890
+ self.debug(f'completed (status: {self.status}, sync: {self.sync}, reports: {self.enable_reports}, hooks: {self.enable_hooks})', sub='end') # noqa: E501
715
891
  self.mark_duplicates()
716
- self.run_hooks('on_end')
892
+ self.run_hooks('on_end', sub='end')
717
893
  self.export_profiler()
718
894
  self.log_results()
719
895
 
720
896
  def log_start(self):
721
897
  """Log runner start."""
722
- if not self.print_remote_info:
898
+ if not self.print_start:
723
899
  return
724
- remote_str = 'starting' if self.sync else 'sent to Celery worker'
725
- info = Info(message=f'{self.config.type.capitalize()} {self.unique_name} {remote_str}...', _source=self.unique_name)
726
- self._print_item(info)
900
+ if self.has_parent:
901
+ return
902
+ if self.config.type != 'task':
903
+ tree = textwrap.indent(build_runner_tree(self.config).render_tree(), ' ')
904
+ info = Info(message=f'{self.config.type.capitalize()} built:\n{tree}', _source=self.unique_name)
905
+ self._print(info, rich=True)
906
+ remote_str = 'started' if self.sync else 'started in worker'
907
+ msg = f'{self.config.type.capitalize()} {format_runner_name(self)}'
908
+ if self.description:
909
+ msg += f' ([dim]{self.description}[/])'
910
+ info = Info(message=f'{msg} {remote_str}', _source=self.unique_name)
911
+ self._print(info, rich=True)
727
912
 
728
913
  def log_results(self):
729
914
  """Log runner results."""
730
- info = Info(message=f'{self.config.type.capitalize()} {self.unique_name} finished with status {self.status} and found {len(self.self_findings)} findings', _source=self.unique_name) # noqa: E501
731
- self._print_item(info)
915
+ if not self.print_end:
916
+ return
917
+ if self.has_parent:
918
+ return
919
+ info = Info(
920
+ message=(
921
+ f'{self.config.type.capitalize()} {format_runner_name(self)} finished with status '
922
+ f'[bold {STATE_COLORS[self.status]}]{self.status}[/] and found '
923
+ f'[bold]{len(self.findings)}[/] findings'
924
+ )
925
+ )
926
+ self._print(info, rich=True)
732
927
 
733
928
  def export_reports(self):
734
929
  """Export reports."""
735
- if self.enable_reports and self.exporters and not self.no_process:
930
+ if self.enable_reports and self.exporters and not self.no_process and not self.dry_run:
931
+ if self.print_end:
932
+ exporters_str = ', '.join([f'[bold cyan]{e.__name__.replace("Exporter", "").lower()}[/]' for e in self.exporters])
933
+ self._print(Info(message=f'Exporting results with exporters: {exporters_str}'), rich=True)
736
934
  report = Report(self, exporters=self.exporters)
737
935
  report.build()
738
936
  report.send()
@@ -740,12 +938,13 @@ class Runner:
740
938
 
741
939
  def export_profiler(self):
742
940
  """Export profiler."""
743
- if self.enable_profiler:
941
+ if self.enable_pyinstrument:
942
+ self.debug('stopping profiler', sub='end')
744
943
  self.profiler.stop()
745
944
  profile_path = Path(self.reports_folder) / f'{self.unique_name}_profile.html'
746
945
  with profile_path.open('w', encoding='utf-8') as f_html:
747
946
  f_html.write(self.profiler.output_html())
748
- self._print_item(Info(message=f'Wrote profile to {str(profile_path)}', _source=self.unique_name), force=True)
947
+ self._print_item(Info(message=f'Wrote profile to {str(profile_path)}'), force=True)
749
948
 
750
949
  def stop_celery_tasks(self):
751
950
  """Stop all tasks running in Celery worker."""
@@ -770,14 +969,14 @@ class Runner:
770
969
  # Init the new item and the list of output types to load from
771
970
  new_item = None
772
971
  output_types = getattr(self, 'output_types', [])
773
- self.debug(f'Input item: {item}', sub='klass.load', verbose=True)
972
+ self.debug(f'input item: {item}', sub='item.convert', verbose=True)
774
973
 
775
974
  # Use a function to pick proper output types
776
975
  output_discriminator = getattr(self, 'output_discriminator', None)
777
976
  if output_discriminator:
778
977
  result = output_discriminator(item)
779
978
  if result:
780
- self.debug(f'Discriminated output type: {result.__name__}', sub='klass.load', verbose=True)
979
+ self.debug('discriminated output type with output_discriminator', sub='item.convert', verbose=True)
781
980
  output_types = [result]
782
981
  else:
783
982
  output_types = []
@@ -787,21 +986,21 @@ class Runner:
787
986
  otypes = [o for o in output_types if o.get_name() == item['_type']]
788
987
  if otypes:
789
988
  output_types = [otypes[0]]
790
- self.debug(f'_type key is present in item and matches {otypes[0]}', sub='klass.load', verbose=True)
989
+ self.debug('discriminated output type with _type key', sub='item.convert', verbose=True)
791
990
 
792
991
  # Load item using picked output types
793
- self.debug(f'Output types to try: {[o.__name__ for o in output_types]}', sub='klass.load', verbose=True)
992
+ self.debug(f'output types to try: {[str(o) for o in output_types]}', sub='item.convert', verbose=True)
794
993
  for klass in output_types:
795
- self.debug(f'Loading item as {klass.__name__}', sub='klass.load', verbose=True)
994
+ self.debug(f'loading item as {str(klass)}', sub='item.convert', verbose=True)
796
995
  output_map = getattr(self, 'output_map', {}).get(klass, {})
797
996
  try:
798
997
  new_item = klass.load(item, output_map)
799
- self.debug(f'[dim green]Successfully loaded item as {klass.__name__}[/]', sub='klass.load', verbose=True)
998
+ self.debug(f'successfully loaded item as {str(klass)}', sub='item.convert', verbose=True)
800
999
  break
801
1000
  except (TypeError, KeyError) as e:
802
1001
  self.debug(
803
- f'[dim red]Failed loading item as {klass.__name__}: {type(e).__name__}: {str(e)}.[/] [dim green]Continuing.[/]',
804
- sub='klass.load', verbose=True)
1002
+ f'failed loading item as {str(klass)}: {type(e).__name__}: {str(e)}.',
1003
+ sub='item.convert', verbose=True)
805
1004
  # error = Error.from_exception(e)
806
1005
  # self.debug(repr(error), sub='debug.klass.load')
807
1006
  continue
@@ -809,7 +1008,7 @@ class Runner:
809
1008
  if not new_item:
810
1009
  new_item = Warning(message=f'Failed to load item as output type:\n {item}')
811
1010
 
812
- self.debug(f'Output item: {new_item.toDict()}', sub='klass.load', verbose=True)
1011
+ self.debug(f'output item: {new_item.toDict()}', sub='item.convert', verbose=True)
813
1012
 
814
1013
  return new_item
815
1014
 
@@ -870,72 +1069,44 @@ class Runner:
870
1069
  return
871
1070
 
872
1071
  # Run item validators
873
- if not self.run_validators('validate_item', item, error=False):
1072
+ if not self.run_validators('validate_item', item, error=False, sub='item'):
874
1073
  return
875
1074
 
876
1075
  # Convert output dict to another schema
877
1076
  if isinstance(item, dict):
878
- item = self.run_hooks('on_item_pre_convert', item)
1077
+ item = self.run_hooks('on_item_pre_convert', item, sub='item')
879
1078
  if not item:
880
1079
  return
881
1080
  item = self._convert_item_schema(item)
882
1081
 
883
- # Update item context
884
- item._context.update(self.context)
885
-
886
- # Add uuid to item
887
- if not item._uuid:
888
- item._uuid = str(uuid.uuid4())
889
-
890
- # Return if already seen
891
- if item._uuid in self.uuids:
892
- return
893
-
894
- # Add source to item
895
- if not item._source:
896
- item._source = self.unique_name
897
-
898
- # Check for state updates
899
- if isinstance(item, State) and self.celery_result and item.task_id == self.celery_result.id:
900
- self.debug(f'Sync runner state from remote: {item.state}')
901
- if item.state in ['FAILURE', 'SUCCESS', 'REVOKED']:
902
- self.started = True
903
- self.done = True
904
- self.progress = 100
905
- self.end_time = datetime.fromtimestamp(time())
906
- elif item.state in ['RUNNING']:
907
- self.started = True
908
- self.start_time = datetime.fromtimestamp(time())
909
- self.end_time = None
910
- self.last_updated_celery = item._timestamp
911
- return
912
-
913
- # If progress item, update runner progress
914
- elif isinstance(item, Progress) and item._source == self.unique_name:
915
- self.progress = item.percent
916
- if not should_update(CONFIG.runners.progress_update_frequency, self.last_updated_progress, item._timestamp):
917
- return
918
- elif int(item.percent) in [0, 100]:
919
- return
920
- else:
921
- self.last_updated_progress = item._timestamp
922
-
923
- # If info item and task_id is defined, update runner celery_ids
924
- elif isinstance(item, Info) and item.task_id and item.task_id not in self.celery_ids:
925
- self.celery_ids.append(item.task_id)
926
-
927
- # If finding, run on_item hooks
928
- elif isinstance(item, tuple(FINDING_TYPES)):
929
- item = self.run_hooks('on_item', item)
930
- if not item:
931
- return
932
-
933
1082
  # Add item to results
934
- self.add_result(item, print=print)
1083
+ self.add_result(item, print=print, queue=False)
935
1084
 
936
1085
  # Yield item
937
1086
  yield item
938
1087
 
1088
+ @staticmethod
1089
+ def _validate_inputs(self, inputs):
1090
+ """Input type is not supported by runner"""
1091
+ supported_types = ', '.join(self.config.input_types) if self.config.input_types else 'any'
1092
+ for _input in inputs:
1093
+ input_type = autodetect_type(_input)
1094
+ if self.config.input_types and input_type not in self.config.input_types:
1095
+ message = (
1096
+ f'Validator failed: target [bold blue]{_input}[/] of type [bold green]{input_type}[/] '
1097
+ f'is not supported by [bold gold3]{self.unique_name}[/]. Supported types: [bold green]{supported_types}[/]'
1098
+ )
1099
+ if self.has_parent:
1100
+ message += '. Removing from current inputs (runner context)'
1101
+ info = Info(message=message)
1102
+ self.inputs.remove(_input)
1103
+ self.add_result(info)
1104
+ else:
1105
+ error = Error(message=message)
1106
+ self.add_result(error)
1107
+ return False
1108
+ return True
1109
+
939
1110
  @staticmethod
940
1111
  def resolve_exporters(exporters):
941
1112
  """Resolve exporters from output options.
@@ -957,8 +1128,8 @@ class Runner:
957
1128
  ]
958
1129
  return [cls for cls in classes if cls]
959
1130
 
960
- def load_profiles(self, profiles):
961
- """Load profiles and update run options.
1131
+ def resolve_profiles(self, profiles):
1132
+ """Resolve profiles and update run options.
962
1133
 
963
1134
  Args:
964
1135
  profiles (list[str]): List of profile names to resolve.
@@ -966,22 +1137,64 @@ class Runner:
966
1137
  Returns:
967
1138
  list: List of profiles.
968
1139
  """
969
- from secator.cli import ALL_PROFILES
1140
+ # Return if profiles are disabled
1141
+ if not self.enable_profiles:
1142
+ return []
1143
+
1144
+ # Split profiles if comma separated
970
1145
  if isinstance(profiles, str):
971
1146
  profiles = profiles.split(',')
1147
+
1148
+ # Add default profiles
1149
+ default_profiles = CONFIG.profiles.defaults
1150
+ for p in default_profiles:
1151
+ if p in profiles:
1152
+ continue
1153
+ profiles.append(p)
1154
+
1155
+ # Abort if no profiles
1156
+ if not profiles:
1157
+ return []
1158
+
1159
+ # Get profile configs
972
1160
  templates = []
1161
+ profile_configs = get_configs_by_type('profile')
973
1162
  for pname in profiles:
974
- matches = [p for p in ALL_PROFILES if p.name == pname]
1163
+ matches = [p for p in profile_configs if p.name == pname]
975
1164
  if not matches:
976
- self._print(Warning(message=f'Profile "{pname}" was not found'), rich=True)
1165
+ self._print(Warning(message=f'Profile "{pname}" was not found. Run [bold green]secator profiles list[/] to see available profiles.'), rich=True) # noqa: E501
977
1166
  else:
978
1167
  templates.append(matches[0])
979
- opts = {}
1168
+
1169
+ if not templates:
1170
+ self.debug('no profiles loaded', sub='init')
1171
+ return
1172
+
1173
+ # Put enforced profiles last
1174
+ enforced_templates = [p for p in templates if p.enforce]
1175
+ non_enforced_templates = [p for p in templates if not p.enforce]
1176
+ templates = non_enforced_templates + enforced_templates
1177
+ profile_opts = {}
980
1178
  for profile in templates:
981
- self._print(Info(message=f'Loaded profile {profile.name} ({profile.description})'), rich=True)
982
- opts.update(profile.opts)
983
- opts = {k: v for k, v in opts.items() if k not in self.run_opts}
984
- self.run_opts.update(opts)
1179
+ self.debug(f'profile {profile.name} opts (enforced: {profile.enforce}): {profile.opts}', sub='init')
1180
+ enforced = profile.enforce or False
1181
+ description = profile.description or ''
1182
+ if enforced:
1183
+ profile_opts.update(profile.opts)
1184
+ else:
1185
+ profile_opts.update({k: self.run_opts.get(k) or v for k, v in profile.opts.items()})
1186
+ if self.print_profiles:
1187
+ msg = f'Loaded profile [bold pink3]{profile.name}[/]'
1188
+ if description:
1189
+ msg += f' ([dim]{description}[/])'
1190
+ if enforced:
1191
+ msg += ' [bold red](enforced)[/]'
1192
+ profile_opts_str = ", ".join([f'[bold yellow3]{k}[/]=[dim yellow3]{v}[/]' for k, v in profile.opts.items()])
1193
+ msg += rf' \[[dim]{profile_opts_str}[/]]'
1194
+ self._print(Info(message=msg), rich=True)
1195
+ if profile_opts:
1196
+ self.run_opts.update(profile_opts)
1197
+ return templates
985
1198
 
986
1199
  @classmethod
987
1200
  def get_func_path(cls, func):