secator 0.10.1a12__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of secator might be problematic. Click here for more details.

Files changed (73) hide show
  1. secator/celery.py +10 -5
  2. secator/celery_signals.py +2 -11
  3. secator/cli.py +309 -69
  4. secator/config.py +3 -2
  5. secator/configs/profiles/aggressive.yaml +6 -5
  6. secator/configs/profiles/default.yaml +6 -7
  7. secator/configs/profiles/insane.yaml +8 -0
  8. secator/configs/profiles/paranoid.yaml +8 -0
  9. secator/configs/profiles/polite.yaml +8 -0
  10. secator/configs/profiles/sneaky.yaml +8 -0
  11. secator/configs/profiles/tor.yaml +5 -0
  12. secator/configs/workflows/host_recon.yaml +11 -2
  13. secator/configs/workflows/url_dirsearch.yaml +5 -0
  14. secator/configs/workflows/url_params_fuzz.yaml +25 -0
  15. secator/configs/workflows/wordpress.yaml +4 -1
  16. secator/decorators.py +64 -34
  17. secator/definitions.py +8 -4
  18. secator/installer.py +84 -49
  19. secator/output_types/__init__.py +2 -1
  20. secator/output_types/certificate.py +78 -0
  21. secator/output_types/stat.py +3 -0
  22. secator/output_types/user_account.py +1 -1
  23. secator/report.py +2 -2
  24. secator/rich.py +1 -1
  25. secator/runners/_base.py +50 -11
  26. secator/runners/_helpers.py +15 -3
  27. secator/runners/command.py +85 -21
  28. secator/runners/scan.py +6 -3
  29. secator/runners/task.py +1 -0
  30. secator/runners/workflow.py +22 -4
  31. secator/tasks/_categories.py +25 -17
  32. secator/tasks/arjun.py +92 -0
  33. secator/tasks/bbot.py +33 -4
  34. secator/tasks/bup.py +4 -2
  35. secator/tasks/cariddi.py +17 -4
  36. secator/tasks/dalfox.py +4 -2
  37. secator/tasks/dirsearch.py +4 -2
  38. secator/tasks/dnsx.py +5 -2
  39. secator/tasks/dnsxbrute.py +4 -1
  40. secator/tasks/feroxbuster.py +5 -2
  41. secator/tasks/ffuf.py +7 -3
  42. secator/tasks/fping.py +4 -1
  43. secator/tasks/gau.py +5 -2
  44. secator/tasks/gf.py +4 -2
  45. secator/tasks/gitleaks.py +79 -0
  46. secator/tasks/gospider.py +5 -2
  47. secator/tasks/grype.py +5 -2
  48. secator/tasks/h8mail.py +4 -2
  49. secator/tasks/httpx.py +6 -3
  50. secator/tasks/katana.py +6 -3
  51. secator/tasks/maigret.py +4 -2
  52. secator/tasks/mapcidr.py +5 -3
  53. secator/tasks/msfconsole.py +8 -6
  54. secator/tasks/naabu.py +16 -5
  55. secator/tasks/nmap.py +31 -29
  56. secator/tasks/nuclei.py +18 -10
  57. secator/tasks/searchsploit.py +8 -3
  58. secator/tasks/subfinder.py +6 -3
  59. secator/tasks/testssl.py +276 -0
  60. secator/tasks/trivy.py +98 -0
  61. secator/tasks/wafw00f.py +85 -0
  62. secator/tasks/wpprobe.py +96 -0
  63. secator/tasks/wpscan.py +8 -4
  64. secator/template.py +61 -67
  65. secator/utils.py +31 -18
  66. secator/utils_test.py +34 -10
  67. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/METADATA +11 -3
  68. secator-0.15.1.dist-info/RECORD +128 -0
  69. secator/configs/profiles/stealth.yaml +0 -7
  70. secator-0.10.1a12.dist-info/RECORD +0 -116
  71. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/WHEEL +0 -0
  72. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/entry_points.txt +0 -0
  73. {secator-0.10.1a12.dist-info → secator-0.15.1.dist-info}/licenses/LICENSE +0 -0
secator/installer.py CHANGED
@@ -11,6 +11,7 @@ import io
11
11
  from dataclasses import dataclass
12
12
  from datetime import datetime
13
13
  from enum import Enum
