secator 0.6.0__py3-none-any.whl → 0.8.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 (90) hide show
  1. secator/celery.py +160 -185
  2. secator/celery_utils.py +268 -0
  3. secator/cli.py +427 -176
  4. secator/config.py +114 -68
  5. secator/configs/workflows/host_recon.yaml +5 -3
  6. secator/configs/workflows/port_scan.yaml +7 -3
  7. secator/configs/workflows/subdomain_recon.yaml +2 -2
  8. secator/configs/workflows/url_bypass.yaml +10 -0
  9. secator/configs/workflows/url_dirsearch.yaml +1 -1
  10. secator/configs/workflows/url_vuln.yaml +1 -1
  11. secator/decorators.py +170 -92
  12. secator/definitions.py +11 -4
  13. secator/exporters/__init__.py +7 -5
  14. secator/exporters/console.py +10 -0
  15. secator/exporters/csv.py +27 -19
  16. secator/exporters/gdrive.py +16 -11
  17. secator/exporters/json.py +3 -1
  18. secator/exporters/table.py +30 -2
  19. secator/exporters/txt.py +20 -16
  20. secator/hooks/gcs.py +53 -0
  21. secator/hooks/mongodb.py +53 -27
  22. secator/installer.py +277 -60
  23. secator/output_types/__init__.py +29 -11
  24. secator/output_types/_base.py +11 -1
  25. secator/output_types/error.py +36 -0
  26. secator/output_types/exploit.py +12 -8
  27. secator/output_types/info.py +24 -0
  28. secator/output_types/ip.py +8 -1
  29. secator/output_types/port.py +9 -2
  30. secator/output_types/progress.py +5 -0
  31. secator/output_types/record.py +5 -3
  32. secator/output_types/stat.py +33 -0
  33. secator/output_types/subdomain.py +1 -1
  34. secator/output_types/tag.py +8 -6
  35. secator/output_types/target.py +2 -2
  36. secator/output_types/url.py +14 -11
  37. secator/output_types/user_account.py +6 -6
  38. secator/output_types/vulnerability.py +8 -6
  39. secator/output_types/warning.py +24 -0
  40. secator/report.py +56 -23
  41. secator/rich.py +44 -39
  42. secator/runners/_base.py +629 -638
  43. secator/runners/_helpers.py +5 -91
  44. secator/runners/celery.py +18 -0
  45. secator/runners/command.py +404 -214
  46. secator/runners/scan.py +8 -24
  47. secator/runners/task.py +21 -55
  48. secator/runners/workflow.py +41 -40
  49. secator/scans/__init__.py +28 -0
  50. secator/serializers/dataclass.py +6 -0
  51. secator/serializers/json.py +10 -5
  52. secator/serializers/regex.py +12 -4
  53. secator/tasks/_categories.py +147 -42
  54. secator/tasks/bbot.py +295 -0
  55. secator/tasks/bup.py +99 -0
  56. secator/tasks/cariddi.py +38 -49
  57. secator/tasks/dalfox.py +3 -0
  58. secator/tasks/dirsearch.py +14 -25
  59. secator/tasks/dnsx.py +49 -30
  60. secator/tasks/dnsxbrute.py +4 -1
  61. secator/tasks/feroxbuster.py +10 -20
  62. secator/tasks/ffuf.py +3 -2
  63. secator/tasks/fping.py +4 -4
  64. secator/tasks/gau.py +5 -0
  65. secator/tasks/gf.py +2 -2
  66. secator/tasks/gospider.py +4 -0
  67. secator/tasks/grype.py +11 -13
  68. secator/tasks/h8mail.py +32 -42
  69. secator/tasks/httpx.py +58 -21
  70. secator/tasks/katana.py +19 -23
  71. secator/tasks/maigret.py +27 -25
  72. secator/tasks/mapcidr.py +2 -3
  73. secator/tasks/msfconsole.py +22 -19
  74. secator/tasks/naabu.py +18 -2
  75. secator/tasks/nmap.py +82 -55
  76. secator/tasks/nuclei.py +13 -3
  77. secator/tasks/searchsploit.py +26 -11
  78. secator/tasks/subfinder.py +5 -1
  79. secator/tasks/wpscan.py +91 -94
  80. secator/template.py +61 -45
  81. secator/thread.py +24 -0
  82. secator/utils.py +417 -78
  83. secator/utils_test.py +48 -23
  84. secator/workflows/__init__.py +28 -0
  85. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/METADATA +59 -48
  86. secator-0.8.0.dist-info/RECORD +115 -0
  87. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/WHEEL +1 -1
  88. secator-0.6.0.dist-info/RECORD +0 -101
  89. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/entry_points.txt +0 -0
  90. {secator-0.6.0.dist-info → secator-0.8.0.dist-info}/licenses/LICENSE +0 -0
secator/cli.py CHANGED
@@ -5,6 +5,7 @@ import shutil
5
5
  import sys
6
6
 
7
7
  from pathlib import Path
8
+ from stat import S_ISFIFO
8
9
 
9
10
  import rich_click as click
10
11
  from dotmap import DotMap
@@ -13,17 +14,22 @@ from jinja2 import Template
13
14
  from rich.live import Live
14
15
  from rich.markdown import Markdown
15
16
  from rich.rule import Rule
17
+ from rich.table import Table
16
18
 
17
19
  from secator.config import CONFIG, ROOT_FOLDER, Config, default_config, config_path
18
- from secator.template import TemplateLoader
19
20
  from secator.decorators import OrderedGroup, register_runner
20
- from secator.definitions import ADDONS_ENABLED, ASCII, DEV_PACKAGE, OPT_NOT_SUPPORTED, VERSION
21
+ from secator.definitions import ADDONS_ENABLED, ASCII, DEV_PACKAGE, OPT_NOT_SUPPORTED, VERSION, STATE_COLORS
21
22
  from secator.installer import ToolInstaller, fmt_health_table_row, get_health_table, get_version_info
23
+ from secator.output_types import FINDING_TYPES, Info, Error
24
+ from secator.report import Report
22
25
  from secator.rich import console
23
26
  from secator.runners import Command, Runner
24
- from secator.report import Report
25
27
  from secator.serializers.dataclass import loads_dataclass
26
- from secator.utils import debug, detect_host, discover_tasks, flatten, print_results_table, print_version
28
+ from secator.template import TemplateLoader
29
+ from secator.utils import (
30
+ debug, detect_host, discover_tasks, flatten, print_version, get_file_date,
31
+ sort_files_by_date, get_file_timestamp, list_reports, get_info_from_report_path, human_to_timedelta
32
+ )
27
33
 
28
34
  click.rich_click.USE_RICH_MARKUP = True
29
35
 
