${band.band === '2.4 GHz'
+ ? 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.'
+ : rec?.dfs
+ ? 'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.'
+ : 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.'}
WEP. A cifra é quebrável; capture IVs na aba Ataques.
',
+ );
+ }
+ if (ap.wps) {
+ notes.push(
+ `
WPS ativo${ap.wps_version ? ` (${escapeHtml(ap.wps_version)})` : ''}. ${ap.wps_locked ? 'O AP anuncia configuração travada.' : 'PIN pode estar acessível; verifique o lock.'}
` : ''}`;
+}
+
+const EVENT_LABEL = {
+ first_seen: 'primeiro visto',
+ probe: 'probe',
+ associate: 'associou',
+ deauth: 'desautenticado',
+};
+
+/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem
+ * sozinho dentro do card de sinal do detalhe do cliente. */
+function bigSparkline(history) {
+ const points = (history || []).slice(-60);
+ if (points.length < 2) return '
Sem amostras de sinal suficientes ainda.
';
+ const W = 560;
+ const H = 70;
+ const dbms = points.map((p) => p.dbm ?? -90);
+ const min = Math.min(...dbms, -90);
+ const max = Math.max(...dbms, -30);
+ const span = Math.max(1, max - min);
+ const d = points
+ .map((p, i) => {
+ const x = (i / (points.length - 1)) * W;
+ const y = H - (((p.dbm ?? min) - min) / span) * (H - 6) - 3;
+ return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`;
+ })
+ .join(' ');
+ return ``;
+}
+
+/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele —
+ * identificação, rede, sinal e a linha do tempo de eventos — num painel só,
+ * em vez de ter que adivinhar olhando a linha compacta da tabela. */
+function renderClientDetail(clients) {
+ const client = clients.find((c) => c.mac === attack.timelineMac);
+ if (!client) return '';
+ const events = (client.events || []).slice(-12).reverse();
+ const rows = events.length
+ ? events.map((e) => `
Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.
+
+
+
+
+
+
+
+
+
${escapeHtml(result)}
+
${escapeHtml(output || 'A saída do reaver/bully aparece aqui.')}
+ `;
+}
+
+function eviltwinHtml() {
+ const job = attack.eviltwinInfo;
+ const running = !!job && job.status === 'running';
+ const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
+ const result = job
+ ? job.password
+ ? `Senha confirmada: ${job.password}`
+ : job.error
+ ? `Falha: ${job.error}`
+ : `${jobStatusLabel(job.status)} · ${job.attempts ?? 0} tentativa(s) no portal${job.last_attempt ? ` · última: ${attemptWord[job.last_attempt] || job.last_attempt}` : ''}`
+ : 'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".';
+ return `
+
Evil Twin + portal cativo
+
Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de hostapd instalado e de uma segunda interface Wi-Fi em modo AP, diferente da que está capturando.
+
+
+
+
+
+
+
+
+
+
${escapeHtml(result)}
+ `;
+}
+
+function enterpriseTwinHtml(ap) {
+ if (!ap || !(ap.akm || []).includes('802.1X')) return '';
+ const job = attack.entTwinInfo;
+ const running = !!job && job.status === 'running';
+ const result = job
+ ? job.error
+ ? `Falha: ${job.error}`
+ : `${jobStatusLabel(job.status)} · ${job.captured ?? 0} credencial(is) capturada(s)${job.last_identity ? ` · última: ${job.last_identity}` : ''}`
+ : 'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.';
+ return `
+
Evil Twin Enterprise (802.1X)
+
Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige hostapd-wpe instalado (não vem com o Sabiá: é um patch no build do hostapd — github.com/aircrack-ng/hostapd-wpe) e uma segunda interface Wi-Fi em modo AP.
+
+
+
+
+
+
+
+
${escapeHtml(result)}
+ ${job && job.captured ? `
Log de credenciais (p/ john --format=netntlm ou reformatar pro hashcat -m 5500): ${escapeHtml(job.log_path)}
` : ''}
+
Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.
Somente em redes próprias ou com autorização escrita. Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.
+
+
+
+
Desautenticação
+
Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.
+
+
${attack.station ? 'Somente o cliente selecionado será desautenticado.' : 'Todos os clientes associados (broadcast).'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${escapeHtml(deauthLiveText())}
+
+
+
+
PMF (802.11w)
+
Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.
+
+
+
+
+
+
+
PMKID clientless
+
Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.
+
+
+
+
+
+
+
Handshake
+
${hs.length ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).` : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.'}
+
${hsListHtml(hs)}
+
+
+
+
+
+
+
+
+
+
+
+
+
${crackHtml()}
+
+
Força bruta por máscara — sem dicionário, testa toda combinação do padrão. ?d dígito, ?l minúscula, ?u maiúscula, ?s símbolo, ?a qualquer um; o resto é literal.
WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.
+
+
+
+
+
+
+
+
Depois de coletar IVs suficientes: aircrack-ng ${escapeHtml(attack.wepOut || 'captura.pcap')}
+ ` : `
WEP
Esta rede não usa WEP.
`}
`;
}
+function updateAuthUi() {
+ const needsStation = attack.direction !== 'ap' && !attack.station;
+ els.tabPanel.querySelectorAll('[data-needs-auth]').forEach((b) => {
+ const deauth = b.dataset.act === 'deauth' || b.dataset.act === 'deauth-handshake' || b.dataset.act === 'deauth-start';
+ b.disabled = !attack.authorized || b.dataset.locked === '1' || (deauth && needsStation);
+ });
+ const running = deauthRunning();
+ const start = els.tabPanel.querySelector('[data-act="deauth-start"]');
+ const stop = els.tabPanel.querySelector('[data-act="deauth-stop"]');
+ const burst = els.tabPanel.querySelector('[data-act="deauth"]');
+ const handshake = els.tabPanel.querySelector('[data-act="deauth-handshake"]');
+ if (start) start.disabled = running || !attack.authorized || needsStation;
+ if (stop) stop.disabled = !running;
+ if (burst) burst.disabled = running || !attack.authorized || needsStation;
+ if (handshake) handshake.disabled = running || !attack.authorized || needsStation;
+}
+
+async function ensureWpsTools() {
+ if (attack.wpsLoaded) return;
+ attack.wpsLoaded = true;
+ const tools = await api('/api/wps/tools');
+ attack.wpsTools = Array.isArray(tools) ? tools : [];
+ if (attack.wpsTools.length && !attack.wpsTools.some((t) => t.name === attack.wpsTool)) {
+ attack.wpsTool = attack.wpsTools[0].name;
+ }
+ if (activeTab === 'attacks') renderTarget(true);
+}
+
+function setResult(id, text, bad) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.textContent = text;
+ el.classList.toggle('bad', !!bad);
+}
+
+function openTarget(bssid) {
+ const ap = findAp(bssid);
+ if (!ap) return;
+ if (attack.deauthJob) void stopDeauthJob();
+ selectedBssid = bssid;
+ activeTab = 'overview';
+ attack.deauthInfo = null;
+ attack.authorized = false;
+ attack.station = '';
+ attack.hsStation = '';
+ attack.crackJob = null;
+ attack.crackInfo = null;
+ attack.timelineMac = '';
+ const slug = bssid.replace(/[^0-9a-z]/gi, '');
+ attack.hsOut = `sabia-${slug}.pcap`;
+ attack.wepOut = `sabia-wep-${slug}.pcap`;
+ renderTarget(true);
+ if (!els.targetDlg.open) els.targetDlg.showModal();
+ void loadSnapshot();
+}
+
+async function loadSnapshot() {
+ if (!current) return;
+ const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
+ if (snap?.access_points) {
+ lastSnapshot = snap;
+ renderResults(snap.access_points);
+ if (selectedBssid) renderTarget(false);
+ }
+}
+
+// Enquanto startDeauthJob está religando a captura e esperando o AP
+// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos
+// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda"
+// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio
+// da própria ação que ele acabou de pedir.
+let resumingForBssid = null;
+
+function renderTarget(force) {
+ const ap = findAp(selectedBssid);
+ if (!ap) {
+ if (els.targetDlg.open && selectedBssid !== resumingForBssid) els.targetDlg.close();
+ return;
+ }
+ if (!els.targetDlg.open) return;
+ const group = apsOfGroup(ap);
+ const clients = clientsFor(ap);
+ const hs = handshakesFor(ap);
+ els.targetTitle.textContent = ap.hidden || !ap.ssid ? 'Rede oculta' : ap.ssid;
+ const sub = [mono(ap.bssid)];
+ if (ap.vendor) sub.push(escapeHtml(ap.vendor));
+ sub.push(ap.channel != null ? `canal ${ap.channel}` : 'canal —');
+ if (ap.band) sub.push(escapeHtml(ap.band));
+ sub.push(securityTag(ap.security));
+ const selector = group.length > 1
+ ? ``
+ : '';
+ els.targetSub.innerHTML = `${sub.join(' · ')}${selector}`;
+ els.tabClientsCount.textContent = String(clients.length);
+ els.tabPanel.dataset.tab = activeTab;
+ document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((b) => {
+ b.setAttribute('aria-selected', String(b.dataset.tab === activeTab));
+ });
+ if (activeTab === 'attacks' && !force) {
+ updateAttackLive(ap, hs);
+ return;
+ }
+ els.tabPanel.innerHTML =
+ activeTab === 'clients' ? renderClients(ap, clients) : activeTab === 'attacks' ? renderAttacks(ap, clients, hs) : renderOverview(ap, clients, hs);
+ if (activeTab === 'attacks') {
+ updateAuthUi();
+ void ensureWpsTools();
+ }
+ const sel = document.getElementById('bssidSel');
+ if (sel) sel.addEventListener('change', () => openTarget(sel.value));
+ const deauthSel = document.getElementById('deauthStation');
+ if (deauthSel) {
+ deauthSel.addEventListener('change', () => {
+ attack.station = deauthSel.value;
+ const scope = document.getElementById('deauthScope');
+ if (scope) {
+ scope.textContent = attack.direction === 'sta'
+ ? 'Do cliente para o AP (exige estação selecionada).'
+ : attack.direction === 'both'
+ ? 'Nas duas direções (exige estação selecionada).'
+ : attack.station
+ ? 'Somente o cliente selecionado será desautenticado.'
+ : 'Todos os clientes associados (broadcast).';
+ }
+ updateAuthUi();
+ });
+ }
+}
+
+function updateAttackLive(ap, hs) {
+ const crackable = hs.filter((h) => h.crackable);
+ document.querySelectorAll('#tabPanel [data-act="export-hs"], #tabPanel [data-act="export-all"], #tabPanel [data-act="export-hc"], #tabPanel [data-act="export-all-hc"]').forEach((b) => {
+ if (hs.length) delete b.dataset.locked;
+ else b.dataset.locked = '1';
+ });
+ document.querySelectorAll('#tabPanel [data-act="crack"]').forEach((b) => {
+ if (crackable.length) delete b.dataset.locked;
+ else b.dataset.locked = '1';
+ });
+ const summary = document.getElementById('hsSummary');
+ if (summary) {
+ const crackable = hs.filter((h) => h.crackable);
+ summary.textContent = hs.length
+ ? `${hs.length} handshake(s) visto(s); ${crackable.length} quebrável(is).`
+ : 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.';
+ }
+ const list = document.getElementById('hsList');
+ if (list) list.innerHTML = hsListHtml(hs);
+ const progress = document.getElementById('crackProgress');
+ if (progress) progress.innerHTML = crackHtml();
+ const deauthResult = document.getElementById('deauthResult');
+ if (deauthResult) {
+ deauthResult.textContent = deauthLiveText();
+ deauthResult.classList.toggle('bad', !!attack.deauthInfo?.error);
+ }
+ updateAuthUi();
+}
+
+async function pollCrackJob() {
+ if (!attack.crackJob) return;
+ const job = await api(`/api/crack?id=${encodeURIComponent(attack.crackJob)}`);
+ if (!job || job.error) {
+ attack.crackJob = null;
+ attack.crackInfo = { status: 'error', error: job?.error || 'job não encontrado' };
+ const el = document.getElementById('crackProgress');
+ if (el) el.innerHTML = crackHtml();
+ return;
+ }
+ attack.crackInfo = job;
+ if (job.status === 'running') {
+ const el = document.getElementById('crackProgress');
+ if (el) el.innerHTML = crackHtml();
+ return;
+ }
+ attack.crackJob = null;
+ const el = document.getElementById('crackProgress');
+ if (el) el.innerHTML = crackHtml();
+ refreshAfterCrack();
+}
+
+async function refreshAfterCrack() {
+ if (!selectedBssid || !current) return;
+ const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
+ if (snap?.access_points) {
+ lastSnapshot = snap;
+ renderResults(snap.access_points);
+ renderTarget(true);
+ }
+}
+
+function updateDeauthUi() {
+ const el = document.getElementById('deauthResult');
+ if (el) {
+ el.textContent = deauthLiveText();
+ el.classList.toggle('bad', !!attack.deauthInfo?.error);
+ }
+ updateAuthUi();
+}
+
+async function waitForBssidChannel(bssid, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
+ const ap = snap?.access_points?.find((a) => a.bssid?.toLowerCase() === bssid?.toLowerCase());
+ if (ap?.channel != null) return true;
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ return false;
+}
+
+async function startDeauthJob(handshake) {
+ // Fixa o alvo no início: o resto desta função faz esperas longas
+ // (religar a captura, aguardar a rede reaparecer na varredura), e nesse
+ // meio tempo o poll global pode não achar mais o AP na primeira resposta
+ // vazia da sessão recém-reiniciada e fechar o diálogo, zerando
+ // `selectedBssid`. Sem essa cópia local, o job acabava sendo disparado
+ // com bssid=null ("MAC inválido: null") mesmo depois de localizar a rede
+ // com sucesso.
+ const bssid = selectedBssid;
+ const station = document.getElementById('deauthStation')?.value || '';
+ const direction = document.getElementById('deauthDirection')?.value || attack.direction;
+ const frame = document.getElementById('deauthFrame')?.value || attack.frameKind;
+ const reason = Number(document.getElementById('deauthReason')?.value) || 7;
+ const interval = Number(document.getElementById('deauthInterval')?.value) || 200;
+ if (!handshake && direction !== 'ap' && !station) {
+ setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true);
+ return;
+ }
+ // Essa ação exige uma sessão de captura ativa (é o que guarda o handshake
+ // montado); sem isso o backend recusa com "inicie a captura antes do
+ // job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma
+ // a captura aqui — do jeito que ele descreveu querer.
+ if (!scanning) {
+ const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
+ if (scanRes && scanRes.ok === false) {
+ setResult('deauthResult', `Não foi possível retomar a captura: ${scanRes.error || 'erro desconhecido'}`, true);
+ return;
+ }
+ scanning = true;
+ els.btnScan.textContent = 'parar captura';
+ els.btnScan.classList.add('live');
+ els.wave.classList.add('scanning');
+ // A captura acabou de reiniciar: a sessão ainda não sabe em qual canal
+ // este BSSID está, e o job travaria o canal errado (o canal em que o
+ // scanner por acaso já estava saltando) em vez do canal real do AP —
+ // os quadros de deauth saem no vácuo e o job termina sem efeito. Espera
+ // o AP reaparecer no snapshot (ele já esteve no ar, é questão de
+ // segundos de varredura) antes de disparar o job.
+ setResult('deauthResult', 'Retomando a captura e localizando a rede…');
+ resumingForBssid = bssid;
+ let found;
+ try {
+ found = await waitForBssidChannel(bssid, 20_000);
+ } finally {
+ resumingForBssid = null;
+ }
+ if (!found) {
+ setResult('deauthResult', 'A rede não reapareceu na varredura a tempo. Tente de novo.', true);
+ return;
+ }
+ }
+ const q = new URLSearchParams({ iface: current, bssid, confirm: 'true' });
+ if (station) q.set('station', station);
+ if (handshake) {
+ q.set('seconds', '30');
+ } else {
+ q.set('direction', direction);
+ q.set('frame', frame);
+ q.set('reason', String(reason));
+ q.set('interval_ms', String(interval));
+ }
+ const res = await api(`/api/${handshake ? 'handshakes/capture' : 'deauth/start'}?${q}`, { method: 'POST' });
+ if (res?.error || !res?.job) {
+ setResult('deauthResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ return;
+ }
+ attack.deauthJob = res.job;
+ attack.deauthInfo = {
+ id: res.job,
+ status: 'starting',
+ frames_sent: 0,
+ station,
+ direction: handshake ? 'ap' : direction,
+ frame_kind: handshake ? 'deauth' : frame,
+ stop_on_handshake: !!handshake,
+ };
+ setResult('deauthResult', handshake ? 'Forçando reassociação e handshake…' : 'Desautenticação contínua iniciada.');
+ updateDeauthUi();
+ void pollDeauth(res.job);
+}
+
+async function stopDeauthJob() {
+ const id = attack.deauthJob;
+ if (!id) return;
+ attack.deauthJob = null;
+ const res = await api(`/api/deauth/stop?id=${encodeURIComponent(id)}`, { method: 'POST' });
+ if (attack.deauthInfo) {
+ attack.deauthInfo.status = res?.error ? 'error' : 'stopped';
+ if (res?.error) attack.deauthInfo.error = res.error;
+ }
+ updateDeauthUi();
+ if (selectedBssid) void loadSnapshot();
+}
+
+async function pollDeauth(id) {
+ const job = await api(`/api/deauth/job?id=${encodeURIComponent(id)}`);
+ if (!job || job.error) {
+ if (attack.deauthJob !== id) return;
+ attack.deauthJob = null;
+ attack.deauthInfo = { id, status: 'error', error: job?.error || 'job não encontrado', frames_sent: 0 };
+ updateDeauthUi();
+ return;
+ }
+ if (attack.deauthJob !== id) return;
+ attack.deauthInfo = job;
+ updateDeauthUi();
+ if (job.status === 'running' || job.status === 'starting') {
+ setTimeout(() => void pollDeauth(id), 800);
+ return;
+ }
+ attack.deauthJob = null;
+ updateDeauthUi();
+ if (selectedBssid) void loadSnapshot();
+}
+
+async function sendDeauth(targetStation, count, reason, resultId) {
+ const q = new URLSearchParams({
+ iface: current,
+ bssid: selectedBssid,
+ count: String(count),
+ reason: String(reason),
+ direction: attack.direction,
+ frame: attack.frameKind,
+ confirm: 'true',
+ });
+ if (targetStation) q.set('station', targetStation);
+ const res = await api(`/api/deauth?${q}`, { method: 'POST' });
+ if (res?.error) setResult(resultId, `Falha: ${res.error}`, true);
+ else setResult(resultId, `${res.frames_sent ?? count} quadro(s) ${res.frame_kind || attack.frameKind} em ${directionLabel(res.direction || attack.direction)} para ${res.station || 'broadcast'} (motivo ${res.reason ?? reason}).`);
+ return res;
+}
+
+async function pollWps(id) {
+ const job = await api(`/api/wps?id=${encodeURIComponent(id)}`);
+ if (!job || job.error) {
+ attack.wpsInfo = { status: 'error', error: job?.error || 'job não encontrado', output: [] };
+ } else {
+ attack.wpsInfo = job;
+ }
+ const pre = document.getElementById('wpsOutput');
+ if (pre) pre.textContent = (attack.wpsInfo.output || []).slice(-12).join('\n') || 'Aguardando saída do reaver/bully…';
+ const res = document.getElementById('wpsResult');
+ if (res) {
+ res.textContent = attack.wpsInfo.password
+ ? `Senha encontrada: ${attack.wpsInfo.password}${attack.wpsInfo.pin ? ` (PIN ${attack.wpsInfo.pin})` : ''}`
+ : attack.wpsInfo.error
+ ? `Falha: ${attack.wpsInfo.error}`
+ : `Status: ${jobStatusLabel(attack.wpsInfo.status)}${attack.wpsInfo.pin ? ` · PIN ${attack.wpsInfo.pin}` : ''}`;
+ res.classList.toggle('bad', !!attack.wpsInfo.error);
+ }
+ const stop = document.querySelector('[data-act="wps-stop"]');
+ const start = document.querySelector('[data-act="wps-start"]');
+ const running = attack.wpsInfo.status === 'running' || attack.wpsInfo.status === 'starting';
+ if (stop) stop.disabled = !running;
+ if (start) start.disabled = !running ? !attack.authorized || !(attack.wpsTools || []).length : true;
+ if (running) setTimeout(() => void pollWps(id), 1500);
+}
+
+async function pollEvilTwin(id) {
+ const job = await api(`/api/eviltwin?id=${encodeURIComponent(id)}`);
+ attack.eviltwinInfo = !job || job.error && !job.status
+ ? { status: 'error', error: job?.error || 'job não encontrado' }
+ : job;
+ const el = document.getElementById('eviltwinResult');
+ if (el) {
+ const attemptWord = { correct: 'senha certa', wrong: 'senha errada', invalid: 'tamanho inválido' };
+ const info = attack.eviltwinInfo;
+ el.textContent = info.password
+ ? `Senha confirmada: ${info.password}`
+ : info.error
+ ? `Falha: ${info.error}`
+ : `${jobStatusLabel(info.status)} · ${info.attempts ?? 0} tentativa(s) no portal${info.last_attempt ? ` · última: ${attemptWord[info.last_attempt] || info.last_attempt}` : ''}`;
+ el.classList.toggle('bad', !!info.error);
+ }
+ const running = attack.eviltwinInfo.status === 'running';
+ const start = document.querySelector('[data-act="eviltwin-start"]');
+ const stop = document.querySelector('[data-act="eviltwin-stop"]');
+ if (start) start.disabled = running;
+ if (stop) stop.disabled = !running;
+ if (running) setTimeout(() => void pollEvilTwin(id), 1500);
+}
+
+async function pollEnterpriseTwin(id) {
+ const job = await api(`/api/eviltwin-enterprise?id=${encodeURIComponent(id)}`);
+ attack.entTwinInfo = !job || job.error && !job.status
+ ? { status: 'error', error: job?.error || 'job não encontrado' }
+ : job;
+ const el = document.getElementById('entTwinResult');
+ if (el) {
+ const info = attack.entTwinInfo;
+ el.textContent = info.error
+ ? `Falha: ${info.error}`
+ : `${jobStatusLabel(info.status)} · ${info.captured ?? 0} credencial(is) capturada(s)${info.last_identity ? ` · última: ${info.last_identity}` : ''}`;
+ el.classList.toggle('bad', !!info.error);
+ }
+ const running = attack.entTwinInfo.status === 'running';
+ const start = document.querySelector('[data-act="enttwin-start"]');
+ const stop = document.querySelector('[data-act="enttwin-stop"]');
+ if (start) start.disabled = running;
+ if (stop) stop.disabled = !running;
+ if (running) setTimeout(() => void pollEnterpriseTwin(id), 1500);
+}
+
+els.targetClose.addEventListener('click', () => els.targetDlg.close());
+els.targetDlg.addEventListener('close', () => {
+ if (attack.deauthJob) void stopDeauthJob();
+ selectedBssid = null;
+});
+document.querySelectorAll('#targetDlg .tabs [data-tab]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ activeTab = btn.dataset.tab;
+ renderTarget(true);
+ });
+});
+
+els.tabPanel.addEventListener('click', async (e) => {
+ const jump = e.target.closest('[data-jump]');
+ if (jump) {
+ activeTab = jump.dataset.jump;
+ renderTarget(true);
+ return;
+ }
+ const timelineBtn = e.target.closest('[data-act="timeline"]');
+ if (timelineBtn) {
+ attack.timelineMac = timelineBtn.dataset.mac;
+ renderTarget(true);
+ return;
+ }
+ if (e.target.closest('[data-act="timeline-close"]')) {
+ attack.timelineMac = '';
+ renderTarget(true);
+ return;
+ }
+ const clientBtn = e.target.closest('[data-act="attack-client"]');
+ if (clientBtn) {
+ attack.station = clientBtn.dataset.mac;
+ activeTab = 'attacks';
+ renderTarget(true);
+ return;
+ }
+ const btn = e.target.closest('button[data-act]');
+ if (!btn || btn.disabled) return;
+ if (btn.dataset.needsAuth !== undefined && !attack.authorized) {
+ return;
+ }
+ const act = btn.dataset.act;
+ const ap = findAp(selectedBssid);
+ if (!ap) return;
+ if (act === 'deauth-start' || act === 'deauth-handshake') {
+ btn.disabled = true;
+ await startDeauthJob(act === 'deauth-handshake');
+ return;
+ }
+ if (act === 'deauth-stop') {
+ btn.disabled = true;
+ await stopDeauthJob();
+ return;
+ }
+ if (act === 'deauth') {
+ const station = document.getElementById('deauthStation')?.value || '';
+ const reason = Number(document.getElementById('deauthReason')?.value) || 7;
+ btn.disabled = true;
+ await sendDeauth(station, Math.min(256, Math.max(1, attack.count)), reason, 'deauthResult');
+ btn.disabled = !attack.authorized;
+ return;
+ }
+ if (act === 'export-hs' || act === 'export-all') {
+ const out = document.getElementById('hsOut')?.value?.trim();
+ if (!out) return setResult('exportResult', 'Informe o caminho do .pcap.', true);
+ const station = document.getElementById('hsStation')?.value || '';
+ const q = new URLSearchParams({ iface: current, out });
+ if (act === 'export-hs') {
+ if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
+ q.set('bssid', selectedBssid);
+ q.set('station', station);
+ }
+ btn.disabled = true;
+ const res = await api(`/api/handshakes/${act === 'export-all' ? 'export-all' : 'export'}?${q}`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
+ else setResult('exportResult', `${res.handshakes ?? 0} handshake(s), ${res.frames ?? 0} quadro(s) em ${res.path}`);
+ return;
+ }
+ if (act === 'export-hc' || act === 'export-all-hc') {
+ const base = document.getElementById('hsOut')?.value?.trim();
+ if (!base) return setResult('exportResult', 'Informe o caminho base do arquivo.', true);
+ const out = base.replace(/\.(pcap|pcapng)$/i, '') + '.hc22000';
+ const station = document.getElementById('hsStation')?.value || '';
+ const q = new URLSearchParams({ iface: current, out });
+ if (act === 'export-hc') {
+ if (!station) return setResult('exportResult', 'Selecione um handshake.', true);
+ q.set('bssid', selectedBssid);
+ q.set('station', station);
+ }
+ btn.disabled = true;
+ const res = await api(`/api/handshakes/${act === 'export-hc' ? 'export-hc22000' : 'export-all-hc22000'}?${q}`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error) setResult('exportResult', `Falha: ${res.error}`, true);
+ else setResult('exportResult', `${res.hashes ?? 0} hash(es) de ${res.handshakes ?? 0} handshake(s) em ${res.path}`);
+ return;
+ }
+ if (act === 'pmkid') {
+ const attempts = Math.min(50, Math.max(1, Number(document.getElementById('pmkidAttempts')?.value) || 5));
+ btn.disabled = true;
+ setResult('pmkidResult', 'Enviando associação com PMKID…');
+ const res = await api(`/api/pmkid?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&attempts=${attempts}&confirm=true`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error) return setResult('pmkidResult', `Falha: ${res.error}`, true);
+ setResult('pmkidResult', res.pmkid
+ ? `PMKID capturado: ${res.pmkid}`
+ : `${res.frames_sent ?? 0} quadro(s) enviados; PMKID não apareceu. ${res.detail || ''}`);
+ attack.pmkidInfo = res;
+ return;
+ }
+ if (act === 'pmf') {
+ const station = document.getElementById('pmfStation')?.value;
+ if (!station) return setResult('pmfResult', 'Nenhuma estação disponível.', true);
+ btn.disabled = true;
+ setResult('pmfResult', 'Testando por ~4 s…');
+ const res = await api(`/api/pmf?iface=${encodeURIComponent(current)}&bssid=${encodeURIComponent(selectedBssid)}&station=${encodeURIComponent(station)}&reason=7&confirm=true`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error) return setResult('pmfResult', `Falha: ${res.error}`, true);
+ const verdict = res.verdict === 'pmf_off' ? 'PMF não obrigatório' : 'Inconclusivo';
+ setResult('pmfResult', `${verdict} · ${res.detail}`);
+ attack.pmfInfo = res;
+ return;
+ }
+ if (act === 'wps-start') {
+ const tool = document.getElementById('wpsTool')?.value;
+ if (!tool) return setResult('wpsResult', 'Nenhuma ferramenta WPS disponível.', true);
+ if (ap.channel == null) return setResult('wpsResult', 'Canal do AP desconhecido.', true);
+ const pin = document.getElementById('wpsPin')?.value?.trim() || '';
+ const q = new URLSearchParams({ iface: current, bssid: selectedBssid, channel: String(ap.channel), tool, confirm: 'true' });
+ if (pin) q.set('pin', pin);
+ btn.disabled = true;
+ const res = await api(`/api/wps?${q}`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error || !res?.job) {
+ setResult('wpsResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ return;
+ }
+ attack.wpsJob = res.job;
+ attack.wpsInfo = { status: 'starting', output: [] };
+ setResult('wpsResult', 'Job iniciado…');
+ void pollWps(res.job);
+ return;
+ }
+ if (act === 'wps-stop') {
+ if (!attack.wpsJob) return;
+ btn.disabled = true;
+ const res = await api(`/api/wps/stop?id=${encodeURIComponent(attack.wpsJob)}`, { method: 'POST' });
+ if (res?.error) setResult('wpsResult', `Falha: ${res.error}`, true);
+ else setResult('wpsResult', 'Parada solicitada.');
+ return;
+ }
+ if (act === 'eviltwin-start') {
+ const apIface = document.getElementById('eviltwinIface')?.value?.trim() || '';
+ if (!apIface) return setResult('eviltwinResult', 'Informe a interface do clone (uma segunda placa).', true);
+ const port = Math.min(65535, Math.max(1, Number(document.getElementById('eviltwinPort')?.value) || 8080));
+ const deauth = document.getElementById('eviltwinDeauth')?.checked || false;
+ attack.eviltwinApIface = apIface;
+ attack.eviltwinPortalPort = port;
+ attack.eviltwinDeauth = deauth;
+ const q = new URLSearchParams({
+ iface: current, ap_iface: apIface, bssid: selectedBssid,
+ portal_port: String(port), deauth: String(deauth), confirm: 'true',
+ });
+ btn.disabled = true;
+ const res = await api(`/api/eviltwin?${q}`, { method: 'POST' });
+ if (res?.error || !res?.job) {
+ btn.disabled = !attack.authorized;
+ return setResult('eviltwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ }
+ attack.eviltwinJob = res.job;
+ attack.eviltwinInfo = { status: 'running', attempts: 0 };
+ void pollEvilTwin(res.job);
+ return;
+ }
+ if (act === 'eviltwin-stop') {
+ if (!attack.eviltwinJob) return;
+ btn.disabled = true;
+ const res = await api(`/api/eviltwin/stop?id=${encodeURIComponent(attack.eviltwinJob)}`, { method: 'POST' });
+ if (res?.error) setResult('eviltwinResult', `Falha: ${res.error}`, true);
+ else setResult('eviltwinResult', 'Parada solicitada.');
+ return;
+ }
+ if (act === 'enttwin-start') {
+ const apIface = document.getElementById('entTwinIface')?.value?.trim() || '';
+ if (!apIface) return setResult('entTwinResult', 'Informe a interface do clone (uma segunda placa).', true);
+ attack.entTwinApIface = apIface;
+ const q = new URLSearchParams({ iface: current, ap_iface: apIface, bssid: selectedBssid, confirm: 'true' });
+ btn.disabled = true;
+ const res = await api(`/api/eviltwin-enterprise?${q}`, { method: 'POST' });
+ if (res?.error || !res?.job) {
+ btn.disabled = !attack.authorized;
+ return setResult('entTwinResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ }
+ attack.entTwinJob = res.job;
+ attack.entTwinInfo = { status: 'running', captured: 0 };
+ void pollEnterpriseTwin(res.job);
+ return;
+ }
+ if (act === 'enttwin-stop') {
+ if (!attack.entTwinJob) return;
+ btn.disabled = true;
+ const res = await api(`/api/eviltwin-enterprise/stop?id=${encodeURIComponent(attack.entTwinJob)}`, { method: 'POST' });
+ if (res?.error) setResult('entTwinResult', `Falha: ${res.error}`, true);
+ else setResult('entTwinResult', 'Parada solicitada.');
+ return;
+ }
+ if (act === 'crack') {
+ const wordlist = document.getElementById('wordlist')?.value?.trim() || '';
+ const station = document.getElementById('hsStation')?.value || '';
+ const rules = document.getElementById('crackRules')?.checked || false;
+ attack.crackRules = rules;
+ const q = new URLSearchParams({ iface: current, bssid: selectedBssid, rules: String(rules) });
+ if (wordlist) q.set('wordlist', wordlist);
+ if (station) q.set('station', station);
+ btn.disabled = true;
+ const res = await api(`/api/crack?${q}`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error || !res?.job) {
+ setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ } else {
+ attack.crackJob = res.job;
+ attack.crackInfo = { status: 'running', tried: 0 };
+ const progress = document.getElementById('crackProgress');
+ if (progress) progress.innerHTML = crackHtml();
+ }
+ return;
+ }
+ if (act === 'crack-mask') {
+ const mask = document.getElementById('crackMask')?.value?.trim() || '';
+ if (!mask) return setResult('exportResult', 'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).', true);
+ attack.crackMask = mask;
+ const station = document.getElementById('hsStation')?.value || '';
+ const q = new URLSearchParams({ iface: current, bssid: selectedBssid, mask });
+ if (station) q.set('station', station);
+ btn.disabled = true;
+ const res = await api(`/api/crack/mask?${q}`, { method: 'POST' });
+ btn.disabled = !attack.authorized;
+ if (res?.error || !res?.job) {
+ setResult('exportResult', `Falha: ${res?.error || 'job não iniciado'}`, true);
+ } else {
+ attack.crackJob = res.job;
+ attack.crackInfo = { status: 'running', tried: 0 };
+ const progress = document.getElementById('crackProgress');
+ if (progress) progress.innerHTML = crackHtml();
+ }
+ return;
+ }
+ if (act === 'wep-restart') {
+ const out = document.getElementById('wepOut')?.value?.trim();
+ if (!out) return setResult('wepResult', 'Informe o caminho do .pcap.', true);
+ btn.disabled = true;
+ await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
+ const q = new URLSearchParams({ iface: current, pcap_out: out });
+ if (ap.channel != null) q.set('channel', String(ap.channel));
+ const res = await api(`/api/scan/start?${q}`, { method: 'POST' });
+ if (res?.error) {
+ setResult('wepResult', `Falha: ${res.error}`, true);
+ } else {
+ scanning = true;
+ lastSnapshot = null;
+ lastAps = [];
+ els.btnScan.textContent = 'parar captura';
+ els.btnScan.classList.add('live');
+ els.wave.classList.add('scanning');
+ setResult('wepResult', `Captura gravando em ${out}. Gere tráfego e aguarde IVs suficientes.`);
+ }
+ btn.disabled = !attack.authorized;
+ return;
+ }
+ if (act === 'wep-deauth') {
+ btn.disabled = true;
+ await sendDeauth('', 10, 7, 'wepResult');
+ btn.disabled = !attack.authorized;
+ return;
+ }
+ if (act === 'wep-copy') {
+ const out = document.getElementById('wepOut')?.value?.trim() || 'captura.pcap';
+ const cmd = `aircrack-ng ${out}`;
+ try {
+ await navigator.clipboard.writeText(cmd);
+ setResult('wepResult', 'Comando copiado.');
+ } catch {
+ setResult('wepResult', cmd);
+ }
+ }
+});
+
+els.tabPanel.addEventListener('input', (e) => {
+ const t = e.target;
+ if (t.id === 'deauthCount') attack.count = Number(t.value) || 1;
+ else if (t.id === 'hsOut') attack.hsOut = t.value;
+ else if (t.id === 'wordlist') attack.wordlist = t.value;
+ else if (t.id === 'crackMask') attack.crackMask = t.value;
+ else if (t.id === 'eviltwinIface') attack.eviltwinApIface = t.value;
+ else if (t.id === 'eviltwinPort') attack.eviltwinPortalPort = Number(t.value) || 8080;
+ else if (t.id === 'entTwinIface') attack.entTwinApIface = t.value;
+ else if (t.id === 'wepOut') attack.wepOut = t.value;
+});
+
+els.tabPanel.addEventListener('change', (e) => {
+ const t = e.target;
+ if (t.id === 'authChk') {
+ attack.authorized = t.checked;
+ updateAuthUi();
+ } else if (t.id === 'crackRules') attack.crackRules = t.checked;
+ else if (t.id === 'eviltwinDeauth') attack.eviltwinDeauth = t.checked;
+ else if (t.id === 'deauthStation') attack.station = t.value;
+ else if (t.id === 'deauthDirection') {
+ attack.direction = t.value;
+ const scope = document.getElementById('deauthScope');
+ if (scope) {
+ scope.textContent = attack.direction === 'sta'
+ ? 'Do cliente para o AP (exige estação selecionada).'
+ : attack.direction === 'both'
+ ? 'Nas duas direções (exige estação selecionada).'
+ : attack.station
+ ? 'Somente o cliente selecionado será desautenticado.'
+ : 'Todos os clientes associados (broadcast).';
+ scope.classList.toggle('bad', attack.direction !== 'ap' && !attack.station);
+ }
+ updateAuthUi();
+ } else if (t.id === 'deauthFrame') attack.frameKind = t.value;
+ else if (t.id === 'deauthInterval') attack.intervalMs = Number(t.value) || 200;
+ else if (t.id === 'deauthReason') attack.reason = Number(t.value) || 7;
+ else if (t.id === 'hsStation') attack.hsStation = t.value;
+ else if (t.id === 'wpsTool') attack.wpsTool = t.value;
+});
+
+
+function reportUrl(iface, download) {
+ const url = new URL('/api/report.html', location.origin);
+ url.searchParams.set('iface', iface);
+ url.searchParams.set('token', token);
+ if (download) url.searchParams.set('download', '1');
+ return url.toString();
+}
+
+function reportCanvasSize(pages) {
+ reportState.pages = pages || 1;
+ const w = 794;
+ const h = Math.max(1123, reportState.pages * 1123);
+ els.reportCanvas.style.width = `${w}px`;
+ els.reportCanvas.style.height = `${h}px`;
+ els.reportFrame.style.width = `${w}px`;
+ els.reportFrame.style.height = `${h}px`;
+ applyReportZoom();
+}
+
+function applyReportZoom() {
+ els.reportCanvas.style.transform = `scale(${reportState.zoom})`;
+ els.reportZoomLabel.textContent = `${Math.round(reportState.zoom * 100)}%`;
+}
+
+async function openReport() {
+ if (!current) return;
+ reportState.iface = current;
+ els.reportStatus.textContent = 'Montando o relatório…';
+ if (!els.reportDlg.open) els.reportDlg.showModal();
+ if (tauri) {
+ const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}`);
+ if (res?.error || !res?.html) {
+ els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o relatório'}`;
+ return;
+ }
+ els.reportFrame.removeAttribute('src');
+ els.reportFrame.srcdoc = res.html;
+ reportState.url = null;
+ } else {
+ const url = reportUrl(current, false);
+ reportState.url = url;
+ els.reportFrame.removeAttribute('srcdoc');
+ els.reportFrame.src = url;
+ }
+ reportCanvasSize(1);
+}
+
+function measureReport() {
+ let doc = null;
+ try { doc = els.reportFrame.contentDocument; } catch { return; }
+ if (!doc) return;
+ const pages = doc.querySelectorAll('.pagedjs_page').length;
+ if (pages) {
+ reportCanvasSize(pages);
+ els.reportStatus.textContent = `Pré-visualização A4 · ${pages} página(s).`;
+ return;
+ }
+ const height = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0);
+ if (height > 400) {
+ reportCanvasSize(Math.ceil(height / 1123));
+ els.reportStatus.textContent = 'Pré-visualização A4.';
+ }
+}
+
+function scheduleReportMeasure() {
+ if (reportMeasureTimer) clearInterval(reportMeasureTimer);
+ let attempts = 0;
+ const tick = () => {
+ attempts++;
+ measureReport();
+ if (attempts >= 12) {
+ clearInterval(reportMeasureTimer);
+ reportMeasureTimer = null;
+ }
+ };
+ reportMeasureTimer = setInterval(tick, 500);
+ setTimeout(tick, 300);
+}
+
+els.reportFrame.addEventListener('load', () => {
+ if (reportState.url) els.reportStatus.textContent = 'Paginando o relatório…';
+ scheduleReportMeasure();
+});
+
+window.addEventListener('message', (event) => {
+ const data = event.data;
+ if (!data || data.type !== 'sabia-report-ready') return;
+ reportCanvasSize(Number(data.pages) || 1);
+ els.reportStatus.textContent = `Pré-visualização A4 · ${reportState.pages} página(s).`;
+});
+
+els.btnReport.addEventListener('click', () => void openReport());
+els.reportClose.addEventListener('click', () => els.reportDlg.close());
+els.reportZoomIn.addEventListener('click', () => {
+ reportState.zoom = Math.min(1.5, Math.round((reportState.zoom + 0.1) * 10) / 10);
+ applyReportZoom();
+});
+els.reportZoomOut.addEventListener('click', () => {
+ reportState.zoom = Math.max(0.4, Math.round((reportState.zoom - 0.1) * 10) / 10);
+ applyReportZoom();
+});
+els.reportPrint.addEventListener('click', () => {
+ try {
+ els.reportFrame.contentWindow.focus();
+ els.reportFrame.contentWindow.print();
+ } catch {
+ els.reportStatus.textContent = 'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.';
+ }
+});
+els.reportDownload.addEventListener('click', async () => {
+ if (!current) return;
+ if (tauri) {
+ els.reportStatus.textContent = 'Preparando o arquivo…';
+ const res = await api(`/api/report.html?iface=${encodeURIComponent(current)}&download=true`);
+ if (!res?.html) {
+ els.reportStatus.textContent = `Falha: ${res?.error || 'não foi possível gerar o HTML'}`;
+ return;
+ }
+ const blob = new Blob([res.html], { type: 'text/html;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `sabia-${current.replace(/[^0-9a-z]/gi, '')}.html`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
+ els.reportStatus.textContent = 'HTML baixado — abre offline e imprime em A4.';
+ } else {
+ window.open(reportUrl(current, true), '_blank', 'noopener');
+ }
+});
+els.reportDlg.addEventListener('close', () => {
+ if (reportMeasureTimer) clearInterval(reportMeasureTimer);
+ reportMeasureTimer = null;
+ if (!tauri) els.reportFrame.removeAttribute('src');
+ else els.reportFrame.removeAttribute('srcdoc');
+});
+
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
+function escapeAttr(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+ }[c]));
+}
+
let waveT = 0;
function animateWave() {
waveT += 0.12;
@@ -230,9 +2368,39 @@ function animateWave() {
async function poll() {
if (scanning && current) {
- const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
- renderResults(aps);
+ const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
+ if (snap?.access_points && snap.running === false) {
+ // A sessão existe e respondeu normal (200), mas o próprio engine
+ // marcou running=false: a thread de captura morreu sozinha (erro de
+ // I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando
+ // que ainda estava capturando e nunca contava o motivo real.
+ scanning = false;
+ els.btnScan.textContent = 'iniciar captura';
+ els.btnScan.classList.remove('live');
+ els.wave.classList.remove('scanning');
+ lastSnapshot = snap;
+ renderResults(snap.access_points);
+ alert('A captura parou sozinha: ' + (snap.error || 'motivo desconhecido — confira se o adaptador ainda está conectado.'));
+ } else if (snap?.access_points) {
+ lastSnapshot = snap;
+ renderResults(snap.access_points);
+ if (selectedBssid) renderTarget(false);
+ } else if (snap && snap.ok === false) {
+ // A captura morreu no meio (interface saiu do monitor, processo externo
+ // tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que
+ // sempre responde 200 com lista vazia — a UI continuava "escutando o
+ // ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê.
+ scanning = false;
+ els.btnScan.textContent = 'iniciar captura';
+ els.btnScan.classList.remove('live');
+ els.wave.classList.remove('scanning');
+ alert('A captura parou: ' + (snap.error || 'erro desconhecido'));
+ } else {
+ const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
+ renderResults(aps);
+ }
}
+ if (attack.crackJob) pollCrackJob();
setTimeout(poll, 1000);
}
@@ -265,6 +2433,7 @@ async function checkElevation() {
(async function init() {
applyLangPref();
+ setView(viewMode);
checkElevation();
const status = await api('/api/version');
els.verInfo.textContent = `v${status.version}`;
diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js
index fb90797..5b152e6 100644
--- a/apps/gui/ui/i18n-en.js
+++ b/apps/gui/ui/i18n-en.js
@@ -2,8 +2,12 @@
export const EN = {
// index.html / sidebar
'auditoria de Wi-Fi': 'Wi-Fi auditing',
+ 'Interface': 'Interface',
+ 'Interface Wi-Fi': 'Wi-Fi interface',
'Interfaces': 'Interfaces',
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
+ 'APs, clientes e ataques': 'APs, clients and attacks',
+ 'espectro e saúde': 'spectrum and health',
'checar atualização': 'check for update',
'checando…': 'checking…',
'atualizar para {n}': 'update to {n}',
@@ -30,7 +34,12 @@ export const EN = {
'parar captura': 'stop capture',
// resultados
+ 'Redes': 'Networks',
'Redes vistas': 'Networks seen',
+ 'Linha do tempo': 'Timeline',
+ 'Linha do tempo ·': 'Timeline ·',
+ 'fechar': 'close',
+ 'motivo {n}': 'reason {n}',
'Pronto pra capturar': 'Ready to capture',
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
@@ -41,16 +50,44 @@ export const EN = {
'oculta': 'hidden',
'Rede': 'Network',
'Canal': 'Channel',
+ 'Canais': 'Channels',
+ 'Banda': 'Band',
+ 'Bandas': 'Bands',
+ 'Nome': 'Name',
'Sinal': 'Signal',
'Segurança': 'Security',
'Clientes': 'Clients',
+ 'filtrar por SSID ou BSSID': 'filter by SSID or BSSID',
+ 'Filtrar redes': 'Filter networks',
+ 'Ordenar por': 'Sort by',
+ 'expandir': 'expand',
+ 'recolher': 'collapse',
+ 'PMF parcial': 'partial PMF',
+ 'canal {n}': 'channel {n}',
+ 'canal {n} · {n}': 'channel {n} · {n}',
+ '1 cliente': '1 client',
+ '{n} clientes': '{n} clients',
+ '{n} de {n} redes': '{n} of {n} networks',
+ '{n} redes · {n} BSSIDs · {n} clientes': '{n} networks · {n} BSSIDs · {n} clients',
+ 'Nenhuma rede corresponde ao filtro': 'No network matches the filter',
+ 'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.',
// erros de UI
'Não foi possível trocar o modo:': 'Could not change the mode:',
+ 'Não foi possível iniciar a captura:': 'Could not start the capture:',
+ 'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}',
+ 'Retomando a captura e localizando a rede…': 'Resuming the capture and locating the network…',
+ 'A rede não reapareceu na varredura a tempo. Tente de novo.': 'The network did not reappear in the scan in time. Try again.',
+ 'A captura parou:': 'The capture stopped:',
'erro desconhecido': 'unknown error',
'rota desconhecida': 'unknown route',
- // seletor de idioma
+ // seletor de idioma / configurações
+ 'Configurações': 'Settings',
+ 'Tema': 'Theme',
+ 'Claro': 'Light',
+ 'Escuro': 'Dark',
+ 'Fechar': 'Close',
'Idioma': 'Language',
'Auto': 'Auto',
'Português': 'Portuguese',
@@ -95,4 +132,434 @@ export const EN = {
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
'MAC inválido: {n}': 'invalid MAC: {n}',
+
+ // detalhes do alvo e ataques
+ 'não': 'no',
+ 'não anunciado': 'not advertised',
+ 'quebrável': 'crackable',
+ 'Nenhum handshake capturado para este BSSID.': 'No handshake captured for this BSSID.',
+ 'Rede aberta.': 'Open network.',
+ 'O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.': 'Traffic is not encrypted over Wi-Fi; any client can associate.',
+ 'A cifra é quebrável; capture IVs na aba Ataques.': 'The cipher is breakable; capture IVs in the Attacks tab.',
+ 'O AP anuncia configuração travada.': 'The AP advertises configuration locked.',
+ 'PIN pode estar acessível; verifique o lock.': 'The PIN may be accessible; check the lock.',
+ 'Desautenticação e captura de handshake podem ser ignoradas pelos clientes.': 'Deauthentication and handshake capture may be ignored by clients.',
+ 'Sem PMF obrigatório.': 'No mandatory PMF.',
+ 'Desautenticação tende a funcionar e facilita a captura do handshake.': 'Deauthentication tends to work and makes handshake capture easier.',
+ 'País': 'Country',
+ 'Último visto': 'Last seen',
+ 'Primeiro visto': 'First seen',
+ 'Frequência': 'Frequency',
+ 'Largura': 'Width',
+ 'Padrão': 'Standard',
+ 'Sinal máximo': 'Max signal',
+ 'Intervalo de beacon': 'Beacon interval',
+ 'Quadros de dados': 'Data frames',
+ 'Versão WPS': 'WPS version',
+ 'Vistos na rede': 'Seen on network',
+ 'Quebráveis': 'Crackable',
+ 'Número do modelo': 'Model number',
+ 'Nome do dispositivo': 'Device name',
+ 'Serial': 'Serial',
+ 'Identificação': 'Identity',
+ 'Rádio': 'Radio',
+ 'sim': 'yes',
+ 'Nome exibido': 'Display name',
+ 'Hostname': 'Hostname',
+ 'Sistema': 'OS',
+ 'IP': 'IP',
+ 'Associado a': 'Associated to',
+ 'Atual': 'Current',
+ 'Máximo': 'Maximum',
+ 'Sem amostras de sinal suficientes ainda.': 'Not enough signal samples yet.',
+ 'Linha do tempo · eventos mais recentes primeiro.': 'Timeline · most recent events first.',
+ 'Cliente · {n}': 'Client · {n}',
+ 'A captura parou sozinha:': 'The capture stopped on its own:',
+ 'A placa não está capturando nada': 'The adapter is not capturing anything',
+ 'Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção.':
+ 'No frame received after several seconds of channel hopping — this adapter may accept turning monitor mode on without actually being able to capture (common with a laptop\'s built-in Wi-Fi). Try a USB adapter dedicated to monitor mode/injection.',
+ 'motivo desconhecido — confira se o adaptador ainda está conectado.':
+ 'unknown reason — check whether the adapter is still connected.',
+ 'Clientes e handshake': 'Clients and handshake',
+ 'Nenhum cliente visto.': 'No client seen.',
+ 'Associe ou gere tráfego na rede para que o Sabiá identifique estações.': 'Associate or generate traffic on the network so Sabiá can identify stations.',
+ 'senha encontrada': 'password found',
+ 'concluído': 'done',
+ 'quebrando': 'cracking',
+ 'senhas testadas': 'passwords tried',
+ '{n} senhas testadas': '{n} passwords tried',
+ '{n} tentativas · {n} ms': '{n} attempts · {n} ms',
+ 'Senha não encontrada no dicionário ({n} tentativas).': 'Password not found in the wordlist ({n} attempts).',
+ '· quebrável': '· crackable',
+ 'não especificado': 'unspecified',
+ 'autenticação expirada': 'authentication expired',
+ 'saindo': 'leaving',
+ 'inativo': 'inactive',
+ 'AP sem recursos': 'AP out of resources',
+ 'classe 2': 'class 2',
+ 'classe 3': 'class 3',
+ '{n} handshake(s) visto(s); {n} quebrável(is).': '{n} handshake(s) seen; {n} crackable.',
+ 'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.': 'No handshake captured yet. Deauthenticate a client to trigger reassociation.',
+ 'Captura WEP': 'WEP capture',
+ 'WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.': 'WEP has no handshake: the attack collects IVs into a .pcap. Restart the capture writing the file and generate traffic with deauthentication. The current capture is restarted and the table starts empty.',
+ 'Arquivo pcap': 'pcap file',
+ 'Reiniciar captura com pcap': 'Restart capture to pcap',
+ 'Gerar tráfego': 'Generate traffic',
+ 'Copiar comando': 'Copy command',
+ 'Esta rede não usa WEP.': 'This network does not use WEP.',
+ 'Somente em redes próprias ou com autorização escrita.': 'Only on your own networks or with written authorization.',
+ 'Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.': 'Every action is logged in the local audit trail with interface, BSSID and station.',
+ 'Tenho autorização para testar esta rede': 'I have authorization to test this network',
+ 'Desautenticação': 'Deauthentication',
+ 'Expulsa uma estação (ou todas) para forçar reassociação e captura de handshake.': 'Kicks a station (or all) to force reassociation and handshake capture.',
+ 'Enviar desautenticação': 'Send deauthentication',
+ 'Forçar handshake': 'Force handshake',
+ 'Dicionário': 'Wordlist',
+ 'Rede oculta': 'Hidden network',
+ 'job não encontrado': 'job not found',
+ '{n} quadro(s) enviados para {n} (motivo {n}).': '{n} frame(s) sent to {n} (reason {n}).',
+ 'Selecione um handshake.': 'Select a handshake.',
+ 'Informe o dicionário.': 'Enter the wordlist.',
+ 'Informe o caminho do .pcap.': 'Enter the .pcap path.',
+ 'Força bruta por máscara — sem dicionário, testa toda combinação do padrão.':
+ 'Mask brute force — no wordlist, tests every combination of the pattern.',
+ 'dígito,': 'digit,',
+ 'minúscula,': 'lowercase,',
+ 'maiúscula,': 'uppercase,',
+ 'símbolo,': 'symbol,',
+ 'qualquer um; o resto é literal.': 'any of those; the rest is literal.',
+ 'Máscara': 'Mask',
+ 'Força bruta por máscara': 'Mask brute force',
+ 'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).': 'Enter a mask (e.g. ?d?d?d?d?d?d?d?d).',
+ 'job não iniciado': 'job not started',
+ 'Captura gravando em {n}. Gere tráfego e aguarde IVs suficientes.': 'Capture writing to {n}. Generate traffic and wait for enough IVs.',
+ 'Visão geral': 'Overview',
+ 'Ver detalhes': 'View details',
+ 'Ver clientes': 'View clients',
+ 'Abrir ataques': 'Open attacks',
+ 'Todos (broadcast)': 'All (broadcast)',
+ 'associado': 'associated',
+ 'sondando': 'probing',
+ 'visto': 'seen',
+ 'Alvo': 'Target',
+ 'Handshake': 'Handshake',
+ 'Salvar pcap em': 'Save pcap to',
+ 'Exportar selecionado': 'Export selected',
+ 'Exportar todos': 'Export all',
+ 'Quebrar senha': 'Crack password',
+ 'completo': 'complete',
+ 'Ataques': 'Attacks',
+ 'Cliente alvo': 'Target client',
+ 'Ações': 'Actions',
+ 'Atacar': 'Attack',
+ 'Cliente não associado a este AP.': 'Client not associated with this AP.',
+ 'Somente o cliente selecionado será desautenticado.': 'Only the selected client will be deauthenticated.',
+ 'Todos os clientes associados (broadcast).': 'All associated clients (broadcast).',
+ 'Fabricante': 'Manufacturer',
+ 'Modelo': 'Model',
+ 'Oculta': 'Hidden',
+ 'Modo': 'Mode',
+ 'Cifra': 'Cipher',
+ 'Privacidade': 'Privacy',
+ 'WPS travado': 'WPS locked',
+ 'Associados': 'Associated',
+ 'Senha': 'Password',
+ 'Quadro': 'Frame',
+ 'Quadros': 'Frames',
+ 'ambas': 'both',
+ 'Tentativas': 'Attempts',
+ 'Testar PMF': 'Test PMF',
+ 'em execução': 'running',
+ 'iniciando': 'starting',
+ 'erro': 'error',
+ 'parado': 'stopped',
+ 'Status: {n}': 'Status: {n}',
+ 'tamanho inválido': 'invalid length',
+ '· última: {n}': '· last: {n}',
+ 'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".':
+ 'Clones the AP (open) on a second interface and shows a portal asking for the password again. The password is only accepted if it truly matches the already-captured handshake — it is not an "accept anything" page.',
+ 'Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de':
+ 'Requires a crackable handshake already captured for this AP (Handshake tab). Needs',
+ 'instalado e de uma': 'installed and a',
+ 'interface Wi-Fi em modo AP, diferente da que está capturando.':
+ 'Wi-Fi interface in AP mode, different from the one capturing.',
+ 'Iniciar Evil Twin': 'Start Evil Twin',
+ 'Informe a interface do clone (uma segunda placa).':
+ 'Enter the clone interface (a second adapter).',
+ 'a interface do clone precisa ser diferente da que está capturando':
+ 'the clone interface must be different from the one capturing',
+ 'esse BSSID ainda não apareceu nesta sessão de captura':
+ 'that BSSID has not appeared in this capture session yet',
+ 'rede oculta: sem SSID não dá pra clonar': 'hidden network: no SSID, cannot clone',
+ 'handshake sem dados suficientes pra conferir senha':
+ 'handshake does not have enough data to verify a password',
+ 'hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin':
+ 'hostapd is not installed (sudo apt install hostapd) — needed for Evil Twin',
+ '{n} · {n} credencial(is) capturada(s){n}': '{n} · {n} credential(s) captured{n}',
+ 'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.':
+ 'Clones the AP on a second interface and lets hostapd-wpe accept the EAP authentication from any client. The credential comes out on its own, as part of 802.1X itself — no portal, no human action.',
+ 'Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige':
+ 'Enterprise network detected (AKM 802.1X) — here the credential is sent automatically by the client during the EAP handshake, no portal needed. Requires',
+ 'instalado (não vem com o Sabiá: é um patch no build do hostapd —':
+ 'installed (not bundled with Sabiá: it is a patch on the hostapd build —',
+ ') e uma': ') and a',
+ 'interface Wi-Fi em modo AP.': 'Wi-Fi interface in AP mode.',
+ 'Iniciar Evil Twin Enterprise': 'Start Enterprise Evil Twin',
+ 'Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.':
+ 'Honest limitation: the clone\'s TLS certificate is self-signed. A device that validates the network\'s certificate chain (common on an updated corporate/MDM profile) will refuse or warn the user — that is the defense that is supposed to exist. It works against configurations without that validation.',
+ 'essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo':
+ 'this network is not WPA2/WPA3-Enterprise (802.1X) — for a single-password (PSK) network, use the Evil Twin with captive portal',
+ 'hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe':
+ 'hostapd-wpe is not installed — not bundled with Sabiá (it is a patch on the hostapd build, not a small binary); install it from the Kali repository or build https://github.com/aircrack-ng/hostapd-wpe',
+ '{n} está num modo que o hostapd-wpe não consegue usar':
+ '{n} is in a mode hostapd-wpe cannot use',
+ 'não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe':
+ 'could not generate the TLS certificate (openssl) for hostapd-wpe',
+ 'hostapd-wpe saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
+ 'hostapd-wpe exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
+ 'job de Evil Twin Enterprise não encontrado': 'Enterprise Evil Twin job not found',
+ 'job de Evil Twin não encontrado': 'Evil Twin job not found',
+ 'hostapd saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
+ 'hostapd exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
+ '{n} está num modo que o hostapd não consegue usar': '{n} is in a mode hostapd cannot use',
+ '{n} handshake(s), {n} quadro(s) em {n}': '{n} handshake(s), {n} frame(s) in {n}',
+ 'Motivo': 'Reason',
+ 'Pacotes': 'Packets',
+ 'Estado': 'State',
+ 'Probes': 'Probes',
+ 'Cliente': 'Client',
+ 'WPS ativo': 'WPS active',
+ 'WPS ativo.': 'WPS active.',
+ 'WPS ativo ({n}).': 'WPS active ({n}).',
+ 'PMF exigido.': 'PMF required.',
+ 'Depois de coletar IVs suficientes:': 'After collecting enough IVs:',
+ 'mensagens {n}': 'messages {n}',
+ '{n} · mensagens {n}': '{n} · messages {n}',
+ 'senha: {n}': 'password: {n}',
+ '{n} · quebrável': '{n} · crackable',
+ '{n} · canal {n}': '{n} · channel {n}',
+ '{n} ({n})': '{n} ({n})',
+
+ // canais, PMKID/PMF, WPS e relatório
+ 'Ocupação dos canais em {n}': 'Channel occupancy on {n}',
+ 'Sem dados de canal ainda': 'No channel data yet',
+ 'Inicie a captura para o Sabiá medir ocupação e sugerir canais.':
+ 'Start capturing so Sabiá can measure occupancy and suggest channels.',
+ '{n} APs · {n} canais ocupados': '{n} APs · {n} busy channels',
+ '{n} AP(s) · recomendado': '{n} AP(s) · recommended',
+ '· recomendado': '· recommended',
+ 'aberta/WEP': 'open/WEP',
+ '{n} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.':
+ '{n} WPA3 network(s) analyzed — details in the target tab and report.',
+ 'Restrito a 1, 6 e 11 para evitar sobreposição.':
+ 'Limited to 1, 6 and 11 to avoid overlap.',
+ 'Menor pontuação de interferência entre os canais observados.':
+ 'Lowest interference score among observed channels.',
+ 'Melhor canal': 'Best channel',
+ 'Espectro e canais': 'Spectrum and channels',
+
+ // saúde do espectro (canais, estilo SMART)
+ 'Boa': 'Good',
+ 'Atenção': 'Attention',
+ 'Saturado': 'Saturated',
+ 'Boa condição': 'Good condition',
+ 'Sem canal livre nessa banda': 'No free channel on this band',
+ 'Até o melhor canal está saturado': 'Even the best channel is saturated',
+ 'Recomendado é DFS — pode cair com radar por perto': 'The recommended channel is DFS — it can drop off if radar is detected nearby',
+ 'Espectro congestionado': 'Congested spectrum',
+ 'Canal recomendado': 'Recommended channel',
+ 'Canais ocupados': 'Busy channels',
+ 'APs na banda': 'APs on this band',
+ 'Pior canal': 'Worst channel',
+ 'Canais DFS': 'DFS channels',
+ 'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.':
+ 'On this band, always prefer 1, 6 or 11 — channels in between overlap neighbors.',
+ 'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.':
+ 'DFS channels free up more room, but the radio must go off the air if it detects radar — keep a non-DFS channel as backup.',
+ 'Considere mudar o AP para o canal recomendado ou mover pra outra banda.':
+ 'Consider moving the AP to the recommended channel or to another band.',
+ 'Canais detalhados ({n})': 'Detailed channels ({n})',
+ 'Pontuação': 'Score',
+ 'APs': 'APs',
+ '{n} AP(s)': '{n} AP(s)',
+ 'Recomendado': 'Recommended',
+ 'Ainda sem eventos além do primeiro avistamento.':
+ 'No events beyond the first sighting yet.',
+ '{n} amostra(s) de sinal. Eventos mais recentes primeiro.':
+ '{n} signal sample(s). Most recent events first.',
+ 'nenhuma ferramenta instalada': 'no tool installed',
+ 'Senha encontrada: {n}{n}': 'Password found: {n}{n}',
+ 'Sem PIN, o reaver usa Pixie Dust; o bully usa o ataque padrão.':
+ 'Without a PIN, reaver uses Pixie Dust; bully uses its default attack.',
+ 'Instale reaver ou bully para habilitar. Ex.: sudo apt install reaver bully':
+ 'Install reaver or bully to enable. E.g.: sudo apt install reaver bully',
+ 'A saída do reaver/bully aparece aqui.': 'reaver/bully output appears here.',
+ 'Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.':
+ 'Orchestrates reaver/bully on the monitor interface. The AP channel is used automatically; the process can be stopped at any time.',
+ 'Ferramenta': 'Tool',
+ 'PIN conhecido (opcional)': 'Known PIN (optional)',
+ 'Exportar .hc22000': 'Export .hc22000',
+ 'Todos .hc22000': 'Export all .hc22000',
+ 'Iniciar WPS': 'Start WPS',
+ 'Parar job': 'Stop job',
+ '8 dígitos': '8 digits',
+ 'nenhum cliente visto': 'no client seen',
+ 'Direção': 'Direction',
+ 'AP → cliente': 'AP → client',
+ 'cliente → AP': 'client → AP',
+ 'Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.':
+ 'Sends deauth in both directions and watches whether the station reassociates. Practical result, not cryptographic proof.',
+ 'Estação': 'Station',
+ 'Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.':
+ 'Forge a local MAC and send an association request with PMKID to try for message 1. Many APs only expose PMKID during real roaming.',
+ 'Caçar PMKID': 'Hunt PMKID',
+ 'Do cliente para o AP (exige estação selecionada).':
+ 'From client to AP (requires a selected station).',
+ 'Nas duas direções (exige estação selecionada).':
+ 'In both directions (requires a selected station).',
+ '{n} quadro(s) {n} em {n} para {n} (motivo {n}).':
+ '{n} {n} frame(s) in {n} to {n} (reason {n}).',
+ 'Aguardando saída do reaver/bully…': 'Waiting for reaver/bully output…',
+ 'Enviando associação com PMKID…': 'Sending association with PMKID…',
+ '{n} quadro(s) enviados; PMKID não apareceu. {n}':
+ '{n} frame(s) sent; PMKID did not appear. {n}',
+ 'Nenhuma estação disponível.': 'No station available.',
+ 'Testando por ~4 s…': 'Testing for ~4 s…',
+ 'PMKID capturado: {n}': 'PMKID captured: {n}',
+ 'PMF não obrigatório': 'PMF not required',
+ 'PMF não obrigatório · {n}': 'PMF not required · {n}',
+ 'Inconclusivo · {n}': 'Inconclusive · {n}',
+ 'Instale reaver ou bully.': 'Install reaver or bully.',
+ 'Canal do AP desconhecido.': 'Unknown AP channel.',
+ 'Montando o relatório…': 'Building the report…',
+ 'não foi possível gerar o relatório': 'could not generate the report',
+ 'Pré-visualização A4 · {n} página(s).': 'A4 preview · {n} page(s).',
+ 'Pré-visualização A4.': 'A4 preview.',
+ 'Paginando o relatório…': 'Paginating the report…',
+ 'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.':
+ 'Could not open printing; download the HTML and open it in the browser.',
+ 'não foi possível gerar o HTML': 'could not generate the HTML',
+ 'Relatório': 'Report',
+ 'Relatório de auditoria': 'Audit report',
+ 'Imprimir / PDF': 'Print / PDF',
+ 'Baixar HTML': 'Download HTML',
+ 'Pré-visualização paginada em A4.': 'Paginated A4 preview.',
+ 'Visualização': 'View',
+ 'Relatório paginado': 'Paginated report',
+ 'handshake sem SSID, PMKID ou mensagem 2 para exportar':
+ 'handshake without SSID, PMKID or message 2 to export',
+ 'nenhum handshake com material para .hc22000':
+ 'no handshake with material for .hc22000',
+ 'direção inválida: use ap, sta ou both': 'invalid direction: use ap, sta or both',
+ 'máscara grande demais pra calcular o total': 'mask too large to calculate the total',
+ 'máscara tem {n} combinações, acima do limite de {n} — restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar':
+ 'mask has {n} combinations, above the limit of {n} — narrow the mask (e.g. fewer ?a/?l positions) before running',
+ 'quadro inválido: use deauth ou disassoc': 'invalid frame: use deauth or disassoc',
+ 'direção sta/both exige a estação alvo': 'sta/both direction requires the target station',
+ 'inicie a captura nesta interface antes do teste PMKID':
+ 'start capturing on this interface before the PMKID test',
+ 'BSSID ainda não visto nesta captura': 'BSSID not seen in this capture yet',
+ 'SSID oculto: impossível montar o association request':
+ 'hidden SSID: cannot build the association request',
+ 'PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.':
+ 'PMKID observed; export to .hc22000 and crack with hashcat or Sabiá.',
+ 'Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.':
+ 'No PMKID appeared. The AP may not cache PMKID or may only answer real roaming; also try deauthenticating an associated client.',
+ 'PIN WPS deve ter 8 dígitos': 'WPS PIN must have 8 digits',
+ 'a ferramenta terminou sem informar PSK': 'the tool finished without reporting a PSK',
+ 'job WPS não encontrado': 'WPS job not found',
+ 'Auditoria Wi-Fi · {n}': 'Wi-Fi audit · {n}',
+ '{n} está em modo managed; ative o modo monitor antes do teste PMKID':
+ '{n} is in managed mode; enable monitor mode before the PMKID test',
+ '{n} não encontrado no PATH; instale reaver ou bully':
+ '{n} not found in PATH; install reaver or bully',
+ '{n} está em modo managed; ative o modo monitor antes do ataque WPS':
+ '{n} is in managed mode; enable monitor mode before the WPS attack',
+ 'não foi possível executar {n}: {n}': 'could not run {n}: {n}',
+ '{n} terminou com código {n}': '{n} exited with code {n}',
+
+ // achados do WIDS (analysis.rs)
+ 'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels',
+ 'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.':
+ 'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.',
+ 'Rajada de desautenticação': 'Deauthentication burst',
+ '{n} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável.':
+ '{n} deauthentication/disassociation frames in the last minute. It may be a deauth attack or an unstable AP.',
+ 'Muitos BSSIDs novos': 'Many new BSSIDs',
+ '{n} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso.':
+ '{n} BSSIDs appeared in the last minute — possible beacon flood or a very dense environment.',
+ 'Rede aberta: {n}': 'Open network: {n}',
+ 'Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.':
+ 'No Wi-Fi encryption; traffic is exposed to any station in range.',
+ 'WEP em {n}': 'WEP on {n}',
+ 'WEP é quebrável em minutos; trate a rede como aberta.':
+ 'WEP can be cracked in minutes; treat the network as open.',
+ 'WPA1 (TKIP) em {n}': 'WPA1 (TKIP) on {n}',
+ 'WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.':
+ 'WPA1/TKIP has known weaknesses; migrate to WPA2/CCMP or WPA3.',
+ 'WPS ativo em {n}': 'WPS active on {n}',
+ 'O AP anuncia WPS com configuração travada.': 'The AP advertises WPS with configuration locked.',
+ 'WPS sem lock explícito; PIN pode ser testado com reaver/bully.':
+ 'WPS without an explicit lock; the PIN can be tested with reaver/bully.',
+ 'Sem PMF em {n}': 'No PMF on {n}',
+ 'Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.':
+ 'Deauthentication and disassociation are unprotected; clients can be expelled to make handshake capture easier.',
+ 'PMF inconsistente em {n}': 'Inconsistent PMF on {n}',
+ 'O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.':
+ 'The AP advertises WPA3 but not PMF; check the configuration.',
+ 'WPA3 com WPS em {n}': 'WPA3 with WPS on {n}',
+ 'WPS não faz parte do WPA3 e abre uma via mais fraca de associação.':
+ 'WPS is not part of WPA3 and opens a weaker association path.',
+ 'primeiro visto': 'first seen',
+ 'associou': 'associated',
+ 'desautenticado': 'deauthenticated',
+
+ 'A estação voltou a se associar.': 'The station reassociated.',
+ 'O AP pode não usar cache de PMKID.': 'The AP may not cache PMKID.',
+
+ 'A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).':
+ 'The station reassociated after deauthentication: PMF is not required (or the client does not use it).',
+ 'A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.':
+ 'The station kept exchanging frames after deauthentication; the management frame was probably accepted.',
+ 'A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.':
+ 'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.',
+ 'Inconclusivo': 'Inconclusive',
+
+ // deauth contínuo e identificação de clientes
+ 'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.':
+ 'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.',
+ 'Iniciar contínuo': 'Start continuous',
+ 'Parar': 'Stop',
+ 'Rajada ({n})': 'Burst ({n})',
+ 'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.':
+ 'Ready. The continuous job locks capture to the AP channel and stops when you say so.',
+ 'Job {n} · {n} quadro(s){n}{n}': 'Job {n} · {n} frame(s){n}{n}',
+ 'Handshake capturado em {n} quadro(s){n}.': 'Handshake captured in {n} frame(s){n}.',
+ 'Job {n} {n} · {n} quadro(s){n}.': 'Job {n} {n} · {n} frame(s){n}.',
+ 'Escolha um cliente para desautenticar nas direções sta/both.':
+ 'Choose a client to deauthenticate in the sta/both directions.',
+ 'Forçando reassociação e handshake…': 'Forcing reassociation and handshake…',
+ 'Desautenticação contínua iniciada.': 'Continuous deauthentication started.',
+ 'Nenhuma ferramenta WPS disponível.': 'No WPS tool available.',
+ 'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.':
+ 'The bundled reaver uses Pixie Dust without a PIN. bully appears if it is installed on the system.',
+ 'O reaver embutido não pôde ser extraído nesta execução.':
+ 'The bundled reaver could not be extracted in this run.',
+ 'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)',
+ 'dicionário próprio, opcional': 'custom wordlist, optional',
+ 'Dispositivo': 'Device',
+ 'MAC aleatório': 'Random MAC',
+ 'Cliente {n}': 'Client {n}',
+ 'Linha do tempo · {n}': 'Timeline · {n}',
+ 'nenhuma ferramenta disponível': 'no tool available',
+ '· canal {n}': '· channel {n}',
+ '· para sozinho no handshake': '· stops on handshake',
+ 'job de deauth não encontrado': 'deauth job not found',
+ 'já existe um job de desautenticação em execução em {n}':
+ 'a deauthentication job is already running on {n}',
+ 'inicie a captura em {n} antes de iniciar o job':
+ 'start capture on {n} before starting the job',
+ 'a captura em {n} não está ativa': 'capture on {n} is not active',
+ '{n} não está disponível; o reaver integrado não pôde ser extraído':
+ '{n} is not available; the bundled reaver could not be extracted',
};
diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html
index 5f5e801..d832af9 100644
--- a/apps/gui/ui/index.html
+++ b/apps/gui/ui/index.html
@@ -18,17 +18,39 @@
-