14
+ from pathlib import Path
14
15
 
15
16
  import json
16
17
  import requests
@@ -30,8 +31,10 @@ class InstallerStatus(Enum):
30
31
  INSTALL_FAILED = 'INSTALL_FAILED'
31
32
  INSTALL_NOT_SUPPORTED = 'INSTALL_NOT_SUPPORTED'
32
33
  INSTALL_SKIPPED_OK = 'INSTALL_SKIPPED_OK'
34
+ INSTALL_VERSION_NOT_SPECIFIED = 'INSTALL_VERSION_NOT_SPECIFIED'
33
35
  GITHUB_LATEST_RELEASE_NOT_FOUND = 'GITHUB_LATEST_RELEASE_NOT_FOUND'
34
36
  GITHUB_RELEASE_NOT_FOUND = 'RELEASE_NOT_FOUND'
37
+ GITHUB_RELEASE_UNMATCHED_DISTRIBUTION = 'RELEASE_UNMATCHED_DISTRIBUTION'
35
38
  GITHUB_RELEASE_FAILED_DOWNLOAD = 'GITHUB_RELEASE_FAILED_DOWNLOAD'
36
39
  GITHUB_BINARY_NOT_FOUND_IN_ARCHIVE = 'GITHUB_BINARY_NOT_FOUND_IN_ARCHIVE'
37
40
  UNKNOWN_DISTRIBUTION = 'UNKNOWN_DISTRIBUTION'
@@ -44,6 +47,7 @@ class InstallerStatus(Enum):
44
47
  @dataclass
45
48
  class Distribution:
46
49
  name: str
50
+ system: str
47
51
  pm_name: str
48
52
  pm_installer: str
49
53
  pm_finalizer: str
@@ -82,12 +86,12 @@ class ToolInstaller:
82
86
  # Install binaries from GH
83
87
  gh_status = InstallerStatus.UNKNOWN
84
88
  if tool_cls.install_github_handle and not CONFIG.security.force_source_install:
85
- gh_status = GithubInstaller.install(tool_cls.install_github_handle)
89
+ gh_status = GithubInstaller.install(tool_cls.install_github_handle, version=tool_cls.install_version or 'latest')
86
90
  status = gh_status
87
91
 
88
92
  # Install from source
89
93
  if tool_cls.install_cmd and not gh_status.is_ok():
90
- status = SourceInstaller.install(tool_cls.install_cmd)
94
+ status = SourceInstaller.install(tool_cls.install_cmd, tool_cls.install_version)
91
95
  if not status.is_ok():
92
96
  cls.print_status(status, name)
93
97
  return status
@@ -166,12 +170,14 @@ class SourceInstaller:
166
170
  """Install a tool from source."""
167
171
 
168
172
  @classmethod
169
- def install(cls, config, install_prereqs=True):
173
+ def install(cls, config, version=None, install_prereqs=True):
170
174
  """Install from source.
171
175
 
172
176
  Args:
173
177
  cls: ToolInstaller class.
174
178
  config (dict): A dict of distros as keys and a command as value.
179
+ version (str, optional): Version to install.
180
+ install_prereqs (bool, optional): Install pre-requisites.
175
181
 
176
182
  Returns:
177
183
  Status: install status.
@@ -181,6 +187,8 @@ class SourceInstaller:
181
187
  install_cmd = config
182
188
  else:
183
189
  distribution = get_distro_config()
190
+ if not distribution.pm_installer:
191
+ return InstallerStatus.UNKNOWN_DISTRIBUTION
184
192
  for distros, command in config.items():
185
193
  if distribution.name in distros.split("|") or distros == '*':
186
194
  install_cmd = command
@@ -203,6 +211,11 @@ class SourceInstaller:
203
211
  if not status.is_ok():
204
212
  return status
205
213
 
214
+ # Handle version
215
+ if '[install_version]' in install_cmd:
216
+ version = version or 'latest'
217
+ install_cmd = install_cmd.replace('[install_version]', version)
218
+
206
219
  # Run command
207
220
  ret = Command.execute(install_cmd, cls_attributes={'shell': True}, quiet=False)
208
221
  return InstallerStatus.SUCCESS if ret.return_code == 0 else InstallerStatus.INSTALL_FAILED
@@ -212,7 +225,7 @@ class GithubInstaller:
212
225
  """Install a tool from GitHub releases."""
213
226
 
214
227
  @classmethod
215
- def install(cls, github_handle):
228
+ def install(cls, github_handle, version='latest'):
216
229
  """Find and install a release from a GitHub handle {user}/{repo}.
