secator 0.22.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.
Files changed (150) hide show
  1. secator/.gitignore +162 -0
  2. secator/__init__.py +0 -0
  3. secator/celery.py +453 -0
  4. secator/celery_signals.py +138 -0
  5. secator/celery_utils.py +320 -0
  6. secator/cli.py +2035 -0
  7. secator/cli_helper.py +395 -0
  8. secator/click.py +87 -0
  9. secator/config.py +670 -0
  10. secator/configs/__init__.py +0 -0
  11. secator/configs/profiles/__init__.py +0 -0
  12. secator/configs/profiles/aggressive.yaml +8 -0
  13. secator/configs/profiles/all_ports.yaml +7 -0
  14. secator/configs/profiles/full.yaml +31 -0
  15. secator/configs/profiles/http_headless.yaml +7 -0
  16. secator/configs/profiles/http_record.yaml +8 -0
  17. secator/configs/profiles/insane.yaml +8 -0
  18. secator/configs/profiles/paranoid.yaml +8 -0
  19. secator/configs/profiles/passive.yaml +11 -0
  20. secator/configs/profiles/polite.yaml +8 -0
  21. secator/configs/profiles/sneaky.yaml +8 -0
  22. secator/configs/profiles/tor.yaml +5 -0
  23. secator/configs/scans/__init__.py +0 -0
  24. secator/configs/scans/domain.yaml +31 -0
  25. secator/configs/scans/host.yaml +23 -0
  26. secator/configs/scans/network.yaml +30 -0
  27. secator/configs/scans/subdomain.yaml +27 -0
  28. secator/configs/scans/url.yaml +19 -0
  29. secator/configs/workflows/__init__.py +0 -0
  30. secator/configs/workflows/cidr_recon.yaml +48 -0
  31. secator/configs/workflows/code_scan.yaml +29 -0
  32. secator/configs/workflows/domain_recon.yaml +46 -0
  33. secator/configs/workflows/host_recon.yaml +95 -0
  34. secator/configs/workflows/subdomain_recon.yaml +120 -0
  35. secator/configs/workflows/url_bypass.yaml +15 -0
  36. secator/configs/workflows/url_crawl.yaml +98 -0
  37. secator/configs/workflows/url_dirsearch.yaml +62 -0
  38. secator/configs/workflows/url_fuzz.yaml +68 -0
  39. secator/configs/workflows/url_params_fuzz.yaml +66 -0
  40. secator/configs/workflows/url_secrets_hunt.yaml +23 -0
  41. secator/configs/workflows/url_vuln.yaml +91 -0
  42. secator/configs/workflows/user_hunt.yaml +29 -0
  43. secator/configs/workflows/wordpress.yaml +38 -0
  44. secator/cve.py +718 -0
  45. secator/decorators.py +7 -0
  46. secator/definitions.py +168 -0
  47. secator/exporters/__init__.py +14 -0
  48. secator/exporters/_base.py +3 -0
  49. secator/exporters/console.py +10 -0
  50. secator/exporters/csv.py +37 -0
  51. secator/exporters/gdrive.py +123 -0
  52. secator/exporters/json.py +16 -0
  53. secator/exporters/table.py +36 -0
  54. secator/exporters/txt.py +28 -0
  55. secator/hooks/__init__.py +0 -0
  56. secator/hooks/gcs.py +80 -0
  57. secator/hooks/mongodb.py +281 -0
  58. secator/installer.py +694 -0
  59. secator/loader.py +128 -0
  60. secator/output_types/__init__.py +49 -0
  61. secator/output_types/_base.py +108 -0
  62. secator/output_types/certificate.py +78 -0
  63. secator/output_types/domain.py +50 -0
  64. secator/output_types/error.py +42 -0
  65. secator/output_types/exploit.py +58 -0
  66. secator/output_types/info.py +24 -0
  67. secator/output_types/ip.py +47 -0
  68. secator/output_types/port.py +55 -0
  69. secator/output_types/progress.py +36 -0
  70. secator/output_types/record.py +36 -0
  71. secator/output_types/stat.py +41 -0
  72. secator/output_types/state.py +29 -0
  73. secator/output_types/subdomain.py +45 -0
  74. secator/output_types/tag.py +69 -0
  75. secator/output_types/target.py +38 -0
  76. secator/output_types/url.py +112 -0
  77. secator/output_types/user_account.py +41 -0
  78. secator/output_types/vulnerability.py +101 -0
  79. secator/output_types/warning.py +30 -0
  80. secator/report.py +140 -0
  81. secator/rich.py +130 -0
  82. secator/runners/__init__.py +14 -0
  83. secator/runners/_base.py +1240 -0
  84. secator/runners/_helpers.py +218 -0
  85. secator/runners/celery.py +18 -0
  86. secator/runners/command.py +1178 -0
  87. secator/runners/python.py +126 -0
  88. secator/runners/scan.py +87 -0
  89. secator/runners/task.py +81 -0
  90. secator/runners/workflow.py +168 -0
  91. secator/scans/__init__.py +29 -0
  92. secator/serializers/__init__.py +8 -0
  93. secator/serializers/dataclass.py +39 -0
  94. secator/serializers/json.py +45 -0
  95. secator/serializers/regex.py +25 -0
  96. secator/tasks/__init__.py +8 -0
  97. secator/tasks/_categories.py +487 -0
  98. secator/tasks/arjun.py +113 -0
  99. secator/tasks/arp.py +53 -0
  100. secator/tasks/arpscan.py +70 -0
  101. secator/tasks/bbot.py +372 -0
  102. secator/tasks/bup.py +118 -0
  103. secator/tasks/cariddi.py +193 -0
  104. secator/tasks/dalfox.py +87 -0
  105. secator/tasks/dirsearch.py +84 -0
  106. secator/tasks/dnsx.py +186 -0
  107. secator/tasks/feroxbuster.py +93 -0
  108. secator/tasks/ffuf.py +135 -0
  109. secator/tasks/fping.py +85 -0
  110. secator/tasks/gau.py +102 -0
  111. secator/tasks/getasn.py +60 -0
  112. secator/tasks/gf.py +36 -0
  113. secator/tasks/gitleaks.py +96 -0
  114. secator/tasks/gospider.py +84 -0
  115. secator/tasks/grype.py +109 -0
  116. secator/tasks/h8mail.py +75 -0
  117. secator/tasks/httpx.py +167 -0
  118. secator/tasks/jswhois.py +36 -0
  119. secator/tasks/katana.py +203 -0
  120. secator/tasks/maigret.py +87 -0
  121. secator/tasks/mapcidr.py +42 -0
  122. secator/tasks/msfconsole.py +179 -0
  123. secator/tasks/naabu.py +85 -0
  124. secator/tasks/nmap.py +487 -0
  125. secator/tasks/nuclei.py +151 -0
  126. secator/tasks/search_vulns.py +225 -0
  127. secator/tasks/searchsploit.py +109 -0
  128. secator/tasks/sshaudit.py +299 -0
  129. secator/tasks/subfinder.py +48 -0
  130. secator/tasks/testssl.py +283 -0
  131. secator/tasks/trivy.py +130 -0
  132. secator/tasks/trufflehog.py +240 -0
  133. secator/tasks/urlfinder.py +100 -0
  134. secator/tasks/wafw00f.py +106 -0
  135. secator/tasks/whois.py +34 -0
  136. secator/tasks/wpprobe.py +116 -0
  137. secator/tasks/wpscan.py +202 -0
  138. secator/tasks/x8.py +94 -0
  139. secator/tasks/xurlfind3r.py +83 -0
  140. secator/template.py +294 -0
  141. secator/thread.py +24 -0
  142. secator/tree.py +196 -0
  143. secator/utils.py +922 -0
  144. secator/utils_test.py +297 -0
  145. secator/workflows/__init__.py +29 -0
  146. secator-0.22.0.dist-info/METADATA +447 -0
  147. secator-0.22.0.dist-info/RECORD +150 -0
  148. secator-0.22.0.dist-info/WHEEL +4 -0
  149. secator-0.22.0.dist-info/entry_points.txt +2 -0
  150. secator-0.22.0.dist-info/licenses/LICENSE +60 -0