@@ -31,6 +37,7 @@ ALL_TASKS = discover_tasks()
31
37
  ALL_CONFIGS = TemplateLoader.load_all()
32
38
  ALL_WORKFLOWS = ALL_CONFIGS.workflow
33
39
  ALL_SCANS = ALL_CONFIGS.scan
40
+ FINDING_TYPES_LOWER = [c.__name__.lower() for c in FINDING_TYPES]
34
41
 
35
42
 
36
43
  #-----#
@@ -42,7 +49,12 @@ ALL_SCANS = ALL_CONFIGS.scan
42
49
  @click.pass_context
43
50
  def cli(ctx, version):
44
51
  """Secator CLI."""
45
- console.print(ASCII, highlight=False)
52
+ ctx.obj = {
53
+ 'piped_input': S_ISFIFO(os.fstat(0).st_mode),
54
+ 'piped_output': not sys.stdout.isatty()
55
+ }
56
+ if not ctx.obj['piped_output']:
57
+ console.print(ASCII, highlight=False)
46
58
  if ctx.invoked_subcommand is None:
47
59
  if version:
48
60
  print_version()
@@ -55,13 +67,14 @@ def cli(ctx, version):
55
67
  #------#
56
68
 
57
69
  @cli.group(aliases=['x', 't'])
58
- def task():
70
+ @click.pass_context
71
+ def task(ctx):
59
72
  """Run a task."""
60
73
  pass
61
74
 
62
75
 
63
76
  for cls in ALL_TASKS:
64
- config = DotMap({'name': cls.__name__, 'type': 'task'})
77
+ config = TemplateLoader(input={'name': cls.__name__, 'type': 'task'})
65
78
  register_runner(task, config)
66
79
 
67
80
  #----------#
@@ -70,7 +83,8 @@ for cls in ALL_TASKS:
70
83
 
71
84
 
72
85
  @cli.group(cls=OrderedGroup, aliases=['w'])
73
- def workflow():
86
+ @click.pass_context
87
+ def workflow(ctx):
74
88
  """Run a workflow."""
75
89
  pass
76
90
 
@@ -84,7 +98,8 @@ for config in sorted(ALL_WORKFLOWS, key=lambda x: x['name']):
84
98
  #------#
85
99
 
86
100
  @cli.group(cls=OrderedGroup, aliases=['s'])
87
- def scan():
101
+ @click.pass_context
102
+ def scan(ctx):
88
103
  """Run a scan."""
89
104
  pass
90
105
 
@@ -109,25 +124,35 @@ for config in sorted(ALL_SCANS, key=lambda x: x['name']):
109
124
  @click.option('--show', is_flag=True, help='Show command (celery multi).')
110
125
  def worker(hostname, concurrency, reload, queue, pool, check, dev, stop, show):
111
126
  """Run a worker."""
127
+
128
+ # Check Celery addon is installed
112
129
  if not ADDONS_ENABLED['worker']:
113
130
  console.print('[bold red]Missing worker addon: please run [bold green4]secator install addons worker[/][/].')
114
131
  sys.exit(1)
132
+
133
+ # Check broken / backend addon is installed
115
134
  broker_protocol = CONFIG.celery.broker_url.split('://')[0]
116
135
  backend_protocol = CONFIG.celery.result_backend.split('://')[0]
117
136
  if CONFIG.celery.broker_url:
118
137
  if (broker_protocol == 'redis' or backend_protocol == 'redis') and not ADDONS_ENABLED['redis']:
119
138
  console.print('[bold red]Missing `redis` addon: please run [bold green4]secator install addons redis[/][/].')
120
139
  sys.exit(1)
140
+
141
+ # Debug Celery config
121
142
  from secator.celery import app, is_celery_worker_alive
122
- debug('conf', obj=dict(app.conf), obj_breaklines=True, sub='celery.app.conf', level=4)
123
- debug('registered tasks', obj=list(app.tasks.keys()), obj_breaklines=True, sub='celery.tasks', level=4)
143
+ debug('conf', obj=dict(app.conf), obj_breaklines=True, sub='celery.app')
144
+ debug('registered tasks', obj=list(app.tasks.keys()), obj_breaklines=True, sub='celery.app')
145
+
124
146
  if check:
125
147
  is_celery_worker_alive()
126
148
  return
149
+
127
150
  if not queue:
128
151
  queue = 'io,cpu,' + ','.join([r['queue'] for r in app.conf.task_routes.values()])
152
+
129
153
  app_str = 'secator.celery.app'
130
154
  celery = f'{sys.executable} -m celery'
155
+
131
156
  if dev:
132
157
  subcmd = 'stop' if stop else 'show' if show else 'start'
133
158
  logfile = '%n.log'
@@ -138,14 +163,15 @@ def worker(hostname, concurrency, reload, queue, pool, check, dev, stop, show):
138
163
  cmd = f'{celery} -A {app_str} multi {subcmd} 3 {queues} -P {pool} {concur} --logfile={logfile} --pidfile={pidfile}'
139
164
  else:
140
165
  cmd = f'{celery} -A {app_str} worker -n {hostname} -Q {queue}'
141
- if pool:
142
- cmd += f' -P {pool}'
143
- if concurrency:
144
- cmd += f' -c {concurrency}'
166
+
167
+ cmd += f' -P {pool}' if pool else ''
168
+ cmd += f' -c {concurrency}' if concurrency else ''
169
+
145
170
  if reload:
146
171
  patterns = "celery.py;tasks/*.py;runners/*.py;serializers/*.py;output_types/*.py;hooks/*.py;exporters/*.py"
147
172
  cmd = f'watchmedo auto-restart --directory=./ --patterns="{patterns}" --recursive -- {cmd}'
148
- Command.execute(cmd, name='secator worker')
173
+
174
+ Command.execute(cmd, name='secator_worker')
149
175
 
150
176
 
151
177
  #-------#
@@ -165,12 +191,12 @@ def util():
165
191
  def proxy(timeout, number):
166
192
  """Get random proxies from FreeProxy."""
167
193
  if CONFIG.offline_mode:
168
- console.print('[bold red]Cannot run this command in offline mode.[/]')
194
+ console.print(Error(message='Cannot run this command in offline mode.'))
169
195
  return
170
196
  proxy = FreeProxy(timeout=timeout, rand=True, anonym=True)
171
197
  for _ in range(number):
172
198
  url = proxy.get()
173
- print(url)
199
+ console.print(url)
174
200
 
175
201
 
176
202
  @util.command()
@@ -185,18 +211,16 @@ def revshell(name, host, port, interface, listen, force):
185
211
  if host is None: # detect host automatically
186
212
  host = detect_host(interface)
187
213
  if not host:
188
- console.print(
189
- f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of available interfaces.',
190
- style='bold red')
214
+ console.print(Error(message=f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of available interfaces')) # noqa: E501
191
215
  return
192
216
  else:
193
- console.print(f'[bold green]Detected host IP: [bold orange1]{host}[/].[/]')
217
+ console.print(Info(message=f'Detected host IP: [bold orange1]{host}[/]'))
194
218
 
195
219
  # Download reverse shells JSON from repo
196
220
  revshells_json = f'{CONFIG.dirs.revshells}/revshells.json'
197
221
  if not os.path.exists(revshells_json) or force:
198
222
  if CONFIG.offline_mode:
199
- console.print('[bold red]Cannot run this command in offline mode.[/]')
223
+ console.print(Error(message='Cannot run this command in offline mode'))
200
224
  return
201
225
  ret = Command.execute(
202
226
  f'wget https://raw.githubusercontent.com/freelabz/secator/main/scripts/revshells.json && mv revshells.json {CONFIG.dirs.revshells}', # noqa: E501
@@ -235,15 +259,14 @@ def revshell(name, host, port, interface, listen, force):
235
259
  console.print('\n'.join(shells_str))
236
260
  else:
237
261
  shell = shell[0]
238
- command = shell['command']
262
+ command = shell['command'].replace('[', r'\[')
239
263
  alias = shell['alias']
240
264
  name = shell['name']
241
265
  command_str = Template(command).render(ip=host, port=port, shell='bash')
242
266
  console.print(Rule(f'[bold gold3]{alias}[/] - [bold red]{name} REMOTE SHELL', style='bold red', align='left'))
243
267
  lang = shell.get('lang') or 'sh'
244
268
  if len(command.splitlines()) == 1:
245
- console.print()
246
- print(f'\033[0;36m{command_str}')
269
+ console.print(command_str, style='cyan', highlight=False, soft_wrap=True)
247
270
  else:
248
271
  md = Markdown(f'```{lang}\n{command_str}\n```')
249
272
  console.print(md)
@@ -252,7 +275,7 @@ def revshell(name, host, port, interface, listen, force):
252
275
  console.print(Rule(style='bold red'))
253
276
 
254
277
  if listen:
255
- console.print(f'Starting netcat listener on port {port} ...', style='bold gold3')
278
+ console.print(Info(message=f'Starting netcat listener on port {port} ...'))
256
279
  cmd = f'nc -lvnp {port}'
257
280
  Command.execute(cmd)
258
281
 
@@ -270,9 +293,7 @@ def serve(directory, host, port, interface):
270
293
  if not host:
271
294
  host = detect_host(interface)
272
295
  if not host:
273
- console.print(
274
- f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of interfaces.',
275
- style='bold red')
296
+ console.print(Error(message=f'Interface "{interface}" could not be found. Run "ifconfig" to see the list of interfaces.')) # noqa: E501
276
297
  return
277
298
  console.print(f'{fname} [dim][/]', style='bold magenta')
278
299
  console.print(f'wget http://{host}:{port}/{fname}', style='dim italic')
@@ -312,9 +333,9 @@ def record(record_name, script, interactive, width, height, output_dir):
312
333
  # If existing cast file, remove it
313
334
  if os.path.exists(output_cast_path):
314
335
  os.unlink(output_cast_path)
315
- console.print(f'Removed existing {output_cast_path}', style='bold green')
336
+ console.print(Info(message=f'Removed existing {output_cast_path}'))
316
337
 
317
- with console.status('[bold gold3]Recording with asciinema ...[/]'):
338
+ with console.status(Info(message='Recording with asciinema ...')):
318
339
  Command.execute(
319
340
  f'asciinema-automation -aa "-c /bin/sh" {script} {output_cast_path} --timeout 200',
320
341
  cls_attributes=attrs,
@@ -365,14 +386,14 @@ def record(record_name, script, interactive, width, height, output_dir):
365
386
  f'agg {output_cast_path} {output_gif_path}',
366
387
  cls_attributes=attrs,
367
388
  )
368
- console.print(f'Generated {output_gif_path}', style='bold green')
389
+ console.print(Info(message=f'Generated {output_gif_path}'))
369
390
 
370
391
 
371
392
  @util.group('build')
372
393
  def build():
373
394
  """Build secator."""
374
395
  if not DEV_PACKAGE:
375
- console.print('[bold red]You MUST use a development version of secator to make builds.[/]')
396
+ console.print(Error(message='You MUST use a development version of secator to make builds'))
376
397
  sys.exit(1)
377
398
  pass
378
399
 
@@ -381,7 +402,7 @@ def build():
381
402
  def build_pypi():
382
403
  """Build secator PyPI package."""
383
404
  if not ADDONS_ENABLED['build']:
384
- console.print('[bold red]Missing build addon: please run [bold green4]secator install addons build[/][/]')
405
+ console.print(Error(message='Missing build addon: please run [bold green4]secator install addons build[/]'))
385
406
  sys.exit(1)
386
407
  with console.status('[bold gold3]Building PyPI package...[/]'):
387
408
  ret = Command.execute(f'{sys.executable} -m hatch build', name='hatch build', cwd=ROOT_FOLDER)
@@ -523,6 +544,41 @@ def config_default(save):
523
544
  # CONFIG.save()
524
545
  # console.print(f'\n[bold green]:tada: Saved config to [/]{CONFIG._path}')
525
546
 
547
+ #-----------#
548
+ # WORKSPACE #
549
+ #-----------#
550
+ @cli.group(aliases=['ws'])
551
+ def workspace():
552
+ """Workspaces."""
553
+ pass
554
+
555
+
556
+ @workspace.command('list')
557
+ def workspace_list():
558
+ """List workspaces."""
559
+ workspaces = {}
560
+ json_reports = []
561
+ for root, _, files in os.walk(CONFIG.dirs.reports):
562
+ for file in files:
563
+ if file.endswith('report.json'):
564
+ path = Path(root) / file
565
+ json_reports.append(path)
566
+ json_reports = sorted(json_reports, key=lambda x: x.stat().st_mtime, reverse=False)
567
+ for path in json_reports:
568
+ ws, runner_type, number = str(path).split('/')[-4:-1]
569
+ if ws not in workspaces:
570
+ workspaces[ws] = {'count': 0, 'path': '/'.join(str(path).split('/')[:-3])}
571
+ workspaces[ws]['count'] += 1
572
+
573
+ # Build table
574
+ table = Table()
575
+ table.add_column("Workspace name", style="bold gold3")
576
+ table.add_column("Run count", overflow='fold')
577
+ table.add_column("Path")
578
+ for workspace, config in workspaces.items():
579
+ table.add_row(workspace, str(config['count']), config['path'])
580
+ console.print(table)
581
+
526
582
 
527
583
  #--------#
528
584
  # REPORT #
@@ -531,54 +587,184 @@ def config_default(save):
531
587
 
532
588
  @cli.group(aliases=['r'])
533
589
  def report():
534
- """View previous reports."""
590
+ """Reports."""
535
591
  pass
536
592
 
537
593
 
538
594
  @report.command('show')
539
- @click.argument('json_path')
540
- @click.option('-o', '--output', type=str, default='console', help='Format')
541
- @click.option('-e', '--exclude-fields', type=str, default='', help='List of fields to exclude (comma-separated)')
542
- def report_show(json_path, output, exclude_fields):
543
- """Show a JSON report."""
544
- with open(json_path, 'r') as f:
545
- report = loads_dataclass(f.read())
546
- results = flatten(list(report['results'].values()))
547
- if output == 'console':
548
- for result in results:
549
- console.print(result)
550
- elif output == 'table':
551
- exclude_fields = exclude_fields.split(',')
552
- print_results_table(
553
- results,
554
- title=report['info']['title'],
555
- exclude_fields=exclude_fields)
556
-
595
+ @click.argument('report_query', required=False)
596
+ @click.option('-o', '--output', type=str, default='console', help='Exporters')
597
+ @click.option('-r', '--runner-type', type=str, default=None, help='Filter by runner type. Choices: task, workflow, scan') # noqa: E501
598
+ @click.option('-d', '--time-delta', type=str, default=None, help='Keep results newer than time delta. E.g: 26m, 1d, 1y') # noqa: E501
599
+ @click.option('-t', '--type', type=str, default='', help=f'Filter by output type. Choices: {FINDING_TYPES_LOWER}')
600
+ @click.option('-q', '--query', type=str, default=None, help='Query results using a Python expression')
601
+ @click.option('-w', '-ws', '--workspace', type=str, default=None, help='Filter by workspace name')
602
+ @click.option('-u', '--unified', is_flag=True, default=False, help='Show unified results (merge reports and de-duplicates results)') # noqa: E501
603
+ def report_show(report_query, output, runner_type, time_delta, type, query, workspace, unified):
604
+ """Show report results and filter on them."""
605
+
606
+ # Get extractors
607
+ otypes = [o.__name__.lower() for o in FINDING_TYPES]
608
+ extractors = []
609
+ if type:
610
+ type = type.split(',')
611
+ for typedef in type:
612
+ if typedef:
613
+ if '.' in typedef:
614
+ _type, _field = tuple(typedef.split('.'))
615
+ else:
616
+ _type = typedef
617
+ _field = None
618
+ extractors.append({
619
+ 'type': _type,
620
+ 'field': _field,
621
+ 'condition': query or 'True'
622
+ })
623
+ elif query:
624
+ query = query.split(';')
625
+ for part in query:
626
+ _type = part.split('.')[0]
627
+ if _type in otypes:
628
+ part = part.replace(_type, 'item')
629
+ extractor = {
630
+ 'type': _type,
631
+ 'condition': part or 'True'
632
+ }
633
+ extractors.append(extractor)
634
+
635
+ # Build runner instance
636
+ current = get_file_timestamp()
637
+ runner = DotMap({
638
+ "config": {
639
+ "name": f"consolidated_report_{current}"
640
+ },
641
+ "name": "runner",
642
+ "workspace_name": "_consolidated",
643
+ "reports_folder": Path.cwd(),
644
+ })
645
+ exporters = Runner.resolve_exporters(output)
557
646
 
558
- @report.command('list')
559
- @click.option('-ws', '--workspace', type=str)
560
- def report_list(workspace):
561
- reports_dir = CONFIG.dirs.reports
562
- json_reports = reports_dir.glob("**/**/report.json")
563
- ws_reports = {}
564
- for path in json_reports:
565
- ws, runner, number = str(path).split('/')[-4:-1]
566
- if ws not in ws_reports:
567
- ws_reports[ws] = []
647
+ # Build report queries from fuzzy input
648
+ paths = []
649
+ if report_query:
650
+ report_query = report_query.split(',')
651
+ else:
652
+ report_query = []
653
+
654
+ # Load all report paths
655
+ load_all_reports = any([not Path(p).exists() for p in report_query])
656
+ all_reports = []
657
+ if load_all_reports or workspace:
658
+ all_reports = list_reports(workspace=workspace, type=runner_type, timedelta=human_to_timedelta(time_delta))
659
+ if not report_query:
660
+ report_query = all_reports
661
+
662
+ for query in report_query:
663
+ query = str(query)
664
+ if not query.endswith('/'):
665
+ query += '/'
666
+ path = Path(query)
667
+ if not path.exists():
668
+ matches = []
669
+ for path in all_reports:
670
+ if query in str(path):
671
+ matches.append(path)
672
+ if not matches:
673
+ console.print(
674
+ f'[bold orange3]Query {query} did not return any matches. [/][bold green]Ignoring.[/]')
675
+ paths.extend(matches)
676
+ else:
677
+ paths.append(path)
678
+ paths = sort_files_by_date(paths)
679
+
680
+ # Load reports, extract results
681
+ all_results = []
682
+ for ix, path in enumerate(paths):
683
+ if unified:
684
+ console.print(rf'Loading {path} \[[bold yellow4]{ix + 1}[/]/[bold yellow4]{len(paths)}[/]] \[results={len(all_results)}]...') # noqa: E501
568
685
  with open(path, 'r') as f:
686
+ data = loads_dataclass(f.read())
569
687
  try:
570
- content = json.loads(f.read())
571
- data = {'path': path, 'name': content['info']['name'], 'runner': runner}
572
- ws_reports[ws].append(data)
573
- except json.JSONDecodeError as e:
688
+ info = get_info_from_report_path(path)
689
+ runner_type = info.get('type', 'unknowns')[:-1]
690
+ runner.results = flatten(list(data['results'].values()))
691
+ if unified:
692
+ all_results.extend(runner.results)
693
+ continue
694
+ report = Report(runner, title=f"Consolidated report - {current}", exporters=exporters)
695
+ report.build(extractors=extractors if not unified else [])
696
+ file_date = get_file_date(path)
697
+ runner_name = data['info']['name']
698
+ console.print(
699
+ f'\n{path} ([bold blue]{runner_name}[/] [dim]{runner_type}[/]) ([dim]{file_date}[/]):')
700
+ if report.is_empty():
701
+ if len(paths) == 1:
702
+ console.print('[bold orange4]No results in report.[/]')
703
+ else:
704
+ console.print('[bold orange4]No new results since previous scan.[/]')
705
+ continue
706
+ report.send()
707
+ except json.decoder.JSONDecodeError as e:
574
708
  console.print(f'[bold red]Could not load {path}: {str(e)}')
575
709
 
576
- for ws in ws_reports:
577
- if workspace and not ws == workspace:
578
- continue
579
- console.print(f'[bold gold3]{ws}:')
580
- for data in sorted(ws_reports[ws], key=lambda x: x['path']):
581
- console.print(f' • {data["path"]} ([bold blue]{data["name"]}[/] [dim]{data["runner"][:-1]}[/])')
710
+ if unified:
711
+ console.print(f'\n:wrench: [bold gold3]Building report by crunching {len(all_results)} results ...[/]')
712
+ console.print(':coffee: [dim]Note that this can take a while when the result count is high...[/]')
713
+ runner.results = all_results
714
+ report = Report(runner, title=f"Consolidated report - {current}", exporters=exporters)
715
+ report.build(extractors=extractors, dedupe=True)
716
+ report.send()
717
+
718
+
719
+ @report.command('list')
720
+ @click.option('-ws', '-w', '--workspace', type=str)
721
+ @click.option('-r', '--runner-type', type=str, default=None, help='Filter by runner type. Choices: task, workflow, scan') # noqa: E501
722
+ @click.option('-d', '--time-delta', type=str, default=None, help='Keep results newer than time delta. E.g: 26m, 1d, 1y') # noqa: E501
723
+ def report_list(workspace, runner_type, time_delta):
724
+ """List all secator reports."""
725
+ paths = list_reports(workspace=workspace, type=runner_type, timedelta=human_to_timedelta(time_delta))
726
+ paths = sorted(paths, key=lambda x: x.stat().st_mtime, reverse=False)
727
+
728
+ # Build table
729
+ table = Table()
730
+ table.add_column("Workspace", style="bold gold3")
731
+ table.add_column("Path", overflow='fold')
732
+ table.add_column("Name")
733
+ table.add_column("Id")
734
+ table.add_column("Date")
735
+ table.add_column("Status", style="green")
736
+
737
+ # Load each report
738
+ for path in paths:
739
+ try:
740
+ info = get_info_from_report_path(path)
741
+ with open(path, 'r') as f:
742
+ content = json.loads(f.read())
743
+ data = {
744
+ 'workspace': info['workspace'],
745
+ 'name': f"[bold blue]{content['info']['name']}[/]",
746
+ 'status': content['info'].get('status', ''),
747
+ 'id': info['type'] + '/' + info['id'],
748
+ 'date': get_file_date(path), # Assuming get_file_date returns a readable date
749
+ }
750
+ status_color = STATE_COLORS[data['status']] if data['status'] in STATE_COLORS else 'white'
751
+
752
+ # Update table
753
+ table.add_row(
754
+ data['workspace'],
755
+ str(path),
756
+ data['name'],
757
+ data['id'],
758
+ data['date'],
759
+ f"[{status_color}]{data['status']}[/]"
760
+ )
761
+ except json.JSONDecodeError as e:
762
+ console.print(f'[bold red]Could not load {path}: {str(e)}')
763
+
764
+ if len(paths) > 0:
765
+ console.print(table)
766
+ else:
767
+ console.print('[bold red]No results found.')
582
768
 
583
769
 
584
770
  @report.command('export')
@@ -588,7 +774,6 @@ def report_list(workspace):
588
774
  def report_export(json_path, output_folder, output):
589
775
  with open(json_path, 'r') as f:
590
776
  data = loads_dataclass(f.read())
591
- flatten(list(data['results'].values()))
592
777
 
593
778
  runner_instance = DotMap({
594
779
  "config": {
@@ -634,7 +819,8 @@ def report_export(json_path, output_folder, output):
634
819
  @cli.command(name='health')
635
820
  @click.option('--json', '-json', is_flag=True, default=False, help='JSON lines output')
636
821
  @click.option('--debug', '-debug', is_flag=True, default=False, help='Debug health output')
637
- def health(json, debug):
822
+ @click.option('--strict', '-strict', is_flag=True, default=False, help='Fail if missing tools')
823
+ def health(json, debug, strict):
638
824
  """[dim]Get health status.[/]"""
639
825
  tools = ALL_TASKS
640
826
  status = {'secator': {}, 'languages': {}, 'tools': {}, 'addons': {}}
@@ -652,14 +838,13 @@ def health(json, debug):
652
838
  console.print('\n:wrench: [bold gold3]Checking installed addons ...[/]')
653
839
  table = get_health_table()
654
840
  with Live(table, console=console):
655
- for addon in ['worker', 'google', 'mongodb', 'redis', 'dev', 'trace', 'build']:
656
- addon_var = ADDONS_ENABLED[addon]
841
+ for addon, installed in ADDONS_ENABLED.items():
657
842
  info = {
658
843
  'name': addon,
659
844
  'version': None,
660
- 'status': 'ok' if addon_var else 'missing',
845
+ 'status': 'ok' if installed else 'missing',
661
846
  'latest_version': None,
662
- 'installed': addon_var,
847
+ 'installed': installed,
663
848
  'location': None
664
849
  }
665
850
  row = fmt_health_table_row(info, 'addons')
@@ -682,39 +867,58 @@ def health(json, debug):
682
867
  table = get_health_table()
683
868
  with Live(table, console=console):
684
869
  for tool in tools:
685
- cmd = tool.cmd.split(' ')[0]
686
- version_flag = tool.version_flag or f'{tool.opt_prefix}version'
687
- version_flag = None if tool.version_flag == OPT_NOT_SUPPORTED else version_flag
688
- info = get_version_info(cmd, version_flag, tool.install_github_handle)
870
+ info = get_version_info(
871
+ tool.cmd.split(' ')[0],
872
+ tool.version_flag or f'{tool.opt_prefix}version',
873
+ tool.install_github_handle,
874
+ tool.install_cmd
875
+ )
689
876
  row = fmt_health_table_row(info, 'tools')
690
877
  table.add_row(*row)
691
878
  status['tools'][tool.__name__] = info
879
+ console.print('')
692
880
 
693
881
  # Print JSON health
694
882
  if json:
695
883
  import json as _json
696
884
  print(_json.dumps(status))
697
885
 
886
+ # Strict mode
887
+ if strict:
888
+ error = False
889
+ for tool, info in status['tools'].items():
890
+ if not info['installed']:
891
+ console.print(Error(message=f'{tool} not installed and strict mode is enabled.[/]'))
892
+ error = True
893
+ if error:
894
+ sys.exit(1)
895
+ console.print(Info(message='Strict healthcheck passed !'))
896
+
897
+
698
898
  #---------#
699
899
  # INSTALL #
700
900
  #---------#
701
901
 
702
902
 
703
- def run_install(cmd, title, next_steps=None):
903
+ def run_install(title=None, cmd=None, packages=None, next_steps=None):
704
904
  if CONFIG.offline_mode:
705
905
  console.print('[bold red]Cannot run this command in offline mode.[/]')
706
906
  return
707
907
  with console.status(f'[bold yellow] Installing {title}...'):
708
- ret = Command.execute(cmd, cls_attributes={'shell': True}, print_cmd=True, print_line=True)
709
- if ret.return_code != 0:
710
- console.print(f':exclamation_mark: Failed to install {title}.', style='bold red')
711
- else:
712
- console.print(f':tada: {title.capitalize()} installed successfully !', style='bold green')
908
+ if cmd:
909
+ from secator.installer import SourceInstaller
910
+ status = SourceInstaller.install(cmd)
911
+ elif packages:
912
+ from secator.installer import PackageInstaller
913
+ status = PackageInstaller.install(packages)
914
+ return_code = 1
915
+ if status.is_ok():
916
+ return_code = 0
713
917
  if next_steps:
714
918
  console.print('[bold gold3]:wrench: Next steps:[/]')
715
919
  for ix, step in enumerate(next_steps):
716
920
  console.print(f' :keycap_{ix}: {step}')
717
- sys.exit(ret.return_code)
921
+ sys.exit(return_code)
718
922
 
719
923
 
720
924
  @cli.group()
@@ -731,40 +935,52 @@ def addons():
731
935
 
732
936
  @addons.command('worker')
733
937
  def install_worker():
734
- "Install worker addon."
938
+ "Install Celery worker addon."
735
939
  run_install(
736
940
  cmd=f'{sys.executable} -m pip install secator[worker]',
737
- title='worker addon',
941
+ title='Celery worker addon',
738
942
  next_steps=[
739
943
  'Run [bold green4]secator worker[/] to run a Celery worker using the file system as a backend and broker.',
740
944
  'Run [bold green4]secator x httpx testphp.vulnweb.com[/] to admire your task running in a worker.',
741
- '[dim]\[optional][/dim] Run [bold green4]secator install addons redis[/] to setup Redis backend / broker.'
945
+ r'[dim]\[optional][/dim] Run [bold green4]secator install addons redis[/] to setup Redis backend / broker.'
742
946
  ]
743
947
  )
744
948
 
745
949
 
746
- @addons.command('google')
747
- def install_google():
748
- "Install google addon."
950
+ @addons.command('gdrive')
951
+ def install_gdrive():
952
+ "Install Google Drive addon."
749
953
  run_install(
750
954
  cmd=f'{sys.executable} -m pip install secator[google]',
751
- title='google addon',
955
+ title='Google Drive addon',
752
956
  next_steps=[
753
- 'Run [bold green4]secator config set addons.google.credentials_path <VALUE>[/].',
754
- 'Run [bold green4]secator config set addons.google.drive_parent_folder_id <VALUE>[/].',
957
+ 'Run [bold green4]secator config set addons.gdrive.credentials_path <VALUE>[/].',
958
+ 'Run [bold green4]secator config set addons.gdrive.drive_parent_folder_id <VALUE>[/].',
755
959
  'Run [bold green4]secator x httpx testphp.vulnweb.com -o gdrive[/] to send reports to Google Drive.'
756
960
  ]
757
961
  )
758
962
 
759
963
 
964
+ @addons.command('gcs')
965
+ def install_gcs():
966
+ "Install Google Cloud Storage addon."
967
+ run_install(
968
+ cmd=f'{sys.executable} -m pip install secator[gcs]',
969
+ title='Google Cloud Storage addon',
970
+ next_steps=[
971
+ 'Run [bold green4]secator config set addons.gcs.credentials_path <VALUE>[/].',
972
+ ]
973
+ )
974
+
975
+
760
976
  @addons.command('mongodb')
761
977
  def install_mongodb():
762
- "Install mongodb addon."
978
+ "Install MongoDB addon."
763
979
  run_install(
764
980
  cmd=f'{sys.executable} -m pip install secator[mongodb]',
765
- title='mongodb addon',
981
+ title='MongoDB addon',
766
982
  next_steps=[
767
- '[dim]\[optional][/] Run [bold green4]docker run --name mongo -p 27017:27017 -d mongo:latest[/] to run a local MongoDB instance.', # noqa: E501
983
+ r'[dim]\[optional][/] Run [bold green4]docker run --name mongo -p 27017:27017 -d mongo:latest[/] to run a local MongoDB instance.', # noqa: E501
768
984
  'Run [bold green4]secator config set addons.mongodb.url mongodb://<URL>[/].',
769
985
  'Run [bold green4]secator x httpx testphp.vulnweb.com -driver mongodb[/] to save results to MongoDB.'
770
986
  ]
@@ -773,12 +989,12 @@ def install_mongodb():
773
989
 
774
990
  @addons.command('redis')
775
991
  def install_redis():
776
- "Install redis addon."
992
+ "Install Redis addon."
777
993
  run_install(
778
994
  cmd=f'{sys.executable} -m pip install secator[redis]',
779
- title='redis addon',
995
+ title='Redis addon',
780
996
  next_steps=[
781
- '[dim]\[optional][/] Run [bold green4]docker run --name redis -p 6379:6379 -d redis[/] to run a local Redis instance.', # noqa: E501
997
+ r'[dim]\[optional][/] Run [bold green4]docker run --name redis -p 6379:6379 -d redis[/] to run a local Redis instance.', # noqa: E501
782
998
  'Run [bold green4]secator config set celery.broker_url redis://<URL>[/]',
783
999
  'Run [bold green4]secator config set celery.result_backend redis://<URL>[/]',
784
1000
  'Run [bold green4]secator worker[/] to run a worker.',
@@ -806,7 +1022,7 @@ def install_trace():
806
1022
  "Install trace addon."
807
1023
  run_install(
808
1024
  cmd=f'{sys.executable} -m pip install secator[trace]',
809
- title='dev addon',
1025
+ title='trace addon',
810
1026
  next_steps=[
811
1027
  ]
812
1028
  )
@@ -849,7 +1065,12 @@ def install_go():
849
1065
  def install_ruby():
850
1066
  """Install Ruby."""
851
1067
  run_install(
852
- cmd='wget -O - https://raw.githubusercontent.com/freelabz/secator/main/scripts/install_ruby.sh | sudo sh',
1068
+ packages={
1069
+ 'apt': ['ruby-full', 'rubygems'],
1070
+ 'apk': ['ruby', 'ruby-dev'],
1071
+ 'pacman': ['ruby', 'ruby-dev'],
1072
+ 'brew': ['ruby']
1073
+ },
853
1074
  title='Ruby'
854
1075
  )
855
1076
 
@@ -859,43 +1080,22 @@ def install_ruby():
859
1080
  def install_tools(cmds):
860
1081
  """Install supported tools."""
861
1082
  if CONFIG.offline_mode:
862
- console.print('[bold red]Cannot run this command in offline mode.[/]')
1083
+ console.print(Error(message='Cannot run this command in offline mode.'))
863
1084
  return
864
1085
  if cmds is not None:
865
1086
  cmds = cmds.split(',')
866
1087
  tools = [cls for cls in ALL_TASKS if cls.__name__ in cmds]
867
1088
  else:
868
1089
  tools = ALL_TASKS
869
-
1090
+ tools.sort(key=lambda x: x.__name__)
1091
+ return_code = 0
870
1092
  for ix, cls in enumerate(tools):
871
- with console.status(f'[bold yellow][{ix}/{len(tools)}] Installing {cls.__name__} ...'):
872
- ToolInstaller.install(cls)
1093
+ with console.status(f'[bold yellow][{ix + 1}/{len(tools)}] Installing {cls.__name__} ...'):
1094
+ status = ToolInstaller.install(cls)
1095
+ if not status.is_ok():
1096
+ return_code = 1
873
1097
  console.print()
874
-
875
-
876
- @install.command('cves')
877
- @click.option('--force', is_flag=True)
878
- def install_cves(force):
879
- """Install CVEs (enables passive vulnerability search)."""
880
- if CONFIG.offline_mode:
881
- console.print('[bold red]Cannot run this command in offline mode.[/]')
882
- return
883
- cve_json_path = f'{CONFIG.dirs.cves}/circl-cve-search-expanded.json'
884
- if not os.path.exists(cve_json_path) or force:
885
- with console.status('[bold yellow]Downloading zipped CVEs from cve.circl.lu ...[/]'):
886
- Command.execute('wget https://cve.circl.lu/static/circl-cve-search-expanded.json.gz', cwd=CONFIG.dirs.cves)
887
- with console.status('[bold yellow]Unzipping CVEs ...[/]'):
888
- Command.execute(f'gunzip {CONFIG.dirs.cves}/circl-cve-search-expanded.json.gz', cwd=CONFIG.dirs.cves)
889
- with console.status(f'[bold yellow]Installing CVEs to {CONFIG.dirs.cves} ...[/]'):
890
- with open(cve_json_path, 'r') as f:
891
- for line in f:
892
- data = json.loads(line)
893
- cve_id = data['id']
894
- cve_path = f'{CONFIG.dirs.cves}/{cve_id}.json'
895
- with open(cve_path, 'w') as f:
896
- f.write(line)
897
- console.print(f'CVE saved to {cve_path}')
898
- console.print(':tada: CVEs installed successfully !', style='bold green')
1098
+ sys.exit(return_code)
899
1099
 
900
1100
 
901
1101
  #--------#
@@ -903,22 +1103,52 @@ def install_cves(force):
903
1103
  #--------#
904
1104
 
905
1105
  @cli.command('update')
906
- def update():
1106
+ @click.option('--all', '-a', is_flag=True, help='Update all secator dependencies (addons, tools, ...)')
1107
+ def update(all):
907
1108
  """[dim]Update to latest version.[/]"""
908
1109
  if CONFIG.offline_mode:
909
- console.print('[bold red]Cannot run this command in offline mode.[/]')
910
- return
1110
+ console.print(Error(message='Cannot run this command in offline mode.'))
1111
+ sys.exit(1)
1112
+
1113
+ # Check current and latest version
911
1114
  info = get_version_info('secator', github_handle='freelabz/secator', version=VERSION)
912
1115
  latest_version = info['latest_version']
1116
+ do_update = True
1117
+
1118
+ # Skip update if latest
913
1119
  if info['status'] == 'latest':
914
- console.print(f'[bold green]secator is already at the newest version {latest_version}[/] !')
915
- sys.exit(0)
916
- console.print(f'[bold gold3]:wrench: Updating secator from {VERSION} to {latest_version} ...[/]')
917
- if 'pipx' in sys.executable:
918
- Command.execute(f'pipx install secator=={latest_version} --force')
919
- else:
920
- Command.execute(f'pip install secator=={latest_version}')
1120
+ console.print(Info(message=f'secator is already at the newest version {latest_version} !'))
1121
+ do_update = False
921
1122
 
1123
+ # Fail if unknown latest
1124
+ if not latest_version:
1125
+ console.print(Error(message='Could not fetch latest secator version.'))
1126
+ sys.exit(1)
1127
+
1128
+ # Update secator
1129
+ if do_update:
1130
+ console.print(f'[bold gold3]:wrench: Updating secator from {VERSION} to {latest_version} ...[/]')
1131
+ if 'pipx' in sys.executable:
1132
+ ret = Command.execute(f'pipx install secator=={latest_version} --force')
1133
+ else:
1134
+ ret = Command.execute(f'pip install secator=={latest_version}')
1135
+ if not ret.return_code == 0:
1136
+ sys.exit(1)
1137
+
1138
+ # Update tools
1139
+ if all:
1140
+ return_code = 0
1141
+ for cls in ALL_TASKS:
1142
+ cmd = cls.cmd.split(' ')[0]
1143
+ version_flag = cls.version_flag or f'{cls.opt_prefix}version'
1144
+ version_flag = None if cls.version_flag == OPT_NOT_SUPPORTED else version_flag
1145
+ info = get_version_info(cmd, version_flag, cls.install_github_handle)
1146
+ if not info['installed'] or info['status'] == 'outdated' or not info['latest_version']:
1147
+ with console.status(f'[bold yellow]Installing {cls.__name__} ...'):
1148
+ status = ToolInstaller.install(cls)
1149
+ if not status.is_ok():
1150
+ return_code = 1
1151
+ sys.exit(return_code)
922
1152
 
923
1153
  #-------#
924
1154
  # ALIAS #
@@ -1020,10 +1250,10 @@ def list_aliases(silent):
1020
1250
  def test():
1021
1251
  """[dim]Run tests."""
1022
1252
  if not DEV_PACKAGE:
1023
- console.print('[bold red]You MUST use a development version of secator to run tests.[/]')
1253
+ console.print(Error(message='You MUST use a development version of secator to run tests.'))
1024
1254
  sys.exit(1)
1025
1255
  if not ADDONS_ENABLED['dev']:
1026
- console.print('[bold red]Missing dev addon: please run [bold green4]secator install addons dev[/][/]')
1256
+ console.print(Error(message='Missing dev addon: please run [bold green4]secator install addons dev[/]'))
1027
1257
  sys.exit(1)
1028
1258
  pass
1029
1259
 
@@ -1053,23 +1283,22 @@ def lint():
1053
1283
  @click.option('--workflows', type=str, default='', help='Secator workflows to test (comma-separated)')
1054
1284
  @click.option('--scans', type=str, default='', help='Secator scans to test (comma-separated)')
1055
1285
  @click.option('--test', '-t', type=str, help='Secator test to run')
1056
- @click.option('--debug', '-d', type=int, default=0, help='Add debug information')
1057
- def unit(tasks, workflows, scans, test, debug=False):
1286
+ def unit(tasks, workflows, scans, test):
1058
1287
  """Run unit tests."""
1059
1288
  os.environ['TEST_TASKS'] = tasks or ''
1060
1289
  os.environ['TEST_WORKFLOWS'] = workflows or ''
1061
1290
  os.environ['TEST_SCANS'] = scans or ''
1062
- os.environ['SECATOR_DEBUG_LEVEL'] = str(debug)
1291
+ os.environ['SECATOR_DIRS_DATA'] = '/tmp/.secator'
1292
+ os.environ['SECATOR_OFFLINE_MODE'] = "1"
1063
1293
  os.environ['SECATOR_HTTP_STORE_RESPONSES'] = '0'
1064
1294
  os.environ['SECATOR_RUNNERS_SKIP_CVE_SEARCH'] = '1'
1065
1295
 
1066
- cmd = f'{sys.executable} -m coverage run --omit="*test*" -m unittest'
1296
+ import shutil
1297
+ shutil.rmtree('/tmp/.secator', ignore_errors=True)
1298
+ cmd = f'{sys.executable} -m coverage run --omit="*test*" --data-file=.coverage.unit -m pytest -s -v tests/unit'
1067
1299
  if test:
1068
- if not test.startswith('tests.unit'):
1069
- test = f'tests.unit.{test}'
1070
- cmd += f' {test}'
1071
- else:
1072
- cmd += ' discover -v tests.unit'
1300
+ test_str = ' or '.join(test.split(','))
1301
+ cmd += f' -k "{test_str}"'
1073
1302
  run_test(cmd, 'unit')
1074
1303
 
1075
1304
 
@@ -1078,35 +1307,57 @@ def unit(tasks, workflows, scans, test, debug=False):
1078
1307
  @click.option('--workflows', type=str, default='', help='Secator workflows to test (comma-separated)')
1079
1308
  @click.option('--scans', type=str, default='', help='Secator scans to test (comma-separated)')
1080
1309
  @click.option('--test', '-t', type=str, help='Secator test to run')
1081
- @click.option('--debug', '-d', type=int, default=0, help='Add debug information')
1082
- def integration(tasks, workflows, scans, test, debug):
1310
+ def integration(tasks, workflows, scans, test):
1083
1311
  """Run integration tests."""
1084
1312
  os.environ['TEST_TASKS'] = tasks or ''
1085
1313
  os.environ['TEST_WORKFLOWS'] = workflows or ''
1086
1314
  os.environ['TEST_SCANS'] = scans or ''
1087
- os.environ['SECATOR_DEBUG_LEVEL'] = str(debug)
1315
+ os.environ['SECATOR_DIRS_DATA'] = '/tmp/.secator'
1088
1316
  os.environ['SECATOR_RUNNERS_SKIP_CVE_SEARCH'] = '1'
1089
- os.environ['SECATOR_DIRS_DATA'] = '/tmp/data'
1090
- os.environ['SECATOR_DIRS_REPORTS'] = '/tmp/data/reports'
1091
- os.environ['SECATOR_DIRS_CELERY'] = '/tmp/celery'
1092
- os.environ['SECATOR_DIRS_CELERY_DATA'] = '/tmp/celery/data'
1093
- os.environ['SECATOR_DIRS_CELERY_RESULTS'] = '/tmp/celery/results'
1317
+
1094
1318
  import shutil
1095
- for path in ['/tmp/data', '/tmp/celery', '/tmp/celery/data', '/tmp/celery/results']:
1096
- shutil.rmtree(path, ignore_errors=True)
1319
+ shutil.rmtree('/tmp/.secator', ignore_errors=True)
1097
1320
 
1098
- cmd = f'{sys.executable} -m unittest'
1321
+ cmd = f'{sys.executable} -m coverage run --omit="*test*" --data-file=.coverage.integration -m pytest -s -v tests/integration' # noqa: E501
1099
1322
  if test:
1100
- if not test.startswith('tests.integration'):
1101
- test = f'tests.integration.{test}'
1102
- cmd += f' {test}'
1103
- else:
1104
- cmd += ' discover -v tests.integration'
1323
+ test_str = ' or '.join(test.split(','))
1324
+ cmd += f' -k "{test_str}"'
1105
1325
  run_test(cmd, 'integration')
1106
1326
 
1107
1327
 
1108
1328
  @test.command()
1109
- def coverage():
1110
- """Run coverage report."""
1329
+ @click.option('--tasks', type=str, default='', help='Secator tasks to test (comma-separated)')
1330
+ @click.option('--workflows', type=str, default='', help='Secator workflows to test (comma-separated)')
1331
+ @click.option('--scans', type=str, default='', help='Secator scans to test (comma-separated)')
1332
+ @click.option('--test', '-t', type=str, help='Secator test to run')
1333
+ def performance(tasks, workflows, scans, test):
1334
+ """Run integration tests."""
1335
+ os.environ['TEST_TASKS'] = tasks or ''
1336
+ os.environ['TEST_WORKFLOWS'] = workflows or ''
1337
+ os.environ['TEST_SCANS'] = scans or ''
1338
+ os.environ['SECATOR_DIRS_DATA'] = '/tmp/.secator'
1339
+ os.environ['SECATOR_RUNNERS_SKIP_CVE_SEARCH'] = '1'
1340
+
1341
+ # import shutil
1342
+ # shutil.rmtree('/tmp/.secator', ignore_errors=True)
1343
+
1344
+ cmd = f'{sys.executable} -m coverage run --omit="*test*" --data-file=.coverage.performance -m pytest -s -v tests/performance' # noqa: E501
1345
+ if test:
1346
+ test_str = ' or '.join(test.split(','))
1347
+ cmd += f' -k "{test_str}"'
1348
+ run_test(cmd, 'performance')
1349
+
1350
+
1351
+ @test.command()
1352
+ @click.option('--unit-only', '-u', is_flag=True, default=False, help='Only generate coverage for unit tests')
1353
+ @click.option('--integration-only', '-i', is_flag=True, default=False, help='Only generate coverage for integration tests') # noqa: E501
1354
+ def coverage(unit_only, integration_only):
1355
+ """Run coverage combine + coverage report."""
1111
1356
  cmd = f'{sys.executable} -m coverage report -m --omit=*/site-packages/*,*/tests/*,*/templates/*'
1357
+ if unit_only:
1358
+ cmd += ' --data-file=.coverage.unit'
1359
+ elif integration_only:
1360
+ cmd += ' --data-file=.coverage.integration'
1361
+ else:
1362
+ Command.execute(f'{sys.executable} -m coverage combine --keep', name='coverage combine', cwd=ROOT_FOLDER)
1112
1363
  run_test(cmd, 'coverage')