217
230
 
218
231
  Args:
@@ -222,35 +235,38 @@ class GithubInstaller:
222
235
  InstallerStatus: status.
223
236
  """
224
237
  _, repo = tuple(github_handle.split('/'))
225
- latest_release = cls.get_latest_release(github_handle)
226
- if not latest_release:
227
- return InstallerStatus.GITHUB_LATEST_RELEASE_NOT_FOUND
238
+ release = cls.get_release(github_handle, version=version)
239
+ if not release:
240
+ return InstallerStatus.GITHUB_RELEASE_NOT_FOUND
228
241
 
229
242
  # Find the right asset to download
230
- os_identifiers, arch_identifiers = cls._get_platform_identifier()
231
- download_url = cls._find_matching_asset(latest_release['assets'], os_identifiers, arch_identifiers)
243
+ system, arch, os_identifiers, arch_identifiers = cls._get_platform_identifier()
244
+ download_url = cls._find_matching_asset(release['assets'], os_identifiers, arch_identifiers)
232
245
  if not download_url:
233
- console.print(Error(message='Could not find a GitHub release matching distribution.'))
234
- return InstallerStatus.GITHUB_RELEASE_NOT_FOUND
246
+ console.print(Error(message=f'Could not find a GitHub release matching distribution (system: {system}, arch: {arch}).')) # noqa: E501
247
+ return InstallerStatus.GITHUB_RELEASE_UNMATCHED_DISTRIBUTION
235
248
 
236
249
  # Download and unpack asset
237
250
  console.print(Info(message=f'Found release URL: {download_url}'))
238
251
  return cls._download_and_unpack(download_url, CONFIG.dirs.bin, repo)
239
252
 
240
253
  @classmethod
241
- def get_latest_release(cls, github_handle):
242
- """Get latest release from GitHub.
254
+ def get_release(cls, github_handle, version='latest'):
255
+ """Get release from GitHub.
243
256
 
244
257
  Args:
245
258
  github_handle (str): A GitHub handle {user}/{repo}.
246
259
 
247
260
  Returns:
248
- dict: Latest release JSON from GitHub releases.
261
+ dict: Release JSON from GitHub releases.
249
262
  """
250
263
  if not github_handle:
251
264
  return False
252
265
  owner, repo = tuple(github_handle.split('/'))
253
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
266
+ if version == 'latest':
267
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
268
+ else:
269
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{version}"
254
270
  headers = {}
255
271
  if CONFIG.cli.github_token:
256
272
  headers['Authorization'] = f'Bearer {CONFIG.cli.github_token}'
@@ -261,11 +277,13 @@ class GithubInstaller:
261
277
  return latest_release
262
278
  except requests.RequestException as e:
263
279
  console.print(Warning(message=f'Failed to fetch latest release for {github_handle}: {str(e)}'))
280
+ if 'rate limit exceeded' in str(e):
281
+ console.print(Warning(message='Consider setting env variable SECATOR_CLI_GITHUB_TOKEN or use secator config set cli.github_token $TOKEN.')) # noqa: E501
264
282
  return None
265
283
 
266
284
  @classmethod
267
285
  def get_latest_version(cls, github_handle):
268
- latest_release = cls.get_latest_release(github_handle)
286
+ latest_release = cls.get_release(github_handle, version='latest')
269
287
  if not latest_release:
270
288
  return None
271
289
  return latest_release['tag_name'].lstrip('v')
@@ -285,16 +303,16 @@ class GithubInstaller:
285
303
 
286
304
  # Enhanced architecture mapping to avoid conflicts
287
305
  arch_mapping = {
288
- 'x86_64': ['amd64', 'x86_64'],
289
- 'amd64': ['amd64', 'x86_64'],
306
+ 'x86_64': ['amd64', 'x86_64', '64bit', 'x64'],
307
+ 'amd64': ['amd64', 'x86_64', '64bit', 'x64'],
290
308
  'aarch64': ['arm64', 'aarch64'],
291
309
  'armv7l': ['armv7', 'arm'],
292
- '386': ['386', 'x86', 'i386'],
310
+ '386': ['386', 'x86', 'i386', '32bit', 'x32'],
293
311
  }
