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/export.py ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Exportação de resultados: JSON e relatório HTML (tema dark)."""
3
+
4
+ import html
5
+ import json
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import i18n
12
+ from .core import log
13
+
14
+
15
+ def _timestamp() -> str:
16
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
17
+
18
+
19
+ def _slug(name: str) -> str:
20
+ """Basename seguro para nome de arquivo."""
21
+ name = re.sub(r"[^A-Za-z0-9._-]+", "_", name.strip()) or "report"
22
+ return name.strip("_.") or "report"
23
+
24
+
25
+ def _ensure_dir(out_dir: str) -> Path:
26
+ d = Path(out_dir).expanduser()
27
+ d.mkdir(parents=True, exist_ok=True)
28
+ return d
29
+
30
+
31
+ def export_json(data: Any, path: str) -> str:
32
+ """Serializa ``data`` como JSON indentado (UTF-8). Retorna o path escrito."""
33
+ p = Path(path).expanduser()
34
+ p.parent.mkdir(parents=True, exist_ok=True)
35
+ with p.open("w", encoding="utf-8") as f:
36
+ json.dump(data, f, indent=2, ensure_ascii=False, default=str)
37
+ return str(p)
38
+
39
+
40
+ def _render_html_value(value: Any) -> str:
41
+ """Renderiza recursivamente um valor como fragmento HTML seguro."""
42
+ if isinstance(value, dict):
43
+ if not value:
44
+ return "<span class='muted'>{}</span>"
45
+ rows = "".join(
46
+ f"<tr><th>{html.escape(str(k))}</th><td>{_render_html_value(v)}</td></tr>"
47
+ for k, v in value.items()
48
+ )
49
+ return f"<table>{rows}</table>"
50
+ if isinstance(value, (list, tuple)):
51
+ if not value:
52
+ return "<span class='muted'>[]</span>"
53
+ items = "".join(f"<li>{_render_html_value(v)}</li>" for v in value)
54
+ return f"<ul>{items}</ul>"
55
+ if value is None:
56
+ return "<span class='muted'>—</span>"
57
+ return html.escape(str(value))
58
+
59
+
60
+ _HTML_TEMPLATE = """<!doctype html>
61
+ <html lang="{lang}">
62
+ <head>
63
+ <meta charset="utf-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>{title}</title>
66
+ <style>
67
+ :root {{ color-scheme: dark; }}
68
+ body {{
69
+ background:#0d0f0d; color:#c8ffc8; margin:0; padding:24px;
70
+ font-family: "JetBrains Mono", "Consolas", ui-monospace, monospace;
71
+ font-size:14px; line-height:1.5;
72
+ }}
73
+ h1 {{ color:#00ff5f; border-bottom:1px solid #143f14; padding-bottom:8px; font-size:20px; }}
74
+ .meta {{ color:#6fbf6f; margin-bottom:20px; font-size:12px; }}
75
+ table {{ border-collapse:collapse; width:100%; margin:4px 0; }}
76
+ th, td {{ border:1px solid #143f14; padding:6px 10px; text-align:left; vertical-align:top; }}
77
+ th {{ color:#ffd700; background:#111511; white-space:nowrap; width:1%; }}
78
+ ul {{ margin:4px 0; padding-left:20px; }}
79
+ li {{ margin:2px 0; }}
80
+ .muted {{ color:#4d6b4d; }}
81
+ footer {{ margin-top:24px; color:#4d6b4d; font-size:12px; border-top:1px solid #143f14; padding-top:8px; }}
82
+ a {{ color:#00ff5f; }}
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <h1>{title}</h1>
87
+ <div class="meta">{generated_by} &middot; {generated}</div>
88
+ {body}
89
+ <footer>{footer}</footer>
90
+ </body>
91
+ </html>
92
+ """
93
+
94
+
95
+ def export_html(data: Any, title: str, path: str) -> str:
96
+ """Renderiza ``data`` em relatório HTML dark. Retorna o path escrito."""
97
+ from . import __version__
98
+
99
+ body = _render_html_value(data)
100
+ lang = "pt-br" if i18n.get_lang() == "pt" else "en"
101
+ doc = _HTML_TEMPLATE.format(
102
+ lang=lang,
103
+ title=html.escape(title),
104
+ generated=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
105
+ generated_by=html.escape(i18n.t("report.generated_by", version=f"v{__version__}")),
106
+ footer=html.escape(i18n.t("report.footer")),
107
+ body=body,
108
+ )
109
+ p = Path(path).expanduser()
110
+ p.parent.mkdir(parents=True, exist_ok=True)
111
+ with p.open("w", encoding="utf-8") as f:
112
+ f.write(doc)
113
+ return str(p)
114
+
115
+
116
+ def save_report(
117
+ data: Any,
118
+ basename: str,
119
+ fmt: str = "json",
120
+ out_dir: str = "reports",
121
+ title: str | None = None,
122
+ ) -> str:
123
+ """Grava ``data`` em ``out_dir/<basename>-<timestamp>.<fmt>``.
124
+
125
+ ``fmt`` aceita ``json`` ou ``html``. Retorna o path escrito.
126
+ """
127
+ fmt = (fmt or "json").lower().strip()
128
+ d = _ensure_dir(out_dir)
129
+ filename = f"{_slug(basename)}-{_timestamp()}.{fmt}"
130
+ path = str(d / filename)
131
+
132
+ if fmt == "html":
133
+ return export_html(data, title or basename, path)
134
+ if fmt == "json":
135
+ return export_json(data, path)
136
+
137
+ log.warning("Formato de export desconhecido '%s'; usando json.", fmt)
138
+ path = str(d / f"{_slug(basename)}-{_timestamp()}.json")
139
+ return export_json(data, path)
gyntoolkit/i18n.py ADDED
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env python3
2
+ """Internacionalização (i18n) leve por catálogo de dicionário.
3
+
4
+ Sem dependências externas e sem etapa de build (.mo). Resolução do idioma:
5
+
6
+ 1. ``ui.lang`` do ``.gyntoolkit.yaml`` (quando diferente de ``auto``).
7
+ 2. Variável de ambiente ``GYNTOOLKIT_LANG``.
8
+ 3. Locale do SO (``locale.getlocale`` / ``$LANG``).
9
+ 4. Fallback para :data:`DEFAULT_LANG` (``en``).
10
+
11
+ Uso::
12
+
13
+ from . import i18n
14
+ i18n.set_lang(i18n.resolve_lang(CONFIG["ui"]["lang"]))
15
+ print(i18n.t("menu.main.title"))
16
+ print(i18n.t("scan.hosts_found", n=3))
17
+ """
18
+
19
+ import locale
20
+ import os
21
+
22
+ DEFAULT_LANG = "en"
23
+ SUPPORTED: tuple[str, ...] = ("en", "pt")
24
+
25
+ # Catálogo de mensagens. Chave namespaceada → string por idioma.
26
+ # Interpolação via str.format: use {name} e passe t(key, name=...).
27
+ MESSAGES: dict[str, dict[str, str]] = {
28
+ "en": {
29
+ # menu principal
30
+ "menu.main.title": "Main Menu:",
31
+ "menu.main.info": "Information Gathering",
32
+ "menu.main.brute": "Brute Force",
33
+ "menu.main.scan": "Advanced Scanning",
34
+ "menu.main.utils": "Utilities",
35
+ # submenu: information gathering
36
+ "menu.info.title": "Information Gathering:",
37
+ "menu.info.whois": "WHOIS Lookup",
38
+ "menu.info.dns": "DNS Lookup",
39
+ "menu.info.geo": "IP Geolocation",
40
+ "menu.info.revdns": "Reverse DNS (PTR)",
41
+ "menu.info.subenum": "Subdomain Enum (crt.sh)",
42
+ "menu.info.ssl": "SSL/TLS Cert Inspector",
43
+ "menu.info.httpfp": "HTTP Fingerprint",
44
+ "menu.info.internetdb": "InternetDB (Shodan free)",
45
+ "menu.info.hibp": "HIBP Breach Check",
46
+ "menu.info.macvendor": "MAC Vendor Lookup",
47
+ "menu.info.traceroute": "Traceroute (TCP)",
48
+ # submenu: brute force
49
+ "menu.brute.title": "Brute Force:",
50
+ "menu.brute.cupp": "Generate Wordlist (CUPP)",
51
+ "menu.brute.ssh": "SSH Attack",
52
+ "menu.brute.http": "HTTP Attack",
53
+ # submenu: utilities
54
+ "menu.utils.title": "Utilities:",
55
+ "menu.utils.hashtext": "Text hash (MD5/SHA1/SHA256/SHA512)",
56
+ "menu.utils.hashfile": "File hash",
57
+ "menu.utils.b64enc": "Base64 encode",
58
+ "menu.utils.b64dec": "Base64 decode",
59
+ "menu.utils.jwt": "JWT decode (no signature check)",
60
+ # brute force (runtime)
61
+ "brute.progress": "[{tried}/{total}] attempts...",
62
+ "brute.ssh_valid": "[+] SSH valid: {user}:{pwd}",
63
+ "brute.http_valid": "[+] HTTP valid: {user}:{pwd}",
64
+ "brute.warn_active": "[!] WARNING: {action} is an active attack.",
65
+ "brute.warn_authorized": "[!] Use only on targets with written authorization.",
66
+ "brute.warn_crime": "[!] Unauthorized use is a crime (Lei 12.737/12, CFAA, etc).",
67
+ "brute.confirm": "Confirm authorization? (type 'AUTORIZO'): ",
68
+ "brute.wordlist_not_found": "Wordlist not found: {path}",
69
+ "brute.cupp_not_found": "CUPP not found. Clone: git clone https://github.com/Mebus/cupp.git",
70
+ "brute.cupp_err": "Error running CUPP: {err}",
71
+ "brute.paramiko_missing": "paramiko not installed. Run: pip install paramiko",
72
+ "brute.aiohttp_missing": "aiohttp not installed. Run: pip install aiohttp",
73
+ # prompts de input
74
+ "prompt.domain": "Enter domain: ",
75
+ "prompt.dns_server": "DNS server (ENTER for default): ",
76
+ "prompt.ip_or_domain": "IP or domain: ",
77
+ "prompt.root_domain": "Root domain: ",
78
+ "prompt.host": "Host: ",
79
+ "prompt.port_443": "Port [443]: ",
80
+ "prompt.url_http": "URL (with http/https): ",
81
+ "prompt.domain_simple": "Domain: ",
82
+ "prompt.mac": "MAC (e.g. 00:1A:2B:3C:4D:5E): ",
83
+ "prompt.target": "Target: ",
84
+ "prompt.max_hops": "Max hops [{n}]: ",
85
+ "prompt.dest_port": "Dest TCP port [{n}]: ",
86
+ "prompt.target_host": "Target host: ",
87
+ "prompt.port_22": "Port [22]: ",
88
+ "prompt.user_or_users_wl": "Single user or users wordlist path{default}: ",
89
+ "prompt.user_or_wl": "Single user or wordlist path{default}: ",
90
+ "prompt.pass_wl": "Passwords wordlist path{default}: ",
91
+ "prompt.workers": "Concurrent workers [{n}]: ",
92
+ "prompt.url_target": "Target URL (with http/https): ",
93
+ "prompt.mode": "Mode [basic/form] (default basic): ",
94
+ "prompt.user_field": "Username field name [username]: ",
95
+ "prompt.pass_field": "Password field name [password]: ",
96
+ "prompt.fail_sig": "Text snippet that indicates failure (e.g. 'Invalid'): ",
97
+ "prompt.target_net": "Target (IP/network): ",
98
+ "prompt.scan_type": "Scan type [fast/full] (default {d}): ",
99
+ "prompt.select_host": "Select host (ENTER for all): ",
100
+ "prompt.text": "Text: ",
101
+ "prompt.algo": "Algorithm [sha256]: ",
102
+ "prompt.file_path": "File path: ",
103
+ "prompt.base64": "Base64: ",
104
+ "prompt.jwt": "JWT: ",
105
+ # status (spinners)
106
+ "status.whois": "Querying WHOIS for {d}...",
107
+ "status.dns": "Querying {t} record for {d}...",
108
+ "status.geo": "Querying geolocation for {t}...",
109
+ "status.revdns": "Reverse DNS for {t}...",
110
+ "status.subenum": "Enumerating subdomains via crt.sh (may take a while)...",
111
+ "status.ssl": "Inspecting TLS cert of {h}:{p}...",
112
+ "status.httpfp": "Fingerprinting {u}...",
113
+ "status.internetdb": "Querying InternetDB (Shodan free)...",
114
+ "status.hibp": "Querying HIBP breaches for {d}...",
115
+ "status.macvendor": "Querying vendor...",
116
+ "status.traceroute": "TCP traceroute to {t}:{p}...",
117
+ "status.discover": "Discovering live hosts...",
118
+ "status.scanning": "Scanning {h}...",
119
+ # labels / títulos / result
120
+ "label.result": "Result:",
121
+ "label.dns_records": "{t} records of {d}",
122
+ "label.geo": "Geolocation of {t}",
123
+ "label.revdns": "Reverse DNS of {t}",
124
+ "label.subdomains": "Subdomains of {d} (total: {n})",
125
+ "label.ssl": "TLS certificate of {h}:{p}",
126
+ "label.httpfp": "HTTP Fingerprint of {u}",
127
+ "label.internetdb": "InternetDB of {t}",
128
+ "label.breaches": "Breaches of {d}",
129
+ "label.macvendor": "MAC Vendor",
130
+ "label.traceroute": "Traceroute {t}:{p}",
131
+ "label.creds_found": "Credentials found",
132
+ "label.hosts_found": "Hosts found: {n}",
133
+ "label.scan_results": "Results for {h}",
134
+ "label.hash": "Hash",
135
+ "label.file_hash": "File hash",
136
+ "label.b64enc": "Base64 encode",
137
+ "label.b64dec": "Base64 decode",
138
+ # geo field labels
139
+ "geo.ip": "IP", "geo.country": "Country", "geo.region": "Region",
140
+ "geo.city": "City", "geo.zip": "ZIP", "geo.lat": "Latitude",
141
+ "geo.lon": "Longitude", "geo.timezone": "Timezone", "geo.isp": "ISP",
142
+ "geo.org": "Organization", "geo.as": "ASN", "geo.reverse": "Reverse DNS",
143
+ "geo.mobile": "Mobile", "geo.proxy": "Proxy", "geo.hosting": "Hosting",
144
+ # internetdb shown labels
145
+ "idb.ip": "IP", "idb.hostnames": "Hostnames", "idb.ports": "Open ports",
146
+ "idb.tags": "Tags", "idb.cpes": "CPEs", "idb.cves": "CVEs",
147
+ # kv labels diversos
148
+ "kv.mac": "MAC", "kv.vendor": "Vendor",
149
+ # table headers
150
+ "table.breaches": "Name|Date|Accounts|Classes",
151
+ "table.creds": "User|Password",
152
+ "table.traceroute": "TTL|IP|RTT",
153
+ "table.scan": "Port|Service|Risk|Banner|CVEs",
154
+ # mensagens
155
+ "msg.no_subdomains": "No subdomains found.",
156
+ "msg.no_breaches": "No known breach for {d}.",
157
+ "msg.auth_not_confirmed": "Authorization not confirmed. Aborting.",
158
+ "msg.empty_wordlist": "Empty wordlist. Aborting.",
159
+ "msg.ssh_start": "Starting SSH brute on {h}:{p} ({u}x{pw} = {c} combos)...",
160
+ "msg.http_start": "Starting HTTP brute on {u} ({c} combos)...",
161
+ "msg.no_valid_creds": "No valid credentials found.",
162
+ "msg.no_host_responded": "No host responded.",
163
+ "msg.invalid_selection": "Invalid selection.",
164
+ "msg.no_open_ports": "No open ports on {h}.",
165
+ "msg.invalid_algo": "Invalid algorithm: {err}",
166
+ "msg.export_prompt": "Export result? [json/html/N]: ",
167
+ "msg.report_saved": "Report saved: {path}",
168
+ "msg.export_fail": "Export failed: {err}",
169
+ # scan (display de valores-DATA + prints)
170
+ "scan.risk_high": "High", "scan.risk_low": "Low",
171
+ "scan.service_unknown": "Unknown",
172
+ "scan.banner_none": "No banner identified",
173
+ "scan.cidr_invalid": "Invalid CIDR: {err}",
174
+ "scan.arp_priv": "ARP scan requires root/admin privilege.",
175
+ "scan.arp_fail": "ARP scan failed: {err}",
176
+ # recon (valores de erro retornados)
177
+ "recon.whois_err": "WHOIS query error: {err}",
178
+ "recon.resolve_fail": "Failed to resolve {t}: {err}",
179
+ "recon.resolve_fail_simple": "Failed to resolve: {err}",
180
+ "recon.query_failed": "Query failed",
181
+ "recon.no_ptr": "No PTR: {err}",
182
+ "recon.ssl_fail": "TLS failure for {h}:{p}: {err}",
183
+ "recon.no_cert": "Peer did not send a certificate",
184
+ "recon.cert_parse_fail": "Certificate parse failure: {err}",
185
+ "recon.http_fail": "HTTP failure: {err}",
186
+ "recon.internetdb_nodata": "No data in InternetDB",
187
+ "recon.mac_invalid": "Invalid MAC: {mac}",
188
+ "recon.not_found_http": "Not found (HTTP {code})",
189
+ "recon.error": "Error: {err}",
190
+ "recon.traceroute_priv": "traceroute requires root/admin privilege",
191
+ "recon.scapy_err": "scapy: {err}",
192
+ # dns chooser
193
+ "dns.select": "Select the DNS record type:",
194
+ "dns.back": "Back",
195
+ "dns.invalid": "Invalid choice. Try again.",
196
+ "dns.desc.A": "IPv4 address",
197
+ "dns.desc.AAAA": "IPv6 address",
198
+ "dns.desc.MX": "Mail servers",
199
+ "dns.desc.NS": "Authoritative DNS servers",
200
+ "dns.desc.CNAME": "Alias of another domain",
201
+ "dns.desc.TXT": "Text records like SPF, DKIM, etc.",
202
+ "dns.desc.SOA": "Domain administrative info",
203
+ "dns.no_record": "No {t} record found for {d}",
204
+ "dns.nxdomain": "Domain {d} does not exist",
205
+ "dns.error": "Error: {err}",
206
+ # utils
207
+ "utils.file_not_found": "File not found: {path}",
208
+ "utils.decode_err": "Decode error: {err}",
209
+ "utils.jwt_parts": "JWT must have 3 parts (header.payload.signature)",
210
+ "utils.jwt_malformed": "Malformed JWT: {err}",
211
+ # comuns / framing
212
+ "common.back_exit": "Back/Exit",
213
+ "common.invalid_option": "Invalid option! Try again.",
214
+ "common.press_enter": "Press Enter to continue...",
215
+ "common.exiting": "Exiting...",
216
+ "common.interrupted": "Interrupted by user.",
217
+ # avisos de privilégio
218
+ "warn.need_root": "Warning: advanced features require root!",
219
+ "warn.need_admin": "Warning: advanced features require administrator!",
220
+ # relatório HTML exportado
221
+ "report.generated_by": "Generated by GynToolkit {version}",
222
+ "report.footer": "GynToolkit · use only on authorized targets.",
223
+ },
224
+ "pt": {
225
+ # menu principal
226
+ "menu.main.title": "Menu Principal:",
227
+ "menu.main.info": "Obter Informações",
228
+ "menu.main.brute": "Brute Force",
229
+ "menu.main.scan": "Varredura Avançada",
230
+ "menu.main.utils": "Utilitários",
231
+ # submenu: obter informações
232
+ "menu.info.title": "Obter Informações:",
233
+ "menu.info.whois": "Consulta WHOIS",
234
+ "menu.info.dns": "DNS Lookup",
235
+ "menu.info.geo": "Geolocalização IP",
236
+ "menu.info.revdns": "Reverse DNS (PTR)",
237
+ "menu.info.subenum": "Subdomain Enum (crt.sh)",
238
+ "menu.info.ssl": "SSL/TLS Cert Inspector",
239
+ "menu.info.httpfp": "HTTP Fingerprint",
240
+ "menu.info.internetdb": "InternetDB (Shodan free)",
241
+ "menu.info.hibp": "HIBP Breach Check",
242
+ "menu.info.macvendor": "MAC Vendor Lookup",
243
+ "menu.info.traceroute": "Traceroute (TCP)",
244
+ # submenu: brute force
245
+ "menu.brute.title": "Brute Force:",
246
+ "menu.brute.cupp": "Gerar Wordlist (CUPP)",
247
+ "menu.brute.ssh": "Ataque SSH",
248
+ "menu.brute.http": "Ataque HTTP",
249
+ # submenu: utilitários
250
+ "menu.utils.title": "Utilitários:",
251
+ "menu.utils.hashtext": "Hash de texto (MD5/SHA1/SHA256/SHA512)",
252
+ "menu.utils.hashfile": "Hash de arquivo",
253
+ "menu.utils.b64enc": "Base64 encode",
254
+ "menu.utils.b64dec": "Base64 decode",
255
+ "menu.utils.jwt": "JWT decode (sem verificar assinatura)",
256
+ # brute force (runtime)
257
+ "brute.progress": "[{tried}/{total}] tentativas...",
258
+ "brute.ssh_valid": "[+] SSH válido: {user}:{pwd}",
259
+ "brute.http_valid": "[+] HTTP válido: {user}:{pwd}",
260
+ "brute.warn_active": "[!] AVISO: {action} é ataque ativo.",
261
+ "brute.warn_authorized": "[!] Use apenas em alvos com autorização por escrito.",
262
+ "brute.warn_crime": "[!] Uso não autorizado é crime (Lei 12.737/12, CFAA, etc).",
263
+ "brute.confirm": "Confirmar autorização? (digite 'AUTORIZO'): ",
264
+ "brute.wordlist_not_found": "Wordlist não encontrada: {path}",
265
+ "brute.cupp_not_found": "CUPP não encontrado. Clone: git clone https://github.com/Mebus/cupp.git",
266
+ "brute.cupp_err": "Erro ao rodar CUPP: {err}",
267
+ "brute.paramiko_missing": "paramiko não instalado. Rode: pip install paramiko",
268
+ "brute.aiohttp_missing": "aiohttp não instalado. Rode: pip install aiohttp",
269
+ # prompts de input
270
+ "prompt.domain": "Digite o domínio: ",
271
+ "prompt.dns_server": "Servidor DNS (ENTER para default): ",
272
+ "prompt.ip_or_domain": "IP ou domínio: ",
273
+ "prompt.root_domain": "Domínio raiz: ",
274
+ "prompt.host": "Host: ",
275
+ "prompt.port_443": "Porta [443]: ",
276
+ "prompt.url_http": "URL (com http/https): ",
277
+ "prompt.domain_simple": "Domínio: ",
278
+ "prompt.mac": "MAC (ex: 00:1A:2B:3C:4D:5E): ",
279
+ "prompt.target": "Alvo: ",
280
+ "prompt.max_hops": "Max hops [{n}]: ",
281
+ "prompt.dest_port": "Porta TCP destino [{n}]: ",
282
+ "prompt.target_host": "Host alvo: ",
283
+ "prompt.port_22": "Porta [22]: ",
284
+ "prompt.user_or_users_wl": "Usuário único ou path de wordlist de usuários{default}: ",
285
+ "prompt.user_or_wl": "Usuário único ou path de wordlist{default}: ",
286
+ "prompt.pass_wl": "Path da wordlist de senhas{default}: ",
287
+ "prompt.workers": "Workers concorrentes [{n}]: ",
288
+ "prompt.url_target": "URL alvo (com http/https): ",
289
+ "prompt.mode": "Modo [basic/form] (default basic): ",
290
+ "prompt.user_field": "Nome do campo usuário [username]: ",
291
+ "prompt.pass_field": "Nome do campo senha [password]: ",
292
+ "prompt.fail_sig": "Trecho de texto que indica falha (ex: 'Invalid'): ",
293
+ "prompt.target_net": "Alvo (IP/rede): ",
294
+ "prompt.scan_type": "Tipo de varredura [rápido/completo] (default {d}): ",
295
+ "prompt.select_host": "Selecione o host (ENTER para todos): ",
296
+ "prompt.text": "Texto: ",
297
+ "prompt.algo": "Algoritmo [sha256]: ",
298
+ "prompt.file_path": "Path do arquivo: ",
299
+ "prompt.base64": "Base64: ",
300
+ "prompt.jwt": "JWT: ",
301
+ # status (spinners)
302
+ "status.whois": "Consultando WHOIS de {d}...",
303
+ "status.dns": "Consultando registro {t} para {d}...",
304
+ "status.geo": "Consultando geolocalização de {t}...",
305
+ "status.revdns": "Reverse DNS de {t}...",
306
+ "status.subenum": "Enumerando subdomínios via crt.sh (pode demorar)...",
307
+ "status.ssl": "Inspecionando cert TLS de {h}:{p}...",
308
+ "status.httpfp": "Fingerprinting {u}...",
309
+ "status.internetdb": "Consultando InternetDB (Shodan free)...",
310
+ "status.hibp": "Consultando HIBP breaches para {d}...",
311
+ "status.macvendor": "Consultando fabricante...",
312
+ "status.traceroute": "Traceroute TCP para {t}:{p}...",
313
+ "status.discover": "Descobrindo hosts ativos...",
314
+ "status.scanning": "Escaneando {h}...",
315
+ # labels / títulos / result
316
+ "label.result": "Resultado:",
317
+ "label.dns_records": "Registros {t} de {d}",
318
+ "label.geo": "Geolocalização de {t}",
319
+ "label.revdns": "Reverse DNS de {t}",
320
+ "label.subdomains": "Subdomínios de {d} (total: {n})",
321
+ "label.ssl": "Certificado TLS de {h}:{p}",
322
+ "label.httpfp": "HTTP Fingerprint de {u}",
323
+ "label.internetdb": "InternetDB de {t}",
324
+ "label.breaches": "Breaches de {d}",
325
+ "label.macvendor": "MAC Vendor",
326
+ "label.traceroute": "Traceroute {t}:{p}",
327
+ "label.creds_found": "Credenciais encontradas",
328
+ "label.hosts_found": "Hosts encontrados: {n}",
329
+ "label.scan_results": "Resultados para {h}",
330
+ "label.hash": "Hash",
331
+ "label.file_hash": "Hash de arquivo",
332
+ "label.b64enc": "Base64 encode",
333
+ "label.b64dec": "Base64 decode",
334
+ # geo field labels
335
+ "geo.ip": "IP", "geo.country": "País", "geo.region": "Região",
336
+ "geo.city": "Cidade", "geo.zip": "CEP", "geo.lat": "Latitude",
337
+ "geo.lon": "Longitude", "geo.timezone": "Fuso", "geo.isp": "ISP",
338
+ "geo.org": "Organização", "geo.as": "ASN", "geo.reverse": "Reverse DNS",
339
+ "geo.mobile": "Mobile", "geo.proxy": "Proxy", "geo.hosting": "Hosting",
340
+ # internetdb shown labels
341
+ "idb.ip": "IP", "idb.hostnames": "Hostnames", "idb.ports": "Portas abertas",
342
+ "idb.tags": "Tags", "idb.cpes": "CPEs", "idb.cves": "CVEs",
343
+ # kv labels diversos
344
+ "kv.mac": "MAC", "kv.vendor": "Fabricante",
345
+ # table headers
346
+ "table.breaches": "Nome|Data|Contas|Classes",
347
+ "table.creds": "Usuário|Senha",
348
+ "table.traceroute": "TTL|IP|RTT",
349
+ "table.scan": "Porta|Serviço|Risco|Banner|CVEs",
350
+ # mensagens
351
+ "msg.no_subdomains": "Nenhum subdomínio encontrado.",
352
+ "msg.no_breaches": "Nenhum breach conhecido para {d}.",
353
+ "msg.auth_not_confirmed": "Autorização não confirmada. Abortando.",
354
+ "msg.empty_wordlist": "Wordlist vazia. Abortando.",
355
+ "msg.ssh_start": "Iniciando SSH brute em {h}:{p} ({u}x{pw} = {c} combos)...",
356
+ "msg.http_start": "Iniciando HTTP brute em {u} ({c} combos)...",
357
+ "msg.no_valid_creds": "Nenhuma credencial válida encontrada.",
358
+ "msg.no_host_responded": "Nenhum host respondeu.",
359
+ "msg.invalid_selection": "Seleção inválida.",
360
+ "msg.no_open_ports": "Nenhuma porta aberta em {h}.",
361
+ "msg.invalid_algo": "Algoritmo inválido: {err}",
362
+ "msg.export_prompt": "Exportar resultado? [json/html/N]: ",
363
+ "msg.report_saved": "Relatório salvo: {path}",
364
+ "msg.export_fail": "Falha ao exportar: {err}",
365
+ # scan (display de valores-DATA + prints)
366
+ "scan.risk_high": "Alto", "scan.risk_low": "Baixo",
367
+ "scan.service_unknown": "Desconhecido",
368
+ "scan.banner_none": "Nenhum banner identificado",
369
+ "scan.cidr_invalid": "CIDR inválido: {err}",
370
+ "scan.arp_priv": "ARP scan requer privilégio root/admin.",
371
+ "scan.arp_fail": "Falha no ARP scan: {err}",
372
+ # recon (valores de erro retornados)
373
+ "recon.whois_err": "Erro na consulta WHOIS: {err}",
374
+ "recon.resolve_fail": "Falha ao resolver {t}: {err}",
375
+ "recon.resolve_fail_simple": "Falha ao resolver: {err}",
376
+ "recon.query_failed": "Consulta falhou",
377
+ "recon.no_ptr": "Sem PTR: {err}",
378
+ "recon.ssl_fail": "Falha SSL para {h}:{p}: {err}",
379
+ "recon.no_cert": "Peer não enviou certificado",
380
+ "recon.cert_parse_fail": "Falha ao parsear cert: {err}",
381
+ "recon.http_fail": "Falha HTTP: {err}",
382
+ "recon.internetdb_nodata": "Sem dados no InternetDB",
383
+ "recon.mac_invalid": "MAC inválido: {mac}",
384
+ "recon.not_found_http": "Não encontrado (HTTP {code})",
385
+ "recon.error": "Erro: {err}",
386
+ "recon.traceroute_priv": "traceroute requer privilégio root/admin",
387
+ "recon.scapy_err": "scapy: {err}",
388
+ # dns chooser
389
+ "dns.select": "Selecione o tipo de registro DNS:",
390
+ "dns.back": "Voltar",
391
+ "dns.invalid": "Escolha inválida. Tente novamente.",
392
+ "dns.desc.A": "Endereço IPv4",
393
+ "dns.desc.AAAA": "Endereço IPv6",
394
+ "dns.desc.MX": "Servidores de e-mail",
395
+ "dns.desc.NS": "Servidores DNS autoritativos",
396
+ "dns.desc.CNAME": "Apelido de outro domínio",
397
+ "dns.desc.TXT": "Registros de texto como SPF, DKIM, etc.",
398
+ "dns.desc.SOA": "Informações administrativas do domínio",
399
+ "dns.no_record": "Nenhum registro {t} encontrado para {d}",
400
+ "dns.nxdomain": "Domínio {d} não existe",
401
+ "dns.error": "Erro: {err}",
402
+ # utils
403
+ "utils.file_not_found": "Arquivo não encontrado: {path}",
404
+ "utils.decode_err": "Erro decode: {err}",
405
+ "utils.jwt_parts": "JWT deve ter 3 partes (header.payload.signature)",
406
+ "utils.jwt_malformed": "JWT malformado: {err}",
407
+ # comuns / framing
408
+ "common.back_exit": "Voltar/Sair",
409
+ "common.invalid_option": "Opção inválida! Tente novamente.",
410
+ "common.press_enter": "Pressione Enter para continuar...",
411
+ "common.exiting": "Saindo...",
412
+ "common.interrupted": "Interrompido pelo usuário.",
413
+ # avisos de privilégio
414
+ "warn.need_root": "Aviso: Funcionalidades avançadas requerem root!",
415
+ "warn.need_admin": "Aviso: Funcionalidades avançadas requerem administrador!",
416
+ # relatório HTML exportado
417
+ "report.generated_by": "Gerado por GynToolkit {version}",
418
+ "report.footer": "GynToolkit · use apenas em alvos autorizados.",
419
+ },
420
+ }
421
+
422
+ _current_lang = DEFAULT_LANG
423
+
424
+
425
+ def _normalize(value: str | None) -> str | None:
426
+ """Reduz 'pt_BR.UTF-8' / 'en-US' → 'pt' / 'en' se suportado, senão None."""
427
+ if not value:
428
+ return None
429
+ code = value.strip().lower().replace("-", "_").split("_", 1)[0]
430
+ return code if code in SUPPORTED else None
431
+
432
+
433
+ def _from_locale() -> str | None:
434
+ for getter in (lambda: locale.getlocale()[0], locale.getdefaultlocale):
435
+ try:
436
+ code = _normalize(getter()[0] if getter is locale.getdefaultlocale else getter())
437
+ except (ValueError, IndexError, TypeError):
438
+ code = None
439
+ if code:
440
+ return code
441
+ for env_var in ("LC_ALL", "LC_MESSAGES", "LANG"):
442
+ code = _normalize(os.environ.get(env_var))
443
+ if code:
444
+ return code
445
+ return None
446
+
447
+
448
+ def resolve_lang(config_lang: str | None = None) -> str:
449
+ """Resolve o idioma efetivo seguindo a ordem de precedência documentada."""
450
+ cfg = _normalize(config_lang)
451
+ if cfg and (config_lang or "").strip().lower() != "auto":
452
+ return cfg
453
+ env = _normalize(os.environ.get("GYNTOOLKIT_LANG"))
454
+ if env:
455
+ return env
456
+ loc = _from_locale()
457
+ if loc:
458
+ return loc
459
+ return DEFAULT_LANG
460
+
461
+
462
+ def set_lang(lang: str | None) -> str:
463
+ """Define o idioma atual (normalizado). Retorna o idioma efetivo."""
464
+ global _current_lang
465
+ _current_lang = _normalize(lang) or DEFAULT_LANG
466
+ return _current_lang
467
+
468
+
469
+ def get_lang() -> str:
470
+ return _current_lang
471
+
472
+
473
+ def t(key: str, **kwargs: object) -> str:
474
+ """Traduz ``key`` no idioma atual.
475
+
476
+ Fallback: idioma atual → :data:`DEFAULT_LANG` → a própria chave.
477
+ ``kwargs`` são aplicados via :meth:`str.format`.
478
+ """
479
+ msg = MESSAGES.get(_current_lang, {}).get(key)
480
+ if msg is None:
481
+ msg = MESSAGES.get(DEFAULT_LANG, {}).get(key, key)
482
+ if kwargs:
483
+ try:
484
+ return msg.format(**kwargs)
485
+ except (KeyError, IndexError, ValueError):
486
+ return msg
487
+ return msg