secator/installer.py ADDED
@@ -0,0 +1,694 @@
1
+ import distro
2
+ import getpass
3
+ import os
4
+ import platform
5
+ import re
6
+ import shutil
7
+ import tarfile
8
+ import zipfile
9
+ import io
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from enum import Enum
14
+ from pathlib import Path
15
+
16
+ import json
17
+ import requests
18
+
19
+ from rich.table import Table
20
+
21
+ from secator.config import CONFIG
22
+ from secator.celery import IN_CELERY_WORKER_PROCESS
23
+ from secator.definitions import OPT_NOT_SUPPORTED
24
+ from secator.output_types import Info, Warning, Error
25
+ from secator.rich import console
26
+ from secator.runners import Command
27
+ from secator.utils import debug, get_versions_from_string
28
+
29
+
30
+ class InstallerStatus(Enum):
31
+ SUCCESS = 'SUCCESS'
32
+ INSTALL_FAILED = 'INSTALL_FAILED'
33
+ INSTALL_NOT_SUPPORTED = 'INSTALL_NOT_SUPPORTED'
34
+ INSTALL_SKIPPED_OK = 'INSTALL_SKIPPED_OK'
35
+ INSTALL_VERSION_NOT_SPECIFIED = 'INSTALL_VERSION_NOT_SPECIFIED'
36
+ GITHUB_LATEST_RELEASE_NOT_FOUND = 'GITHUB_LATEST_RELEASE_NOT_FOUND'
37
+ GITHUB_RELEASE_NOT_FOUND = 'RELEASE_NOT_FOUND'
38
+ GITHUB_RELEASE_UNMATCHED_DISTRIBUTION = 'RELEASE_UNMATCHED_DISTRIBUTION'
39
+ GITHUB_RELEASE_FAILED_DOWNLOAD = 'GITHUB_RELEASE_FAILED_DOWNLOAD'
40
+ GITHUB_BINARY_NOT_FOUND_IN_ARCHIVE = 'GITHUB_BINARY_NOT_FOUND_IN_ARCHIVE'
41
+ UNKNOWN_DISTRIBUTION = 'UNKNOWN_DISTRIBUTION'
42
+ UNKNOWN = 'UNKNOWN'
43
+
44
+ def is_ok(self):
45
+ return self.value in ['SUCCESS', 'INSTALL_SKIPPED_OK']
46
+
47
+
48
+ @dataclass
49
+ class Distribution:
50
+ name: str
51
+ system: str
52
+ pm_name: str
53
+ pm_installer: str
54
+ pm_finalizer: str
55
+
56
+
57
+ class ToolInstaller:
58
+ status = InstallerStatus
59
+
60
+ @classmethod
61
+ def install(cls, tool_cls):
62
+ name = tool_cls.__name__
63
+ console.print(Info(message=f'Installing {name}'))
64
+ status = InstallerStatus.UNKNOWN
65
+
66
+ # Fail if not supported
67
+ if not any(_ for _ in [
68
+ tool_cls.install_pre,
69
+ tool_cls.github_handle,
70
+ tool_cls.install_cmd,
71
+ tool_cls.install_post]):
72
+ return InstallerStatus.INSTALL_NOT_SUPPORTED
73
+
74
+ # Check PATH
75
+ path_var = os.environ.get('PATH', '')
76
+ if str(CONFIG.dirs.bin) not in path_var:
77
+ console.print(Warning(message=f'Bin directory {CONFIG.dirs.bin} not found in PATH ! Binaries installed by secator will not work')) # noqa: E501
78
+ console.print(Warning(message=f'Run "export PATH=$PATH:{CONFIG.dirs.bin}" to add the binaries to your PATH'))
79
+
80
+ # Install pre-required packages
81
+ if tool_cls.install_pre:
82
+ status = PackageInstaller.install(tool_cls.install_pre)
83
+ if not status.is_ok():
84
+ cls.print_status(status, name)
85
+ return status
86
+
87
+ # Install binaries from GH
88
+ gh_status = InstallerStatus.UNKNOWN
89
+ install_ignore_bin = get_distro_config().name in tool_cls.install_ignore_bin
90
+ if tool_cls.github_handle and tool_cls.install_github_bin and not CONFIG.security.force_source_install and not install_ignore_bin: # noqa: E501
91
+ gh_status = GithubInstaller.install(
92
+ tool_cls.github_handle,
93
+ version=tool_cls.install_version or 'latest',
94
+ version_prefix=tool_cls.install_github_version_prefix
95
+ )
96
+ status = gh_status
97
+
98
+ # Install from source
99
+ if not gh_status.is_ok():
100
+ # Install pre-required packages
101
+ if tool_cls.install_cmd_pre:
102
+ status = PackageInstaller.install(tool_cls.install_cmd_pre)
103
+ if not status.is_ok():
104
+ cls.print_status(status, name)
105
+ return status
106
+ if not tool_cls.install_cmd:
107
+ status = InstallerStatus.INSTALL_SKIPPED_OK
108
+ else:
109
+ status = SourceInstaller.install(tool_cls.install_cmd, tool_cls.install_version)
110
+ if not status.is_ok():
111
+ cls.print_status(status, name)
112
+ return status
113
+
114
+ # Install post commands
115
+ if tool_cls.install_post:
116
+ post_status = SourceInstaller.install(tool_cls.install_post)
117
+ if not post_status.is_ok():
118
+ cls.print_status(post_status, name)
119
+ return post_status
120
+
121
+ cls.print_status(status, name)
122
+ return status
123
+
124
+ @classmethod
125
+ def print_status(cls, status, name):
126
+ if status.is_ok():
127
+ console.print(Info(message=f'{name} installed successfully!'))
128
+ elif status == InstallerStatus.INSTALL_NOT_SUPPORTED:
129
+ console.print(Error(message=f'{name} install is not supported yet. Please install manually'))
130
+ else:
131
+ console.print(Error(message=f'Failed to install {name}: {status}'))
132
+
133
+
134
+ class PackageInstaller:
135
+ """Install system packages."""
136
+
137
+ @classmethod
138
+ def install(cls, config):
139
+ """Install packages using the correct package manager based on the distribution.
140
+
141
+ Args:
142
+ config (dict): A dict of package managers as keys and a list of package names as values.
143
+
144
+ Returns:
145
+ InstallerStatus: installer status.
146
+ """
147
+ # Init status
148
+ distribution = get_distro_config()
149
+ if not distribution.pm_installer:
150
+ return InstallerStatus.UNKNOWN_DISTRIBUTION
151
+
152
+ console.print(
153
+ Info(message=f'Detected distribution "{distribution.name}", using package manager "{distribution.pm_name}"'))
154
+
155
+ # Construct package list
156
+ pkg_list = []
157
+ for managers, packages in config.items():
158
+ if distribution.pm_name in managers.split("|") or managers == '*':
159
+ pkg_list.extend(packages)
160
+ break
161
+
162
+ # Installer cmd
163
+ cmd = distribution.pm_installer
164
+ if CONFIG.security.auto_install_commands and IN_CELERY_WORKER_PROCESS:
165
+ cmd = f'flock /tmp/install.lock {cmd}'
166
+ if getpass.getuser() != 'root':
167
+ cmd = f'sudo {cmd}'
168
+
169
+ if pkg_list:
170
+ pkg_str = ''
171
+ for pkg in pkg_list:
172
+ if ':' in pkg:
173
+ pdistro, pkg = pkg.split(':')
174
+ if pdistro != distribution.name:
175
+ continue
176
+ pkg_str += f'{pkg} '
177
+ console.print(Info(message=f'Installing packages {pkg_str}'))
178
+ status = SourceInstaller.install(f'{cmd} {pkg_str}', install_prereqs=False)
179
+ if not status.is_ok():
180
+ return status
181
+ return InstallerStatus.SUCCESS
182
+
183
+
184
+ class SourceInstaller:
185
+ """Install a tool from source."""
186
+
187
+ @classmethod
188
+ def install(cls, config, version=None, install_prereqs=True):
189
+ """Install from source.
190
+
191
+ Args:
192
+ cls: ToolInstaller class.
193
+ config (dict): A dict of distros as keys and a command as value.
194
+ version (str, optional): Version to install.
195
+ install_prereqs (bool, optional): Install pre-requisites.
196
+
197
+ Returns:
198
+ Status: install status.
199
+ """
200
+ install_cmd = None
201
+ if isinstance(config, str):
202
+ install_cmd = config
203
+ else:
204
+ distribution = get_distro_config()
205
+ if not distribution.pm_installer:
206
+ return InstallerStatus.UNKNOWN_DISTRIBUTION
207
+ for distros, command in config.items():
208
+ if distribution.name in distros.split("|") or distros == '*':
209
+ install_cmd = command
210
+ break
211
+ if not install_cmd:
212
+ return InstallerStatus.INSTALL_SKIPPED_OK
213
+
214
+ # Install build dependencies if needed
215
+ if install_prereqs:
216
+ regex = re.compile(r'(cargo\s+|go\s+|gem\s+|git\s+)')
217
+ matches = regex.findall(install_cmd)
218
+ matches = list(set(matches))
219
+ for match in matches:
220
+ match = match.strip()
221
+ if match == 'cargo':
222
+ status = PackageInstaller.install({'*': ['curl']})
223
+ if not status.is_ok():
224
+ return status
225
+ rust_install_cmd = 'curl https://sh.rustup.rs -sSf | sh -s -- -y'
226
+ distribution = get_distro_config()
227
+ if not distribution.pm_installer:
228
+ return InstallerStatus.UNKNOWN_DISTRIBUTION
229
+ if distribution.pm_name == 'apk':
230
+ install_cmd = install_cmd.replace('cargo ', 'RUSTFLAGS="-Ctarget-feature=-crt-static" cargo ')
231
+ status = SourceInstaller.install(rust_install_cmd)
232
+ if not status.is_ok():
233
+ return status
234
+ if match == 'go':
235
+ status = PackageInstaller.install({'apt': ['golang-go'], '*': ['go']})
236
+ if not status.is_ok():
237
+ return status
238
+ if match == 'gem':
239
+ status = PackageInstaller.install({'apk': ['ruby', 'ruby-dev'], 'pacman': ['ruby', 'rubygems'], 'apt': ['ruby-full', 'rubygems']}) # noqa: E501
240
+ if not status.is_ok():
241
+ return status
242
+ if match == 'git':
243
+ status = PackageInstaller.install({'*': ['git']})
244
+ if not status.is_ok():
245
+ return status
246
+
247
+ # Handle version
248
+ if '[install_version]' in install_cmd:
249
+ version = version or 'latest'
250
+ install_cmd = install_cmd.replace('[install_version]', version)
251
+ elif '[install_version_strip]' in install_cmd:
252
+ version = version or 'latest'
253
+ install_cmd = install_cmd.replace('[install_version_strip]', version.lstrip('v'))
254
+
255
+ # Run command
256
+ ret = Command.execute(install_cmd, cls_attributes={'shell': True}, quiet=False)
257
+ return InstallerStatus.SUCCESS if ret.return_code == 0 else InstallerStatus.INSTALL_FAILED
258
+
259
+
260
+ class GithubInstaller:
261
+ """Install a tool from GitHub releases."""
262
+
263
+ @classmethod
264
+ def install(cls, github_handle, version='latest', version_prefix=''):
265
+ """Find and install a release from a GitHub handle {user}/{repo}.
266
+
267
+ Args:
268
+ github_handle (str): A GitHub handle {user}/{repo}
269
+
270
+ Returns:
271
+ InstallerStatus: status.
272
+ """
273
+ _, repo = tuple(github_handle.split('/'))
274
+ release = cls.get_release(github_handle, version=version, version_prefix=version_prefix)
275
+ if not release:
276
+ console.print(Warning(message=f'Could not find release {version} for {github_handle}.'))
277
+ return InstallerStatus.GITHUB_RELEASE_NOT_FOUND
278
+
279
+ # Find the right asset to download
280
+ system, arch, os_identifiers, arch_identifiers = cls._get_platform_identifier()
281
+ download_url = cls._find_matching_asset(release['assets'], os_identifiers, arch_identifiers)
282
+ if not download_url:
283
+ console.print(Warning(message=f'Could not find a GitHub release matching distribution (system: {system}, arch: {arch}).')) # noqa: E501
284
+ return InstallerStatus.GITHUB_RELEASE_UNMATCHED_DISTRIBUTION
285
+
286
+ # Download and unpack asset
287
+ console.print(Info(message=f'Found release URL: {download_url}'))
288
+ return cls._download_and_unpack(download_url, CONFIG.dirs.bin, repo)
289
+
290
+ @classmethod
291
+ def get_release(cls, github_handle, version='latest', version_prefix=''):
292
+ """Get release from GitHub.
293
+
294
+ Args:
295
+ github_handle (str): A GitHub handle {user}/{repo}.
296
+
297
+ Returns:
298
+ dict: Release JSON from GitHub releases.
299
+ """
300
+ if not github_handle:
301
+ return False
302
+ owner, repo = tuple(github_handle.split('/'))
303
+ if version == 'latest':
304
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
305
+ else:
306
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{version_prefix}{version}"
307
+ headers = {}
308
+ if CONFIG.cli.github_token:
309
+ headers['Authorization'] = f'Bearer {CONFIG.cli.github_token}'
310
+ try:
311
+ debug(f'Fetching release {version} from {url}', sub='installer')
312
+ response = requests.get(url, headers=headers, timeout=5)
313
+ response.raise_for_status()
314
+ latest_release = response.json()
315
+ return latest_release
316
+ except requests.RequestException as e:
317
+ console.print(Warning(message=f'Failed to fetch latest release for {github_handle}: {str(e)}'))
318
+ if 'rate limit exceeded' in str(e):
319
+ console.print(Warning(message='Consider setting env variable SECATOR_CLI_GITHUB_TOKEN or use secator config set cli.github_token $TOKEN.')) # noqa: E501
320
+ return None
321
+
322
+ @classmethod
323
+ def get_latest_version(cls, github_handle, version_prefix=None):
324
+ latest_release = cls.get_release(github_handle, version='latest', version_prefix=version_prefix)
325
+ if not latest_release:
326
+ return None
327
+ return latest_release['tag_name'].lstrip('v')
328
+
329
+ @classmethod
330
+ def _get_platform_identifier(cls):
331
+ """Generate lists of possible identifiers for the current platform."""
332
+ system = platform.system().lower()
333
+ arch = platform.machine().lower()
334
+
335
+ # Mapping common platform.system() values to those found in release names
336
+ os_mapping = {
337
+ 'linux': ['linux'],
338
+ 'windows': ['windows', 'win'],
339
+ 'darwin': ['darwin', 'macos', 'osx', 'mac']
340
+ }
341
+
342
+ # Enhanced architecture mapping to avoid conflicts
343
+ arch_mapping = {
344
+ 'x86_64': ['amd64', 'x86_64', '64bit', 'x64'],
345
+ 'amd64': ['amd64', 'x86_64', '64bit', 'x64'],
346
+ 'aarch64': ['arm64', 'aarch64'],
347
+ 'armv7l': ['armv7', 'arm'],
348
+ '386': ['386', 'x86', 'i386', '32bit', 'x32'],
349
+ }
350
+
351
+ os_identifiers = os_mapping.get(system, [])
352
+ arch_identifiers = arch_mapping.get(arch, [])
353
+ return system, arch, os_identifiers, arch_identifiers
354
+
355
+ @classmethod
356
+ def _find_matching_asset(cls, assets, os_identifiers, arch_identifiers):
357
+ """Find a release asset matching the current platform more precisely."""
358
+ potential_matches = []
359
+
360
+ for asset in assets:
361
+ asset_name = asset['name'].lower()
362
+ if any(os_id in asset_name for os_id in os_identifiers) and \
363
+ any(arch_id in asset_name for arch_id in arch_identifiers):
364
+ potential_matches.append(asset['browser_download_url'])
365
+
366
+ # Preference ordering for file formats, if needed
367
+ preferred_formats = ['.tar.gz', '.zip']
368
+
369
+ for format in preferred_formats:
370
+ for match in potential_matches:
371
+ if match.endswith(format):
372
+ return match
373
+
374
+ if potential_matches:
375
+ return potential_matches[0]
376
+
377
+ @classmethod
378
+ def _download_and_unpack(cls, url, destination, repo_name):
379
+ """Download and unpack a release asset.
380
+
381
+ Args:
382
+ cls (Runner): Task class.
383
+ url (str): GitHub release URL.
384
+ destination (str): Local destination.
385
+ repo_name (str): GitHub repository name.
386
+
387
+ Returns:
388
+ InstallerStatus: install status.
389
+ """
390
+ console.print(Info(message=f'Downloading and unpacking to {destination}...'))
391
+ response = requests.get(url, timeout=5)
392
+ if not response.status_code == 200:
393
+ return InstallerStatus.GITHUB_RELEASE_FAILED_DOWNLOAD
394
+
395
+ # Create a temporary directory to extract the archive
396
+ date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
397
+ temp_dir = os.path.join("/tmp", f'{repo_name}_{date_str}')
398
+ os.makedirs(temp_dir, exist_ok=True)
399
+
400
+ console.print(Info(message=f'Extracting binary to {temp_dir}...'))
401
+ if url.endswith('.zip'):
402
+ with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref:
403
+ zip_ref.extractall(temp_dir)
404
+ elif url.endswith('.tar.gz'):
405
+ with tarfile.open(fileobj=io.BytesIO(response.content), mode='r:gz') as tar:
406
+ tar.extractall(path=temp_dir)
407
+ else:
408
+ with Path(f'{temp_dir}/{repo_name}').open('wb') as f:
409
+ f.write(response.content)
410
+
411
+ # For archives, find and move the binary that matches the repo name
412
+ binary_path = cls._find_binary_in_directory(temp_dir, repo_name)
413
+ if binary_path:
414
+ os.chmod(binary_path, 0o755) # Make it executable
415
+ destination = os.path.join(destination, repo_name)
416
+ console.print(Info(message=f'Moving binary to {destination}...'))
417
+ shutil.move(binary_path, destination) # Move the binary
418
+ return InstallerStatus.SUCCESS
419
+ else:
420
+ console.print(Error(message='Binary matching the repository name was not found in the archive.'))
421
+ return InstallerStatus.GITHUB_BINARY_NOT_FOUND_IN_ARCHIVE
422
+
423
+ @classmethod
424
+ def _find_binary_in_directory(cls, directory, binary_name):
425
+ """Search for the binary in the given directory that matches the repository name."""
426
+ for root, _, files in os.walk(directory):
427
+ for file in files:
428
+ # Match the file name exactly with the repository name
429
+ if file.startswith(binary_name):
430
+ return os.path.join(root, file)
431
+ return None
432
+
433
+
434
+ def which(command):
435
+ """Run which on a command.
436
+
437
+ Args:
438
+ command (str): Command to check.
439
+
440
+ Returns:
441
+ secator.Command: Command instance.
442
+ """
443
+ return Command.execute(f'which {command}', quiet=True, print_errors=False)
444
+
445
+
446
+ def get_version(version_cmd):
447
+ """Run version command and match first version number found.
448
+
449
+ Args:
450
+ version_cmd (str): Command to get the version.
451
+
452
+ Returns:
453
+ tuple[str]: Version string, return code.
454
+ """
455
+ from secator.runners import Command
456
+ ret = Command.execute(version_cmd, quiet=True, print_errors=False)
457
+ versions = get_versions_from_string(ret.output)
458
+ if not versions:
459
+ return None
460
+ return versions[0]
461
+
462
+
463
+ def parse_version(ver):
464
+ from packaging import version as _version
465
+ try:
466
+ return _version.parse(ver)
467
+ except _version.InvalidVersion:
468
+ version_regex = re.compile(r'(\d+\.\d+(?:\.\d+)?)')
469
+ match = version_regex.search(ver)
470
+ if match:
471
+ return _version.parse(match.group(1))
472
+ return None
473
+
474
+
475
+ def get_version_info(name, version_flag=None, github_handle=None, install_github_version_prefix=None, install_cmd=None, install_version=None, version=None, bleeding=False): # noqa: E501
476
+ """Get version info for a command.
477
+
478
+ Args:
479
+ name (str): Command name.
480
+ version_flag (str): Version flag.
481
+ github_handle (str): Github handle.
482
+ install_github_version_prefix (str): Github version prefix.
483
+ install_cmd (str): Install command.
484
+ install_version (str): Install version.
485
+ version (str): Existing version.
486
+ bleeding (bool): Bleeding edge.
487
+
488
+ Return:
489
+ dict: Version info.
490
+ """
491
+ from secator.installer import GithubInstaller
492
+ info = {
493
+ 'name': name,
494
+ 'installed': False,
495
+ 'version': version,
496
+ 'version_cmd': None,
497
+ 'latest_version': None,
498
+ 'install_version': None,
499
+ 'location': None,
500
+ 'status': '',
501
+ 'outdated': False,
502
+ 'bleeding': False,
503
+ 'source': None,
504
+ 'errors': [],
505
+ }
506
+
507
+ # Get binary path
508
+ location = which(name).output
509
+ if not location or not Path(location).exists():
510
+ info['installed'] = False
511
+ info['status'] = 'missing'
512
+ return info
513
+ info['location'] = location
514
+ info['installed'] = True
515
+
516
+ # Get latest / recommanded version
517
+ latest_version = None
518
+ if install_version and not bleeding:
519
+ ver = parse_version(install_version)
520
+ info['latest_version'] = str(ver)
521
+ info['install_version'] = str(ver)
522
+ info['source'] = 'supported'
523
+ if ver:
524
+ latest_version = str(ver)
525
+ else:
526
+ latest_version = None
527
+ if not CONFIG.offline_mode:
528
+ if github_handle:
529
+ latest_version = GithubInstaller.get_latest_version(
530
+ github_handle,
531
+ version_prefix=install_github_version_prefix,
532
+ )
533
+ info['latest_version'] = latest_version
534
+ info['source'] = 'github'
535
+ elif install_cmd and install_cmd.startswith('pip'):
536
+ req = requests.get(f'https://pypi.python.org/pypi/{name}/json')
537
+ version = parse_version('0')
538
+ if req.status_code == requests.codes.ok:
539
+ j = json.loads(req.text.encode(req.encoding))
540
+ releases = j.get('releases', [])
541
+ for release in releases:
542
+ ver = parse_version(release)
543
+ if ver and not ver.is_prerelease and not ver.is_postrelease and not ver.is_devrelease:
544
+ version = max(version, ver)
545
+ latest_version = str(version)
546
+ info['source'] = 'pypi'
547
+ version = str(version) if version else None
548
+ else:
549
+ info['errors'].append('Cannot get latest version for query method (github, pip) is available')
550
+ info['latest_version'] = f'v{latest_version}' if install_version and install_version.startswith('v') else latest_version # noqa: E501
551
+
552
+ # Get current version
553
+ version_flag = None if version_flag == OPT_NOT_SUPPORTED else version_flag
554
+ if version_flag and not version:
555
+ version_cmd = f'{name} {version_flag}'
556
+ info['version_cmd'] = version_cmd
557
+ version = get_version(version_cmd)
558
+ info['version'] = version
559
+ if not version:
560
+ info['errors'].append(f'Error fetching version for command. Version command: {version_cmd}')
561
+ info['status'] = 'version fetch error'
562
+ return info
563
+
564
+ # Check if up-to-date
565
+ if version and latest_version:
566
+ outdated = parse_version(version) < parse_version(latest_version)
567
+ equal = parse_version(version) == parse_version(latest_version)
568
+ if outdated:
569
+ info['status'] = 'outdated'
570
+ info['outdated'] = True
571
+ elif equal:
572
+ info['status'] = 'latest'
573
+ else:
574
+ info['status'] = 'bleeding'
575
+ info['bleeding'] = True
576
+ if install_version:
577
+ info['errors'].append(f'Version {version} is greather than the recommended version {latest_version}')
578
+ else:
579
+ info['errors'].append(f'Version {version} is greather than the latest version {latest_version}')
580
+ elif not version:
581
+ info['status'] = 'current unknown'
582
+ elif not latest_version:
583
+ info['status'] = 'latest unknown'
584
+ if CONFIG.offline_mode:
585
+ info['status'] += r' [dim orange1]\[offline][/]'
586
+
587
+ return info
588
+
589
+
590
+ def get_distro_config():
591
+ """Detects the system's package manager based on the OS distribution and return the default installation command."""
592
+
593
+ # If explicitely set by the user, use that one
594
+ package_manager_variable = os.environ.get('SECATOR_PACKAGE_MANAGER')
595
+ if package_manager_variable:
596
+ return package_manager_variable
597
+ installer = None
598
+ finalizer = None
599
+ system = platform.system()
600
+ distrib = system
601
+
602
+ if system == "Linux":
603
+ distrib = distro.like() or distro.id()
604
+ distrib = distrib.split(' ')[0] if distrib else None
605
+
606
+ if distrib in ["ubuntu", "debian", "linuxmint", "popos", "kali"]:
607
+ installer = "apt install -y --no-install-recommends"
608
+ finalizer = "rm -rf /var/lib/apt/lists/*"
609
+ elif distrib in ["arch", "manjaro", "endeavouros"]:
610
+ installer = "pacman -S --noconfirm --needed"
611
+ elif distrib in ["alpine"]:
612
+ installer = "apk add --no-cache"
613
+ elif distrib in ["fedora"]:
614
+ installer = "dnf install -y"
615
+ finalizer = "dnf clean all"
616
+ elif distrib in ["centos", "rhel", "rocky", "alma"]:
617
+ installer = "yum -y"
618
+ finalizer = "yum clean all"
619
+ elif distrib in ["opensuse", "sles"]:
620
+ installer = "zypper -n"
621
+ finalizer = "zypper clean --all"
622
+
623
+ elif system == "Darwin": # macOS
624
+ installer = "brew install"
625
+
626
+ elif system == "Windows":
627
+ if shutil.which("winget"):
628
+ installer = "winget install --disable-interactivity"
629
+ elif shutil.which("choco"):
630
+ installer = "choco install -y --no-progress"
631
+ else:
632
+ installer = "scoop" # Alternative package manager for Windows
633
+
634
+ if not installer:
635
+ console.print(Error(message=f'Could not find installer for your distribution (system: {system}, distrib: {distrib})')) # noqa: E501
636
+
637
+ manager = installer.split(' ')[0] if installer else ''
638
+ config = Distribution(
639
+ pm_installer=installer,
640
+ pm_finalizer=finalizer,
641
+ pm_name=manager,
642
+ name=distrib,
643
+ system=system
644
+ )
645
+ return config
646
+
647
+
648
+ def fmt_health_table_row(version_info, category=None):
649
+ name = version_info['name']
650
+ version = version_info['version']
651
+ if version:
652
+ version = version.lstrip('v')
653
+ status = version_info['status']
654
+ installed = version_info['installed']
655
+ latest_version = version_info['latest_version']
656
+ if latest_version:
657
+ latest_version = latest_version.lstrip('v')
658
+ source = version_info.get('source')
659
+ name_str = f'[magenta]{name:<13}[/]'
660
+
661
+ # Format version row
662
+ _version = version or ''
663
+ _version = f'[bold green]{_version:<10}[/]'
664
+ if status == 'latest':
665
+ _version += f' [bold green](latest {source})[/]'
666
+ elif status == 'bleeding':
667
+ msg = f'bleeding >{latest_version} {source}' if source else f'bleeding >{latest_version}'
668
+ _version += f' [bold orange1]({msg})[/]'
669
+ elif status == 'outdated':
670
+ _version += ' [bold red](outdated)[/]'
671
+ if latest_version:
672
+ _version += f' [dim](<{latest_version} {source})[/]'
673
+ elif status == 'missing':
674
+ _version = '[bold red]missing[/]'
675
+ elif status == 'missing_ok':
676
+ _version = '[dim green]not installed [/]'
677
+ elif status == 'ok':
678
+ _version = '[bold green]ok [/]'
679
+ elif status == 'version fetch error':
680
+ _version = '[bold orange1]unknown[/] [dim](current unknown)[/]'
681
+ elif status:
682
+ if not version and installed:
683
+ _version = '[bold green]ok [/]'
684
+ _version += f' [dim]({status}[/])'
685
+
686
+ row = (name_str, _version)
687
+ return row
688
+
689
+
690
+ def get_health_table():
691
+ table = Table(box=None, show_header=False)
692
+ for col in ['name', 'version']:
693
+ table.add_column(col)
694
+ return table