294
312
 
295
313
  os_identifiers = os_mapping.get(system, [])
296
314
  arch_identifiers = arch_mapping.get(arch, [])
297
- return os_identifiers, arch_identifiers
315
+ return system, arch, os_identifiers, arch_identifiers
298
316
 
299
317
  @classmethod
300
318
  def _find_matching_asset(cls, assets, os_identifiers, arch_identifiers):
@@ -348,6 +366,9 @@ class GithubInstaller:
348
366
  elif url.endswith('.tar.gz'):
349
367
  with tarfile.open(fileobj=io.BytesIO(response.content), mode='r:gz') as tar:
350
368
  tar.extractall(path=temp_dir)
369
+ else:
370
+ with Path(f'{temp_dir}/{repo_name}').open('wb') as f:
371
+ f.write(response.content)
351
372
 
352
373
  # For archives, find and move the binary that matches the repo name
353
374
  binary_path = cls._find_binary_in_directory(temp_dir, repo_name)
@@ -397,13 +418,11 @@ def get_version(version_cmd):
397
418
  import re
398
419
  regex = r'[0-9]+\.[0-9]+\.?[0-9]*\.?[a-zA-Z]*'
399
420
  ret = Command.execute(version_cmd, quiet=True, print_errors=False)
400
- return_code = ret.return_code
401
- if not return_code == 0:
402
- return '', ret.return_code
403
421
  match = re.findall(regex, ret.output)
404
422
  if not match:
405
- return '', return_code
406
- return match[0], return_code
423
+ console.print(Warning(message=f'Failed to find version in version command output. Command: {version_cmd}; Output: {ret.output}; Return code: {ret.return_code}')) # noqa: E501
424
+ return None
425
+ return match[0]
407
426
 
408
427
 
409
428
  def parse_version(ver):
@@ -436,14 +455,22 @@ def get_version_info(name, version_flag=None, install_github_handle=None, instal
436
455
  'name': name,
437
456
  'installed': False,
438
457
  'version': version,
458
+ 'version_cmd': None,
439
459
  'latest_version': None,
440
460
  'location': None,
441
- 'status': ''
461
+ 'status': '',
462
+ 'outdated': False,
463
+ 'errors': []
442
464
  }
443
465
 
444
466
  # Get binary path
445
467
  location = which(name).output
468
+ if not location or not Path(location).exists():
469
+ info['installed'] = False
470
+ info['status'] = 'missing'
471
+ return info
446
472
  info['location'] = location
473
+ info['installed'] = True
447
474
 
448
475
  # Get latest version
449
476
  latest_version = None
