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.
- secator/.gitignore +162 -0
- secator/__init__.py +0 -0
- secator/celery.py +453 -0
- secator/celery_signals.py +138 -0
- secator/celery_utils.py +320 -0
- secator/cli.py +2035 -0
- secator/cli_helper.py +395 -0
- secator/click.py +87 -0
- secator/config.py +670 -0
- secator/configs/__init__.py +0 -0
- secator/configs/profiles/__init__.py +0 -0
- secator/configs/profiles/aggressive.yaml +8 -0
- secator/configs/profiles/all_ports.yaml +7 -0
- secator/configs/profiles/full.yaml +31 -0
- secator/configs/profiles/http_headless.yaml +7 -0
- secator/configs/profiles/http_record.yaml +8 -0
- secator/configs/profiles/insane.yaml +8 -0
- secator/configs/profiles/paranoid.yaml +8 -0
- secator/configs/profiles/passive.yaml +11 -0
- secator/configs/profiles/polite.yaml +8 -0
- secator/configs/profiles/sneaky.yaml +8 -0
- secator/configs/profiles/tor.yaml +5 -0
- secator/configs/scans/__init__.py +0 -0
- secator/configs/scans/domain.yaml +31 -0
- secator/configs/scans/host.yaml +23 -0
- secator/configs/scans/network.yaml +30 -0
- secator/configs/scans/subdomain.yaml +27 -0
- secator/configs/scans/url.yaml +19 -0
- secator/configs/workflows/__init__.py +0 -0
- secator/configs/workflows/cidr_recon.yaml +48 -0
- secator/configs/workflows/code_scan.yaml +29 -0
- secator/configs/workflows/domain_recon.yaml +46 -0
- secator/configs/workflows/host_recon.yaml +95 -0
- secator/configs/workflows/subdomain_recon.yaml +120 -0
- secator/configs/workflows/url_bypass.yaml +15 -0
- secator/configs/workflows/url_crawl.yaml +98 -0
- secator/configs/workflows/url_dirsearch.yaml +62 -0
- secator/configs/workflows/url_fuzz.yaml +68 -0
- secator/configs/workflows/url_params_fuzz.yaml +66 -0
- secator/configs/workflows/url_secrets_hunt.yaml +23 -0
- secator/configs/workflows/url_vuln.yaml +91 -0
- secator/configs/workflows/user_hunt.yaml +29 -0
- secator/configs/workflows/wordpress.yaml +38 -0
- secator/cve.py +718 -0
- secator/decorators.py +7 -0
- secator/definitions.py +168 -0
- secator/exporters/__init__.py +14 -0
- secator/exporters/_base.py +3 -0
- secator/exporters/console.py +10 -0
- secator/exporters/csv.py +37 -0
- secator/exporters/gdrive.py +123 -0
- secator/exporters/json.py +16 -0
- secator/exporters/table.py +36 -0
- secator/exporters/txt.py +28 -0
- secator/hooks/__init__.py +0 -0
- secator/hooks/gcs.py +80 -0
- secator/hooks/mongodb.py +281 -0
- secator/installer.py +694 -0
- secator/loader.py +128 -0
- secator/output_types/__init__.py +49 -0
- secator/output_types/_base.py +108 -0
- secator/output_types/certificate.py +78 -0
- secator/output_types/domain.py +50 -0
- secator/output_types/error.py +42 -0
- secator/output_types/exploit.py +58 -0
- secator/output_types/info.py +24 -0
- secator/output_types/ip.py +47 -0
- secator/output_types/port.py +55 -0
- secator/output_types/progress.py +36 -0
- secator/output_types/record.py +36 -0
- secator/output_types/stat.py +41 -0
- secator/output_types/state.py +29 -0
- secator/output_types/subdomain.py +45 -0
- secator/output_types/tag.py +69 -0
- secator/output_types/target.py +38 -0
- secator/output_types/url.py +112 -0
- secator/output_types/user_account.py +41 -0
- secator/output_types/vulnerability.py +101 -0
- secator/output_types/warning.py +30 -0
- secator/report.py +140 -0
- secator/rich.py +130 -0
- secator/runners/__init__.py +14 -0
- secator/runners/_base.py +1240 -0
- secator/runners/_helpers.py +218 -0
- secator/runners/celery.py +18 -0
- secator/runners/command.py +1178 -0
- secator/runners/python.py +126 -0
- secator/runners/scan.py +87 -0
- secator/runners/task.py +81 -0
- secator/runners/workflow.py +168 -0
- secator/scans/__init__.py +29 -0
- secator/serializers/__init__.py +8 -0
- secator/serializers/dataclass.py +39 -0
- secator/serializers/json.py +45 -0
- secator/serializers/regex.py +25 -0
- secator/tasks/__init__.py +8 -0
- secator/tasks/_categories.py +487 -0
- secator/tasks/arjun.py +113 -0
- secator/tasks/arp.py +53 -0
- secator/tasks/arpscan.py +70 -0
- secator/tasks/bbot.py +372 -0
- secator/tasks/bup.py +118 -0
- secator/tasks/cariddi.py +193 -0
- secator/tasks/dalfox.py +87 -0
- secator/tasks/dirsearch.py +84 -0
- secator/tasks/dnsx.py +186 -0
- secator/tasks/feroxbuster.py +93 -0
- secator/tasks/ffuf.py +135 -0
- secator/tasks/fping.py +85 -0
- secator/tasks/gau.py +102 -0
- secator/tasks/getasn.py +60 -0
- secator/tasks/gf.py +36 -0
- secator/tasks/gitleaks.py +96 -0
- secator/tasks/gospider.py +84 -0
- secator/tasks/grype.py +109 -0
- secator/tasks/h8mail.py +75 -0
- secator/tasks/httpx.py +167 -0
- secator/tasks/jswhois.py +36 -0
- secator/tasks/katana.py +203 -0
- secator/tasks/maigret.py +87 -0
- secator/tasks/mapcidr.py +42 -0
- secator/tasks/msfconsole.py +179 -0
- secator/tasks/naabu.py +85 -0
- secator/tasks/nmap.py +487 -0
- secator/tasks/nuclei.py +151 -0
- secator/tasks/search_vulns.py +225 -0
- secator/tasks/searchsploit.py +109 -0
- secator/tasks/sshaudit.py +299 -0
- secator/tasks/subfinder.py +48 -0
- secator/tasks/testssl.py +283 -0
- secator/tasks/trivy.py +130 -0
- secator/tasks/trufflehog.py +240 -0
- secator/tasks/urlfinder.py +100 -0
- secator/tasks/wafw00f.py +106 -0
- secator/tasks/whois.py +34 -0
- secator/tasks/wpprobe.py +116 -0
- secator/tasks/wpscan.py +202 -0
- secator/tasks/x8.py +94 -0
- secator/tasks/xurlfind3r.py +83 -0
- secator/template.py +294 -0
- secator/thread.py +24 -0
- secator/tree.py +196 -0
- secator/utils.py +922 -0
- secator/utils_test.py +297 -0
- secator/workflows/__init__.py +29 -0
- secator-0.22.0.dist-info/METADATA +447 -0
- secator-0.22.0.dist-info/RECORD +150 -0
- secator-0.22.0.dist-info/WHEEL +4 -0
- secator-0.22.0.dist-info/entry_points.txt +2 -0
- 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
|