gyntoolkit 2.1.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.
- gyntoolkit/__init__.py +80 -0
- gyntoolkit/__main__.py +7 -0
- gyntoolkit/brute.py +179 -0
- gyntoolkit/cli.py +451 -0
- gyntoolkit/config.py +109 -0
- gyntoolkit/core.py +86 -0
- gyntoolkit/export.py +139 -0
- gyntoolkit/i18n.py +487 -0
- gyntoolkit/recon.py +358 -0
- gyntoolkit/scan.py +150 -0
- gyntoolkit/ui.py +132 -0
- gyntoolkit/utils.py +61 -0
- gyntoolkit-2.1.1.dist-info/METADATA +386 -0
- gyntoolkit-2.1.1.dist-info/RECORD +18 -0
- gyntoolkit-2.1.1.dist-info/WHEEL +5 -0
- gyntoolkit-2.1.1.dist-info/entry_points.txt +2 -0
- gyntoolkit-2.1.1.dist-info/licenses/LICENSE +21 -0
- gyntoolkit-2.1.1.dist-info/top_level.txt +1 -0
gyntoolkit/recon.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Recon passivo e ativo: WHOIS, DNS, geo, TLS, fingerprint, breaches, traceroute."""
|
|
3
|
+
|
|
4
|
+
import ipaddress
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import dns.resolver
|
|
12
|
+
import requests
|
|
13
|
+
import whois
|
|
14
|
+
from colorama import Fore, Style
|
|
15
|
+
from scapy.all import IP, TCP, sr1
|
|
16
|
+
|
|
17
|
+
from . import i18n
|
|
18
|
+
from .core import PROMPT, log, sanitize_input
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def whois_lookup(domain: str):
|
|
22
|
+
"""Consulta informações WHOIS"""
|
|
23
|
+
try:
|
|
24
|
+
domain = sanitize_input(domain, r"[A-Za-z0-9.-]")
|
|
25
|
+
return whois.whois(domain)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
return i18n.t("recon.whois_err", err=e)
|
|
28
|
+
|
|
29
|
+
def dns_lookup(domain: str,
|
|
30
|
+
record_type: str = "A",
|
|
31
|
+
nameserver: str = None,
|
|
32
|
+
timeout: int = 5) -> list[str]:
|
|
33
|
+
"""
|
|
34
|
+
Consulta registros DNS de um domínio.
|
|
35
|
+
:param domain: domínio a ser consultado
|
|
36
|
+
:param record_type: tipo de registro (A, AAAA, MX, TXT, CNAME, NS, SOA, etc.)
|
|
37
|
+
:param nameserver: servidor DNS (ex: "8.8.8.8"); None para usar o default
|
|
38
|
+
:param timeout: tempo máximo de espera, em segundos
|
|
39
|
+
:return: lista de strings com os dados retornados
|
|
40
|
+
"""
|
|
41
|
+
resolver = dns.resolver.Resolver()
|
|
42
|
+
resolver.lifetime = timeout
|
|
43
|
+
if nameserver:
|
|
44
|
+
resolver.nameservers = [nameserver]
|
|
45
|
+
try:
|
|
46
|
+
answers = resolver.resolve(domain, record_type)
|
|
47
|
+
return [rdata.to_text() for rdata in answers]
|
|
48
|
+
except dns.resolver.NoAnswer:
|
|
49
|
+
return [i18n.t("dns.no_record", t=record_type, d=domain)]
|
|
50
|
+
except dns.resolver.NXDOMAIN:
|
|
51
|
+
return [i18n.t("dns.nxdomain", d=domain)]
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return [i18n.t("dns.error", err=e)]
|
|
54
|
+
|
|
55
|
+
def geo_ip(target: str) -> dict[str, str]:
|
|
56
|
+
"""Geolocalização de IP/host via ip-api.com (free tier, sem key)."""
|
|
57
|
+
try:
|
|
58
|
+
ip = socket.gethostbyname(target)
|
|
59
|
+
except socket.gaierror as e:
|
|
60
|
+
return {"erro": i18n.t("recon.resolve_fail", t=target, err=e)}
|
|
61
|
+
|
|
62
|
+
fields = "status,message,continent,country,regionName,city,zip,lat,lon,timezone,isp,org,as,reverse,mobile,proxy,hosting,query"
|
|
63
|
+
try:
|
|
64
|
+
response = requests.get(
|
|
65
|
+
f"http://ip-api.com/json/{ip}",
|
|
66
|
+
params={"fields": fields},
|
|
67
|
+
headers={"User-Agent": "gyntoolkit/2.0"},
|
|
68
|
+
timeout=10,
|
|
69
|
+
)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
data = response.json()
|
|
72
|
+
if data.get("status") != "success":
|
|
73
|
+
return {"erro": data.get("message", i18n.t("recon.query_failed"))}
|
|
74
|
+
return data
|
|
75
|
+
except (requests.RequestException, ValueError) as e:
|
|
76
|
+
log.warning("geo_ip %s falhou: %s", target, e)
|
|
77
|
+
return {"erro": str(e)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def reverse_dns(ip: str) -> dict[str, Any]:
|
|
81
|
+
"""PTR lookup via socket + dnspython fallback."""
|
|
82
|
+
try:
|
|
83
|
+
ipaddress.ip_address(ip)
|
|
84
|
+
except ValueError:
|
|
85
|
+
try:
|
|
86
|
+
ip = socket.gethostbyname(ip)
|
|
87
|
+
except socket.gaierror as e:
|
|
88
|
+
return {"erro": i18n.t("recon.resolve_fail", t=ip, err=e)}
|
|
89
|
+
try:
|
|
90
|
+
hostname, aliases, addrs = socket.gethostbyaddr(ip)
|
|
91
|
+
return {"ip": ip, "hostname": hostname, "aliases": aliases, "addrs": addrs}
|
|
92
|
+
except socket.herror as e:
|
|
93
|
+
log.debug("reverse_dns %s falhou: %s", ip, e)
|
|
94
|
+
return {"ip": ip, "erro": i18n.t("recon.no_ptr", err=e)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def subdomain_enum(domain: str, timeout: int = 30) -> list[str]:
|
|
98
|
+
"""Enumeração de subdomínios via crt.sh Certificate Transparency."""
|
|
99
|
+
try:
|
|
100
|
+
response = requests.get(
|
|
101
|
+
f"https://crt.sh/?q=%.{domain}&output=json",
|
|
102
|
+
headers={"User-Agent": "gyntoolkit/2.0"},
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
entries = response.json()
|
|
107
|
+
except (requests.RequestException, ValueError) as e:
|
|
108
|
+
log.warning("crt.sh falhou para %s: %s", domain, e)
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
subs = set()
|
|
112
|
+
for entry in entries:
|
|
113
|
+
name_value = entry.get("name_value", "")
|
|
114
|
+
for name in name_value.split("\n"):
|
|
115
|
+
name = name.strip().lstrip("*.").lower()
|
|
116
|
+
if name and name.endswith(domain.lower()):
|
|
117
|
+
subs.add(name)
|
|
118
|
+
return sorted(subs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_cert_der(der: bytes) -> dict[str, Any]:
|
|
122
|
+
"""Parse cert DER usando cryptography (parseamento robusto sem depender de validação TLS)."""
|
|
123
|
+
from cryptography import x509
|
|
124
|
+
from cryptography.hazmat.primitives import hashes as crypto_hashes
|
|
125
|
+
|
|
126
|
+
cert = x509.load_der_x509_certificate(der)
|
|
127
|
+
|
|
128
|
+
def _name_attr(name_obj, oid):
|
|
129
|
+
try:
|
|
130
|
+
attrs = name_obj.get_attributes_for_oid(oid)
|
|
131
|
+
return attrs[0].value if attrs else None
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
subject_cn = _name_attr(cert.subject, x509.NameOID.COMMON_NAME)
|
|
136
|
+
subject_org = _name_attr(cert.subject, x509.NameOID.ORGANIZATION_NAME)
|
|
137
|
+
issuer_cn = _name_attr(cert.issuer, x509.NameOID.COMMON_NAME)
|
|
138
|
+
|
|
139
|
+
sans: list[str] = []
|
|
140
|
+
try:
|
|
141
|
+
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
|
142
|
+
sans = [str(n.value) for n in san_ext.value]
|
|
143
|
+
except x509.ExtensionNotFound:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
not_before = cert.not_valid_before_utc
|
|
147
|
+
not_after = cert.not_valid_after_utc
|
|
148
|
+
days_left = (not_after - datetime.now(timezone.utc)).days
|
|
149
|
+
|
|
150
|
+
fingerprint = cert.fingerprint(crypto_hashes.SHA256()).hex()
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"subject_cn": subject_cn,
|
|
154
|
+
"issuer_cn": issuer_cn,
|
|
155
|
+
"org": subject_org,
|
|
156
|
+
"not_before": not_before.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
157
|
+
"not_after": not_after.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
158
|
+
"dias_para_expirar": days_left,
|
|
159
|
+
"sans": sans,
|
|
160
|
+
"sha256": fingerprint,
|
|
161
|
+
"serial": format(cert.serial_number, "x"),
|
|
162
|
+
"signature_algo": cert.signature_algorithm_oid._name,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def ssl_inspect(host: str, port: int = 443, timeout: int = 8) -> dict[str, Any]:
|
|
167
|
+
"""Inspeciona certificado SSL/TLS do alvo (aceita cert inválido, apenas inspeciona)."""
|
|
168
|
+
ctx = ssl.create_default_context()
|
|
169
|
+
ctx.check_hostname = False
|
|
170
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
171
|
+
try:
|
|
172
|
+
with (
|
|
173
|
+
socket.create_connection((host, port), timeout=timeout) as sock,
|
|
174
|
+
ctx.wrap_socket(sock, server_hostname=host) as tls,
|
|
175
|
+
):
|
|
176
|
+
der = tls.getpeercert(binary_form=True)
|
|
177
|
+
cipher = tls.cipher()
|
|
178
|
+
version = tls.version()
|
|
179
|
+
except (TimeoutError, socket.gaierror, ConnectionRefusedError, OSError, ssl.SSLError) as e:
|
|
180
|
+
return {"erro": i18n.t("recon.ssl_fail", h=host, p=port, err=e)}
|
|
181
|
+
|
|
182
|
+
if not der:
|
|
183
|
+
return {"erro": i18n.t("recon.no_cert")}
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
parsed = _parse_cert_der(der)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
log.warning("Falha ao parsear cert DER: %s", e)
|
|
189
|
+
return {"erro": i18n.t("recon.cert_parse_fail", err=e)}
|
|
190
|
+
|
|
191
|
+
parsed.update({
|
|
192
|
+
"host": host,
|
|
193
|
+
"porta": port,
|
|
194
|
+
"tls_version": version,
|
|
195
|
+
"cipher": cipher[0] if cipher else None,
|
|
196
|
+
})
|
|
197
|
+
return parsed
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
TECH_SIGNATURES = [
|
|
201
|
+
("nginx", r"nginx"),
|
|
202
|
+
("Apache", r"Apache"),
|
|
203
|
+
("IIS", r"Microsoft-IIS"),
|
|
204
|
+
("Cloudflare", r"cloudflare"),
|
|
205
|
+
("PHP", r"PHP/"),
|
|
206
|
+
("Express", r"Express"),
|
|
207
|
+
("Django", r"csrftoken|Django"),
|
|
208
|
+
("Rails", r"_rails_|Phusion"),
|
|
209
|
+
("WordPress", r"wp-content|wp-includes"),
|
|
210
|
+
("Laravel", r"laravel_session"),
|
|
211
|
+
("ASP.NET", r"ASP\.NET|AspNet"),
|
|
212
|
+
("Node.js", r"X-Powered-By.*Express|Node"),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def http_fingerprint(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
217
|
+
"""Detecta tecnologias via headers HTTP + cookies + body snippet."""
|
|
218
|
+
try:
|
|
219
|
+
response = requests.get(
|
|
220
|
+
url,
|
|
221
|
+
headers={"User-Agent": "Mozilla/5.0 gyntoolkit/2.0"},
|
|
222
|
+
timeout=timeout,
|
|
223
|
+
allow_redirects=True,
|
|
224
|
+
verify=False,
|
|
225
|
+
)
|
|
226
|
+
except requests.RequestException as e:
|
|
227
|
+
return {"erro": i18n.t("recon.http_fail", err=e)}
|
|
228
|
+
|
|
229
|
+
headers = dict(response.headers)
|
|
230
|
+
body_snippet = response.text[:8192]
|
|
231
|
+
haystack = "\n".join([f"{k}: {v}" for k, v in headers.items()]) + "\n" + body_snippet
|
|
232
|
+
|
|
233
|
+
techs = [name for name, pattern in TECH_SIGNATURES if re.search(pattern, haystack, re.IGNORECASE)]
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"url": response.url,
|
|
237
|
+
"status": response.status_code,
|
|
238
|
+
"redirects": len(response.history),
|
|
239
|
+
"server": headers.get("Server"),
|
|
240
|
+
"x_powered_by": headers.get("X-Powered-By"),
|
|
241
|
+
"content_type": headers.get("Content-Type"),
|
|
242
|
+
"set_cookie": headers.get("Set-Cookie"),
|
|
243
|
+
"hsts": headers.get("Strict-Transport-Security"),
|
|
244
|
+
"csp": headers.get("Content-Security-Policy"),
|
|
245
|
+
"xfo": headers.get("X-Frame-Options"),
|
|
246
|
+
"tech": techs,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def internetdb_lookup(ip: str, timeout: int = 10) -> dict[str, Any]:
|
|
251
|
+
"""Consulta InternetDB (Shodan free tier, sem key)."""
|
|
252
|
+
try:
|
|
253
|
+
socket.inet_aton(ip)
|
|
254
|
+
except OSError:
|
|
255
|
+
try:
|
|
256
|
+
ip = socket.gethostbyname(ip)
|
|
257
|
+
except socket.gaierror as e:
|
|
258
|
+
return {"erro": i18n.t("recon.resolve_fail_simple", err=e)}
|
|
259
|
+
try:
|
|
260
|
+
response = requests.get(
|
|
261
|
+
f"https://internetdb.shodan.io/{ip}",
|
|
262
|
+
headers={"User-Agent": "gyntoolkit/2.0"},
|
|
263
|
+
timeout=timeout,
|
|
264
|
+
)
|
|
265
|
+
if response.status_code == 404:
|
|
266
|
+
return {"ip": ip, "info": i18n.t("recon.internetdb_nodata")}
|
|
267
|
+
response.raise_for_status()
|
|
268
|
+
return response.json()
|
|
269
|
+
except (requests.RequestException, ValueError) as e:
|
|
270
|
+
log.warning("InternetDB %s falhou: %s", ip, e)
|
|
271
|
+
return {"erro": str(e)}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def hibp_breaches(domain: str, timeout: int = 10) -> list[dict[str, Any]]:
|
|
275
|
+
"""Consulta HIBP breaches por domínio (endpoint público)."""
|
|
276
|
+
try:
|
|
277
|
+
response = requests.get(
|
|
278
|
+
"https://haveibeenpwned.com/api/v3/breaches",
|
|
279
|
+
params={"domain": domain},
|
|
280
|
+
headers={"User-Agent": "gyntoolkit-recon"},
|
|
281
|
+
timeout=timeout,
|
|
282
|
+
)
|
|
283
|
+
if response.status_code == 404:
|
|
284
|
+
return []
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
return response.json()
|
|
287
|
+
except (requests.RequestException, ValueError) as e:
|
|
288
|
+
log.warning("HIBP falhou para %s: %s", domain, e)
|
|
289
|
+
return [{"erro": str(e)}]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def mac_vendor(mac: str, timeout: int = 6) -> str:
|
|
293
|
+
"""Lookup fabricante via api.macvendors.com (free, no key)."""
|
|
294
|
+
mac = mac.strip().replace("-", ":").upper()
|
|
295
|
+
if not re.fullmatch(r"([0-9A-F]{2}:){5}[0-9A-F]{2}", mac):
|
|
296
|
+
return i18n.t("recon.mac_invalid", mac=mac)
|
|
297
|
+
try:
|
|
298
|
+
response = requests.get(f"https://api.macvendors.com/{mac}", timeout=timeout)
|
|
299
|
+
if response.status_code == 200:
|
|
300
|
+
return response.text.strip()
|
|
301
|
+
return i18n.t("recon.not_found_http", code=response.status_code)
|
|
302
|
+
except requests.RequestException as e:
|
|
303
|
+
return i18n.t("recon.error", err=e)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def traceroute(target: str, max_hops: int = 20, timeout: int = 3, dport: int = 80) -> list[dict[str, Any]]:
|
|
307
|
+
"""Traceroute TCP via scapy (requer privilégio)."""
|
|
308
|
+
try:
|
|
309
|
+
target_ip = socket.gethostbyname(target)
|
|
310
|
+
except socket.gaierror as e:
|
|
311
|
+
return [{"erro": i18n.t("recon.resolve_fail_simple", err=e)}]
|
|
312
|
+
|
|
313
|
+
hops = []
|
|
314
|
+
for ttl in range(1, max_hops + 1):
|
|
315
|
+
pkt = IP(dst=target_ip, ttl=ttl) / TCP(dport=dport, flags="S")
|
|
316
|
+
start = datetime.now(timezone.utc)
|
|
317
|
+
try:
|
|
318
|
+
reply = sr1(pkt, timeout=timeout, verbose=0)
|
|
319
|
+
except PermissionError:
|
|
320
|
+
return [{"erro": i18n.t("recon.traceroute_priv")}]
|
|
321
|
+
except OSError as e:
|
|
322
|
+
return [{"erro": i18n.t("recon.scapy_err", err=e)}]
|
|
323
|
+
rtt_ms = (datetime.now(timezone.utc) - start).total_seconds() * 1000
|
|
324
|
+
|
|
325
|
+
if reply is None:
|
|
326
|
+
hops.append({"ttl": ttl, "ip": "*", "rtt_ms": None})
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
hops.append({"ttl": ttl, "ip": reply.src, "rtt_ms": round(rtt_ms, 2)})
|
|
330
|
+
if reply.haslayer(TCP) and reply.getlayer(TCP).flags & 0x12:
|
|
331
|
+
break
|
|
332
|
+
if reply.haslayer("ICMP") and reply.getlayer("ICMP").type == 3:
|
|
333
|
+
break
|
|
334
|
+
return hops
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Tipos DNS oferecidos no menu (ordem fixa). As descrições são localizadas
|
|
338
|
+
# em runtime via i18n.t('dns.desc.<tipo>'), não armazenadas aqui.
|
|
339
|
+
DNS_TYPES = ["A", "AAAA", "MX", "NS", "CNAME", "TXT", "SOA"]
|
|
340
|
+
|
|
341
|
+
def escolher_tipo_dns() -> str:
|
|
342
|
+
"""Mostra menu e retorna o tipo DNS escolhido"""
|
|
343
|
+
print(f"\n{Fore.CYAN}{i18n.t('dns.select')}{Style.RESET_ALL}")
|
|
344
|
+
for idx, tipo in enumerate(DNS_TYPES, 1):
|
|
345
|
+
print(f" {Fore.YELLOW}[{idx}]{Style.RESET_ALL} {tipo} → {i18n.t(f'dns.desc.{tipo}')}")
|
|
346
|
+
print(f" {Fore.YELLOW}[0]{Style.RESET_ALL} {i18n.t('dns.back')}")
|
|
347
|
+
|
|
348
|
+
while True:
|
|
349
|
+
try:
|
|
350
|
+
choice = int(input(f"\n{PROMPT}"))
|
|
351
|
+
if choice == 0:
|
|
352
|
+
return None
|
|
353
|
+
elif 1 <= choice <= len(DNS_TYPES):
|
|
354
|
+
return DNS_TYPES[choice - 1]
|
|
355
|
+
else:
|
|
356
|
+
raise ValueError
|
|
357
|
+
except ValueError:
|
|
358
|
+
print(f"{Fore.RED}{i18n.t('dns.invalid')}{Style.RESET_ALL}")
|
gyntoolkit/scan.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Varredura avançada: port scan (SYN/connect), banner, CVE lookup, host discovery."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from colorama import Fore, Style
|
|
11
|
+
from scapy.all import ARP, IP, TCP, Ether, sr1, srp
|
|
12
|
+
|
|
13
|
+
from . import i18n
|
|
14
|
+
from .core import TOP_PORTS, is_admin_windows, log
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def syn_scan(target: str, port: int) -> tuple[int, bool]:
|
|
18
|
+
"""Varredura stealth SYN (requer admin/root).
|
|
19
|
+
|
|
20
|
+
Se o scapy não conseguir enviar (sem Npcap/interface indisponível), degrada
|
|
21
|
+
para ``connect_scan`` em vez de derrubar toda a varredura.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
pkt = IP(dst=target)/TCP(dport=port, flags="S")
|
|
25
|
+
response = sr1(pkt, timeout=2, verbose=0)
|
|
26
|
+
if response is None:
|
|
27
|
+
return (port, False)
|
|
28
|
+
return (port, response.haslayer(TCP) and response.getlayer(TCP).flags == 0x12)
|
|
29
|
+
except (OSError, PermissionError) as e:
|
|
30
|
+
log.warning("syn_scan %s:%s falhou: %s", target, port, e)
|
|
31
|
+
return (port, False)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
# scapy indisponível (ex.: interface/Npcap ausente): usa connect scan.
|
|
34
|
+
log.warning("syn_scan indisponível em %s:%s (%s); fallback connect_scan", target, port, e)
|
|
35
|
+
return await connect_scan(target, port)
|
|
36
|
+
|
|
37
|
+
async def connect_scan(target: str, port: int) -> tuple[int, bool]:
|
|
38
|
+
"""Varredura TCP completa"""
|
|
39
|
+
try:
|
|
40
|
+
reader, writer = await asyncio.wait_for(
|
|
41
|
+
asyncio.open_connection(target, port),
|
|
42
|
+
timeout=2
|
|
43
|
+
)
|
|
44
|
+
writer.close()
|
|
45
|
+
await writer.wait_closed()
|
|
46
|
+
return (port, True)
|
|
47
|
+
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
|
|
48
|
+
return (port, False)
|
|
49
|
+
|
|
50
|
+
async def get_banner(target: str, port: int) -> tuple[int, str]:
|
|
51
|
+
"""Obtém banner do serviço"""
|
|
52
|
+
try:
|
|
53
|
+
reader, writer = await asyncio.wait_for(
|
|
54
|
+
asyncio.open_connection(target, port),
|
|
55
|
+
timeout=3
|
|
56
|
+
)
|
|
57
|
+
writer.write(b"GET / HTTP/1.1\r\n\r\n")
|
|
58
|
+
banner = await asyncio.wait_for(reader.read(512), timeout=2)
|
|
59
|
+
writer.close()
|
|
60
|
+
with contextlib.suppress(OSError):
|
|
61
|
+
await writer.wait_closed()
|
|
62
|
+
return (port, banner.decode(errors='ignore').strip())
|
|
63
|
+
except (asyncio.TimeoutError, ConnectionRefusedError, OSError) as e:
|
|
64
|
+
log.debug("banner %s:%s vazio: %s", target, port, e)
|
|
65
|
+
return (port, "")
|
|
66
|
+
|
|
67
|
+
def check_vulnerabilities(service: str) -> list[str]:
|
|
68
|
+
"""Consulta NVD API 2.0 por CVEs associados ao serviço."""
|
|
69
|
+
if not service or service.lower() in {"desconhecido", "unknown"}:
|
|
70
|
+
return []
|
|
71
|
+
try:
|
|
72
|
+
response = requests.get(
|
|
73
|
+
"https://services.nvd.nist.gov/rest/json/cves/2.0",
|
|
74
|
+
params={"keywordSearch": service, "resultsPerPage": 5},
|
|
75
|
+
headers={"User-Agent": "gyntoolkit/2.0"},
|
|
76
|
+
timeout=15,
|
|
77
|
+
)
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
data = response.json()
|
|
80
|
+
return [item["cve"]["id"] for item in data.get("vulnerabilities", [])[:5]]
|
|
81
|
+
except (requests.RequestException, ValueError, KeyError) as e:
|
|
82
|
+
log.warning("NVD lookup falhou para '%s': %s", service, e)
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
def network_discovery(cidr: str, timeout: int = 2) -> list[str]:
|
|
86
|
+
"""Descobre hosts ativos em rede via ARP scan (requer privilégio)."""
|
|
87
|
+
try:
|
|
88
|
+
ipaddress.ip_network(cidr, strict=False)
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
log.error("CIDR inválido '%s': %s", cidr, e)
|
|
91
|
+
print(f"{Fore.RED}{i18n.t('scan.cidr_invalid', err=e)}{Style.RESET_ALL}")
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
arp = ARP(pdst=cidr)
|
|
96
|
+
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
|
|
97
|
+
answered, _ = srp(ether / arp, timeout=timeout, verbose=0)
|
|
98
|
+
hosts = sorted({r.psrc for _, r in answered}, key=lambda ip: ipaddress.ip_address(ip))
|
|
99
|
+
return hosts
|
|
100
|
+
except PermissionError:
|
|
101
|
+
print(f"{Fore.RED}{i18n.t('scan.arp_priv')}{Style.RESET_ALL}")
|
|
102
|
+
return []
|
|
103
|
+
except OSError as e:
|
|
104
|
+
log.error("ARP scan falhou: %s", e)
|
|
105
|
+
print(f"{Fore.RED}{i18n.t('scan.arp_fail', err=e)}{Style.RESET_ALL}")
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
async def perform_scan(target: str, scan_type: str) -> dict[int, dict]:
|
|
109
|
+
"""Executa varredura completa com análise de vulnerabilidades"""
|
|
110
|
+
# aceita canônico "fast" e legados pt ("rápido"/"rapido"); resto = full range
|
|
111
|
+
ports = TOP_PORTS if scan_type in ("fast", "rápido", "rapido") else range(1, 65536)
|
|
112
|
+
open_ports = []
|
|
113
|
+
results = {}
|
|
114
|
+
|
|
115
|
+
# Fase 1: Varredura de portas.
|
|
116
|
+
# Privilégio é constante durante o scan: decide SYN vs connect uma vez.
|
|
117
|
+
use_syn = os.geteuid() == 0 if os.name == 'posix' else is_admin_windows()
|
|
118
|
+
tasks = [
|
|
119
|
+
syn_scan(target, port) if use_syn else connect_scan(target, port)
|
|
120
|
+
for port in ports
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
# Processar resultados
|
|
124
|
+
for future in asyncio.as_completed(tasks):
|
|
125
|
+
port, is_open = await future
|
|
126
|
+
if is_open:
|
|
127
|
+
open_ports.append(port)
|
|
128
|
+
|
|
129
|
+
# Fase 2: Obter banners
|
|
130
|
+
banner_tasks = [get_banner(target, port) for port in open_ports]
|
|
131
|
+
banners = {}
|
|
132
|
+
for future in asyncio.as_completed(banner_tasks):
|
|
133
|
+
port, banner = await future
|
|
134
|
+
banners[port] = banner
|
|
135
|
+
|
|
136
|
+
# Fase 3: Analisar vulnerabilidades
|
|
137
|
+
for port in open_ports:
|
|
138
|
+
service = banners[port].split()[0] if banners[port] else "unknown"
|
|
139
|
+
vulns = check_vulnerabilities(service)
|
|
140
|
+
|
|
141
|
+
# 'service' e 'risco' são tokens neutros (DATA); o display os localiza
|
|
142
|
+
# via i18n (scan.service_unknown / scan.risk_high / scan.risk_low).
|
|
143
|
+
results[port] = {
|
|
144
|
+
'service': service,
|
|
145
|
+
'banner': banners[port],
|
|
146
|
+
'vulnerabilidades': vulns,
|
|
147
|
+
'risco': "high" if vulns else "low"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return results
|
gyntoolkit/ui.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Camada de apresentação com ``rich`` (tabelas, painéis, spinners).
|
|
3
|
+
|
|
4
|
+
Degrada graciosamente para ``colorama`` puro se ``rich`` não estiver instalado,
|
|
5
|
+
então a CLI funciona igual sem a dependência.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from colorama import Fore, Style
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from rich import box
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
RICH = True
|
|
20
|
+
console = Console()
|
|
21
|
+
except ImportError: # pragma: no cover - fallback sem rich
|
|
22
|
+
RICH = False
|
|
23
|
+
console = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt(value: Any) -> str:
|
|
27
|
+
if value is None:
|
|
28
|
+
return "—"
|
|
29
|
+
if isinstance(value, (list, tuple)):
|
|
30
|
+
return ", ".join(str(v) for v in value) if value else "—"
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def success(msg: str) -> None:
|
|
35
|
+
if RICH:
|
|
36
|
+
console.print(f"[bold green]{msg}[/]")
|
|
37
|
+
else:
|
|
38
|
+
print(f"{Fore.GREEN}{msg}{Style.RESET_ALL}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error(msg: str) -> None:
|
|
42
|
+
if RICH:
|
|
43
|
+
console.print(f"[bold red]{msg}[/]")
|
|
44
|
+
else:
|
|
45
|
+
print(f"{Fore.RED}{msg}{Style.RESET_ALL}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def notice(msg: str) -> None:
|
|
49
|
+
if RICH:
|
|
50
|
+
console.print(f"[cyan]{msg}[/]")
|
|
51
|
+
else:
|
|
52
|
+
print(f"{Fore.CYAN}{msg}{Style.RESET_ALL}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def print_kv(
|
|
56
|
+
title: str,
|
|
57
|
+
mapping: dict[str, Any],
|
|
58
|
+
warn_keys: dict[str, Any] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Renderiza um mapa chave/valor. ``warn_keys`` marca chaves cujo valor,
|
|
61
|
+
se satisfizer o predicado, deve aparecer em vermelho.
|
|
62
|
+
|
|
63
|
+
``warn_keys`` mapeia chave -> callable(value) -> bool.
|
|
64
|
+
"""
|
|
65
|
+
warn_keys = warn_keys or {}
|
|
66
|
+
if RICH:
|
|
67
|
+
table = Table(title=title, box=box.SIMPLE_HEAVY, title_style="bold green",
|
|
68
|
+
show_header=False, expand=False)
|
|
69
|
+
table.add_column("campo", style="yellow", no_wrap=True)
|
|
70
|
+
table.add_column("valor", style="white")
|
|
71
|
+
for k, v in mapping.items():
|
|
72
|
+
predicate = warn_keys.get(k)
|
|
73
|
+
style = "bold red" if predicate and predicate(v) else "white"
|
|
74
|
+
table.add_row(str(k), f"[{style}]{_fmt(v)}[/]")
|
|
75
|
+
console.print(table)
|
|
76
|
+
else:
|
|
77
|
+
print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
|
|
78
|
+
for k, v in mapping.items():
|
|
79
|
+
predicate = warn_keys.get(k)
|
|
80
|
+
color = Fore.RED if predicate and predicate(v) else Fore.YELLOW
|
|
81
|
+
print(f" {color}{k}:{Style.RESET_ALL} {_fmt(v)}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def print_table(
|
|
85
|
+
title: str,
|
|
86
|
+
columns: Sequence[str],
|
|
87
|
+
rows: Iterable[Sequence[Any]],
|
|
88
|
+
row_styles: list[str | None] | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Renderiza uma tabela. ``row_styles`` opcional colore cada linha (rich)."""
|
|
91
|
+
rows = list(rows)
|
|
92
|
+
if RICH:
|
|
93
|
+
table = Table(title=title, box=box.ROUNDED, title_style="bold green",
|
|
94
|
+
header_style="bold yellow")
|
|
95
|
+
for col in columns:
|
|
96
|
+
table.add_column(str(col))
|
|
97
|
+
for idx, row in enumerate(rows):
|
|
98
|
+
style = row_styles[idx] if row_styles and idx < len(row_styles) else None
|
|
99
|
+
table.add_row(*[_fmt(c) for c in row], style=style)
|
|
100
|
+
console.print(table)
|
|
101
|
+
else:
|
|
102
|
+
print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
|
|
103
|
+
print(f" {Fore.YELLOW}{' '.join(str(c) for c in columns)}{Style.RESET_ALL}")
|
|
104
|
+
for row in rows:
|
|
105
|
+
print(" " + " ".join(_fmt(c) for c in row))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def print_list(title: str, items: Sequence[Any]) -> None:
|
|
109
|
+
if RICH:
|
|
110
|
+
console.print(f"[bold green]{title}[/]")
|
|
111
|
+
for it in items:
|
|
112
|
+
console.print(f" • {_fmt(it)}")
|
|
113
|
+
else:
|
|
114
|
+
print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
|
|
115
|
+
for it in items:
|
|
116
|
+
print(f" - {_fmt(it)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@contextmanager
|
|
120
|
+
def status(message: str):
|
|
121
|
+
"""Spinner enquanto um bloco roda (rich). Fallback imprime a mensagem."""
|
|
122
|
+
if RICH:
|
|
123
|
+
with console.status(f"[cyan]{message}[/]", spinner="dots"):
|
|
124
|
+
yield
|
|
125
|
+
else:
|
|
126
|
+
print(f"{Fore.CYAN}{message}{Style.RESET_ALL}")
|
|
127
|
+
yield
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def kv_pairs(mapping: dict[str, Any], labels: dict[str, str]) -> list[tuple[str, Any]]:
|
|
131
|
+
"""Filtra/relabela um dict preservando a ordem de ``labels``."""
|
|
132
|
+
return [(labels[k], mapping[k]) for k in labels if k in mapping]
|