@@ -472,34 +499,36 @@ def get_version_info(name, version_flag=None, install_github_handle=None, instal
472
499
  if ver:
473
500
  latest_version = str(ver)
474
501
  info['latest_version'] = latest_version
502
+ else:
503
+ error = f'Failed to get latest version for {name}. Command: apt-cache madison {name}'
504
+ info['errors'].append(error)
505
+ console.print(Warning(message=error))
475
506
 
476
507
  # Get current version
477
- version_ret = 1
478
508
  version_flag = None if version_flag == OPT_NOT_SUPPORTED else version_flag
479
509
  if version_flag:
480
510
  version_cmd = f'{name} {version_flag}'
481
- version, version_ret = get_version(version_cmd)
511
+ info['version_cmd'] = version_cmd
512
+ version = get_version(version_cmd)
482
513
  info['version'] = version
483
- if version_ret != 0: # version command error
484
- info['installed'] = False
485
- info['status'] = 'missing'
514
+ if not version:
515
+ info['errors'].append(f'Error fetching version for command. Version command: {version_cmd}')
516
+ info['status'] = 'version fetch error'
486
517
  return info
487
518
 
488
- if location:
489
- info['installed'] = True
490
- if version and latest_version:
491
- if parse_version(version) < parse_version(latest_version):
492
- info['status'] = 'outdated'
493
- else:
494
- info['status'] = 'latest'
495
- elif not version:
496
- info['status'] = 'current unknown'
497
- elif not latest_version:
498
- info['status'] = 'latest unknown'
499
- if CONFIG.offline_mode:
500
- info['status'] += r' [dim orange1]\[offline][/]'
501
- else:
502
- info['status'] = 'missing'
519
+ # Check if up-to-date
520
+ if version and latest_version:
521
+ if parse_version(version) < parse_version(latest_version):
522
+ info['status'] = 'outdated'
523
+ info['outdated'] = True
524
+ else:
525
+ info['status'] = 'latest'
526
+ elif not version:
527
+ info['status'] = 'current unknown'
528
+ elif not latest_version:
529
+ info['status'] = 'latest unknown'
530
+ if CONFIG.offline_mode:
531
+ info['status'] += r' [dim orange1]\[offline][/]'
503
532
 
504
533
  return info
505
534
 
@@ -517,7 +546,7 @@ def get_distro_config():
517
546
  distrib = system
518
547
 
519
548
  if system == "Linux":
520
- distrib = distro.id()
549
+ distrib = distro.like() or distro.id()
521
550
 
522
551
  if distrib in ["ubuntu", "debian", "linuxmint", "popos", "kali"]:
523
552
  installer = "apt install -y --no-install-recommends"
@@ -547,12 +576,16 @@ def get_distro_config():
547
576
  else:
548
577
  installer = "scoop" # Alternative package manager for Windows
549
578
 
550
- manager = installer.split(' ')[0]
579
+ if not installer:
580
+ console.print(Error(message=f'Could not find installer for your distribution (system: {system}, distrib: {distrib})')) # noqa: E501
581
+
582
+ manager = installer.split(' ')[0] if installer else ''
551
583
  config = Distribution(
552
584
  pm_installer=installer,
553
585
  pm_finalizer=finalizer,
554
586
  pm_name=manager,
555
- name=distrib
587
+ name=distrib,
588
+ system=system
556
589
  )
557
590
  return config
558
591
 
@@ -578,6 +611,8 @@ def fmt_health_table_row(version_info, category=None):
578
611
  _version = '[bold red]missing[/]'
579
612
  elif status == 'ok':
580
613
  _version = '[bold green]ok [/]'
614
+ elif status == 'version fetch error':
615
+ _version = '[bold orange1]unknown[/] [dim](current unknown)[/]'
581
616
  elif status:
582
617
  if not version and installed:
583
618
  _version = '[bold green]ok [/]'
@@ -26,6 +26,7 @@ from secator.output_types.url import Url
26
26
  from secator.output_types.user_account import UserAccount
27
27
  from secator.output_types.vulnerability import Vulnerability
28
28
  from secator.output_types.record import Record
29
+ from secator.output_types.certificate import Certificate
29
30
  from secator.output_types.info import Info
30
31
  from secator.output_types.warning import Warning
31
32
  from secator.output_types.error import Error
@@ -39,6 +40,6 @@ STAT_TYPES = [
39
40
  Stat
40
41
  ]
41
42
  FINDING_TYPES = [
42
- Subdomain, Ip, Port, Url, Tag, Exploit, UserAccount, Vulnerability
43
+ Subdomain, Ip, Port, Url, Tag, Exploit, UserAccount, Vulnerability, Certificate
43
44
  ]
44
45
  OUTPUT_TYPES = FINDING_TYPES + EXECUTION_TYPES + STAT_TYPES
@@ -0,0 +1,78 @@
1
+ import time
2
+ from datetime import datetime, timedelta
3
+ from dataclasses import dataclass, field
4
+ from secator.output_types import OutputType
5
+ from secator.utils import rich_to_ansi
6
+ from secator.definitions import CERTIFICATE_STATUS_UNKNOWN
7
+
8
+
9
+ @dataclass
10
+ class Certificate(OutputType):
11
+ host: str
12
+ fingerprint_sha256: str = field(default='')
13
+ ip: str = field(default='', compare=False)
14
+ raw_value: str = field(default='', compare=False)
15
+ subject_cn: str = field(default='', compare=False)
16
+ subject_an: list[str] = field(default_factory=list, compare=False)
17
+ not_before: datetime = field(default=None, compare=False)
18
+ not_after: datetime = field(default=None, compare=False)
19
+ issuer_dn: str = field(default='', compare=False)
20
+ issuer_cn: str = field(default='', compare=False)
21
+ issuer: str = field(default='', compare=False)
22
+ self_signed: bool = field(default=True, compare=False)
23
+ trusted: bool = field(default=False, compare=False)
24
+ status: str = field(default=CERTIFICATE_STATUS_UNKNOWN, compare=False)
25
+ keysize: int = field(default=None, compare=False)
26
+ serial_number: str = field(default='', compare=False)
27
+ ciphers: list[str] = field(default_factory=list, compare=False)
28
+ # parent_certificate: 'Certificate' = None # noqa: F821
29
+ _source: str = field(default='', repr=True)
30
+ _type: str = field(default='certificate', repr=True)
31
+ _timestamp: int = field(default_factory=lambda: time.time(), compare=False)
32
+ _uuid: str = field(default='', repr=True, compare=False)
33
+ _context: dict = field(default_factory=dict, repr=True, compare=False)
34
+ _tagged: bool = field(default=False, repr=True, compare=False)
35
+ _duplicate: bool = field(default=False, repr=True, compare=False)
36
+ _related: list = field(default_factory=list, compare=False)
37
+ _table_fields = ['ip', 'host']
38
+ _sort_by = ('ip',)
39
+
40
+ def __str__(self) -> str:
41
+ return self.subject_cn
42
+
43
+ def is_expired(self) -> bool:
44
+ if self.not_after:
45
+ return self.not_after < datetime.now()
46
+ return True
47
+
48
+ def is_expired_soon(self, months: int = 1) -> bool:
49
+ if self.not_after:
50
+ return self.not_after < datetime.now() + timedelta(days=months * 30)
51
+ return True
52
+
53
+ @staticmethod
54
+ def format_date(date):
55
+ if date:
56
+ return date.strftime("%m/%d/%Y")
57
+ return '?'
58
+
59
+ def __repr__(self) -> str:
60
+ s = f'📜 [bold white]{self.host}[/]'
61
+ s += f' [cyan]{self.status}[/]'
62
+ s += rf' [white]\[fingerprint={self.fingerprint_sha256[:10]}][/]'
63
+ if self.subject_cn:
64
+ s += rf' [white]\[cn={self.subject_cn}][/]'
65
+ if self.subject_an:
66
+ s += rf' [white]\[an={", ".join(self.subject_an)}][/]'
67
+ if self.issuer:
68
+ s += rf' [white]\[issuer={self.issuer}][/]'
69
+ elif self.issuer_cn:
70
+ s += rf' [white]\[issuer_cn={self.issuer_cn}][/]'
71
+ expiry_date = Certificate.format_date(self.not_after)
72
+ if self.is_expired():
73
+ s += f' [red]expired since {expiry_date}[/red]'
74
+ elif self.is_expired_soon(months=2):
75
+ s += f' [yellow]expires <2 months[/yellow], [yellow]valid until {expiry_date}[/yellow]'
76
+ else:
77
+ s += f' [green]not expired[/green], [yellow]valid until {expiry_date}[/yellow]'
78
+ return rich_to_ansi(s)
@@ -25,6 +25,9 @@ class Stat(OutputType):
25
25
  _table_fields = ['name', 'pid', 'cpu', 'memory']
26
26
  _sort_by = ('name', 'pid')
27
27
 
28
+ def __str__(self) -> str:
29
+ return f'{self.name} [pid={self.pid}] [cpu={self.cpu:.2f}%] [memory={self.memory:.2f}%]'
30
+
28
31
  def __repr__(self) -> str:
29
32
  s = rf'[dim yellow3]📊 {self.name} \[pid={self.pid}] \[cpu={self.cpu:.2f}%] \[memory={self.memory:.2f}%]'
30
33
  if self.net_conns:
@@ -37,5 +37,5 @@ class UserAccount(OutputType):
37
37
  if self.url:
38
38
  s += rf' \[[white]{_s(self.url)}[/]]'
39
39
  if self.extra_data:
40
- s += r' \[[bold yellow]' + _s(', '.join(f'{k}:{v}' for k, v in self.extra_data.items()) + '[/]]')
40
+ s += r' \[[bold yellow]' + _s(', '.join(f'{k}:{v}' for k, v in self.extra_data.items())) + '[/]]'
41
41
  return rich_to_ansi(s)
secator/report.py CHANGED
@@ -55,7 +55,7 @@ class Report:
55
55
  f'{str(e)}[/]\n[dim]{traceback_as_string(e)}[/]',
56
56
  )
57
57
 
58
- def build(self, extractors=[], dedupe=False):
58
+ def build(self, extractors=[], dedupe=CONFIG.runners.remove_duplicates):
59
59
  # Trim options
60
60
  from secator.decorators import DEFAULT_CLI_OPTIONS
61
61
  opts = merge_opts(self.runner.config.options, self.runner.run_opts)
@@ -97,7 +97,7 @@ class Report:
97
97
  if items:
98
98
  if sort_by and all(sort_by):
99
99
  items = sorted(items, key=operator.attrgetter(*sort_by))
100
- if dedupe and CONFIG.runners.remove_duplicates:
100
+ if dedupe:
101
101
  items = remove_duplicates(items)
102
102
  # items = [item for item in items if not item._duplicate and item not in dedupe_from]
103
103
  for extractor in extractors:
secator/rich.py CHANGED
@@ -4,7 +4,7 @@ import yaml
4
4
  from rich.console import Console
5
5
  from rich.table import Table
6
6
 
7
- console = Console(stderr=True)
7
+ console = Console(stderr=True, record=True)
8
8
  console_stdout = Console(record=True)
9
9
  # handler = RichHandler(rich_tracebacks=True) # TODO: add logging handler
10
10
 
secator/runners/_base.py CHANGED
@@ -11,7 +11,7 @@ import humanize
11
11
  from secator.definitions import ADDONS_ENABLED
12
12
  from secator.celery_utils import CeleryData
13
13
  from secator.config import CONFIG
14
- from secator.output_types import FINDING_TYPES, OutputType, Progress, Info, Warning, Error, Target, State
14
+ from secator.output_types import FINDING_TYPES, OUTPUT_TYPES, OutputType, Progress, Info, Warning, Error, Target, State
15
15
  from secator.report import Report
16
16
  from secator.rich import console, console_stdout
17
17
  from secator.runners._helpers import (get_task_folder_id, run_extractors)
@@ -53,7 +53,7 @@ class Runner:
53
53
  """
54
54
 
55
55
  # Input field (mostly for tests and CLI)
56
- input_type = None
56
+ input_types = []
57
57
 
58
58
  # Output types
59
59
  output_types = []
@@ -102,6 +102,7 @@ class Runner:
102
102
  self.piped_input = self.run_opts.get('piped_input', False)
103
103
  self.piped_output = self.run_opts.get('piped_output', False)
104
104
  self.enable_duplicate_check = self.run_opts.get('enable_duplicate_check', True)
105
+ self.dry_run = self.run_opts.get('dry_run', False)
105
106
 
106
107
  # Runner print opts
107
108
  self.print_item = self.run_opts.get('print_item', False)
@@ -128,18 +129,19 @@ class Runner:
128
129
  [self.add_result(result, print=False, output=False) for result in results]
129
130
 
130
131
  # Determine inputs
131
- inputs = [inputs] if not isinstance(inputs, list) else inputs
132
- if not self.chunk and self.results:
133
- inputs, run_opts, errors = run_extractors(self.results, run_opts, inputs)
134
- for error in errors:
135
- self.add_result(error, print=True)
136
- self.inputs = list(set(inputs))
132
+ self.inputs = [inputs] if not isinstance(inputs, list) else inputs
133
+ targets = [Target(name=target) for target in self.inputs]
134
+ self.filter_results(results + targets)
137
135
 
138
136
  # Debug
139
137
  self.debug('Inputs', obj=self.inputs, sub='init')
140
138
  self.debug('Run opts', obj={k: v for k, v in self.run_opts.items() if v is not None}, sub='init')
141
139
  self.debug('Print opts', obj={k: v for k, v in self.print_opts.items() if v is not None}, sub='init')
142
140
 
141
+ # Load profiles
142
+ profiles_str = run_opts.get('profiles', [])
143
+ self.load_profiles(profiles_str)
144
+
143
145
  # Determine exporters
144
146
  exporters_str = self.run_opts.get('output') or self.default_exporters
145
147
  self.exporters = self.resolve_exporters(exporters_str)
@@ -310,6 +312,8 @@ class Runner:
310
312
  self.mark_completed()
311
313
 
312
314
  finally:
315
+ if self.dry_run:
316
+ return
313
317
  if self.sync:
314
318
  self.mark_completed()
315
319
  if self.enable_reports:
@@ -328,6 +332,15 @@ class Runner:
328
332
  self.add_result(error, print=True)
329
333
  yield error
330
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
+
331
344
  def add_result(self, item, print=False, output=True):
332
345
  """Add item to runner results.
333
346
 
@@ -818,7 +831,7 @@ class Runner:
818
831
  if isinstance(data, (OutputType, dict)):
819
832
  if getattr(data, 'toDict', None):
820
833
  data = data.toDict()
821
- data = json.dumps(data)
834
+ data = json.dumps(data, default=str)
822
835
  print(data, file=out)
823
836
 
824
837
  def _get_findings_count(self):
@@ -911,8 +924,8 @@ class Runner:
911
924
  elif isinstance(item, Info) and item.task_id and item.task_id not in self.celery_ids:
912
925
  self.celery_ids.append(item.task_id)
913
926
 
914
- # If finding, run on_item hooks
915
- elif isinstance(item, tuple(FINDING_TYPES)):
927
+ # If output type, run on_item hooks
928
+ elif isinstance(item, tuple(OUTPUT_TYPES)):
916
929
  item = self.run_hooks('on_item', item)
917
930
  if not item:
918
931
  return
@@ -944,6 +957,32 @@ class Runner:
944
957
  ]
945
958
  return [cls for cls in classes if cls]
946
959
 
960
+ def load_profiles(self, profiles):
961
+ """Load profiles and update run options.
962
+
963
+ Args:
964
+ profiles (list[str]): List of profile names to resolve.
965
+
966
+ Returns:
967
+ list: List of profiles.
968
+ """
969
+ from secator.cli import ALL_PROFILES
970
+ if isinstance(profiles, str):
971
+ profiles = profiles.split(',')
972
+ templates = []
973
+ for pname in profiles:
974
+ matches = [p for p in ALL_PROFILES if p.name == pname]
975
+ if not matches:
976
+ self._print(Warning(message=f'Profile "{pname}" was not found'), rich=True)
977
+ else:
978
+ templates.append(matches[0])
979
+ opts = {}
980
+ 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)
985
+
947
986
  @classmethod
948
987
  def get_func_path(cls, func):
949
988
  """Get the full symbolic path of a function or method, including staticmethods, using function and method
@@ -4,27 +4,39 @@ from secator.output_types import Error
4
4
  from secator.utils import deduplicate, debug
5
5
 
6
6
 
7
- def run_extractors(results, opts, inputs=[]):
7
+ def run_extractors(results, opts, inputs=[], dry_run=False):
8
8
  """Run extractors and merge extracted values with option dict.
9
9
 
10
10
  Args:
11
11
  results (list): List of results.
12
12
  opts (dict): Options.
13
13
  inputs (list): Original inputs.
14
+ dry_run (bool): Dry run.
14
15
 
15
16
  Returns:
16
17
  tuple: inputs, options, errors.
17
18
  """
18
19
  extractors = {k: v for k, v in opts.items() if k.endswith('_')}
19
20
  errors = []
21
+ computed_inputs = []
22
+ computed_opts = {}
20
23
  for key, val in extractors.items():
21
24
  key = key.rstrip('_')
22
25
  values, err = extract_from_results(results, val)
23
26
  errors.extend(err)
24
27
  if key == 'targets':
25
- inputs = deduplicate(values)
28
+ targets = ['<COMPUTED>'] if dry_run else deduplicate(values)
29
+ computed_inputs.extend(targets)
26
30
  else:
27
- opts[key] = deduplicate(values)
31
+ computed_opt = ['<COMPUTED>'] if dry_run else deduplicate(values)
32
+ if computed_opt:
33
+ computed_opts[key] = computed_opt
34
+ opts[key] = computed_opts[key]
35
+ if computed_inputs:
36
+ debug('computed_inputs', obj=computed_inputs, sub='extractors')
37
+ inputs = computed_inputs
38
+ if computed_opts:
39
+ debug('computed_opts', obj=computed_opts, sub='extractors')
28
40
  return inputs, opts, errors
29
41
 
30
42