10 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5 7d69f2e4b2 ci: pin CI jobs to the arm64 runners, not the shared ubuntu-latest alias
CI / Format and clippy (push) Successful in 17s
CI / Tests (Linux) (push) Successful in 27s
CI / Dependency audit (push) Successful in 10s
CI / Check desktop app (Linux) (push) Successful in 1m47s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 49s
Release / Desktop app (linux-aarch64) (push) Successful in 4m22s
Release / CLI (linux-x86_64) (push) Successful in 50s
Release / Desktop app (linux-x86_64) (push) Successful in 4m4s
Release / Desktop app (windows) (push) Successful in 3m8s
Same issue and fix as Guará: ci.yml's four jobs used
runs-on: ubuntu-latest, which now resolves only to hostinger-runner
after the Gitea runner relabeling, piling every CI push onto the same
single machine release.yml routes x86_64 builds to and overloading it.
Pins all four jobs to linux-arm64.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:23:45 -03:00
Lucas RayanandClaude Sonnet 5 ba0c99d5b6 chore(release): bump version to 1.0.4
CI / Dependency audit (push) Canceled after 0s
CI / Check desktop app (Linux) (push) Canceled after 0s
Release / Create release (push) Successful in 6s
CI / Format and clippy (push) Canceled after 0s
CI / Tests (Linux) (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Successful in 53s
Release / Desktop app (linux-aarch64) (push) Successful in 4m45s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-x86_64) (push) Successful in 10m26s
Release / Desktop app (windows) (push) Successful in 8m12s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:06:19 -03:00
Lucas RayanandClaude Sonnet 5 476e27e253 ci(release): build linux-x86_64 natively on the new amd64 runner
A native x86_64 Gitea Actions runner (hostinger-runner, label
linux-amd64) is now available alongside the two aarch64 runners
(oracle-runner/-b, label linux-arm64). Routes the linux-x86_64 leg of
the cli and gui release matrices to it instead of cross-compiling it
from an aarch64 host, same change as the Guará repository.

- matrix.include gets an explicit runner field per platform
  (linux-arm64 for aarch64, linux-amd64 for x86_64 and windows);
  runs-on: ${{ matrix.runner }} picks it per job. windows is pinned to
  linux-amd64 explicitly rather than relying on the ubuntu-latest
  alias, since that alias has already been reassigned once between
  the aarch64 and the x86_64 runner on this Gitea instance.
- The "Platform cross-linker" step (gcc-x86-64-linux-gnu) only runs
  when NOT on the native amd64 runner, since a native build needs no
  cross-linker.
- scripts/build-gui.sh and scripts/package-cli.sh are intentionally
  left untouched: both already branch on `uname -m` and take the
  simple native path when run on x86_64, so no script change was
  needed. The existing cross-compile path (qemu wrappers, the ELF
  launcher that survives tauri-bundler's dd patch) stays in place as a
  dormant fallback in case the single amd64 runner is ever
  unavailable — deliberately kept, not deleted.

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:40:29 -03:00
153 changed files with 4090 additions and 1338 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Report a bug
description: Something did not work as expected
labels: [bug]
body:
- type: textarea
id: what-happened
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true }
- type: input
id: system
attributes: { label: "Operating system and Sabiá version", placeholder: "Ubuntu 24.04 / sabia 1.0.3" }
- type: input
id: adapter
attributes: { label: "Wi-Fi adapter and mode", placeholder: "wlan0 (Intel AX200), monitor mode" }
- type: textarea
id: log
attributes: { label: "Terminal output (sabia scan / ui)", render: shell }
+8
View File
@@ -0,0 +1,8 @@
## What changes and why
## Checklist
- [ ] `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)
+23 -27
View File
@@ -1,31 +1,32 @@
name: CI
on:
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
# Documentation-only or local agent state changes do not trigger CI jobs.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', 'scripts/release-notes.sh', '.gitea/workflows/release.yml']
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', 'scripts/release-notes.sh', '.gitea/workflows/release.yml']
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ gitea.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
# a toolchain do rustup, o registry do cargo e o diretório target ficam ali,
# então nada disso é baixado ou recompilado do zero a cada execução. 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
runs-on: ubuntu-latest
name: Format and clippy
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
steps:
@@ -36,15 +37,15 @@ 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)
runs-on: ubuntu-latest
name: Tests (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
steps:
@@ -53,22 +54,22 @@ jobs:
- run: cargo test --workspace
audit:
name: Auditoria de dependências
runs-on: ubuntu-latest
name: Dependency audit
runs-on: linux-arm64
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)
runs-on: ubuntu-latest
name: Check desktop app (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
steps:
@@ -77,15 +78,10 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri
- name: Tauri system dependencies
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- 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:
+58 -32
View File
@@ -1,24 +1,28 @@
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.
# 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 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
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
jobs:
release:
name: Cria a release
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -30,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
@@ -44,62 +48,84 @@ jobs:
cli:
name: CLI (${{ matrix.platform }})
needs: release
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-cli-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" }
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu" }
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "", runner: linux-arm64 }
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu", runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Linker e ferramentas desta plataforma
if: ${{ matrix.apt != '' }}
run: |
apt-get update -qq
apt-get install -y -qq ${{ matrix.apt }}
- name: Compila e empacota
- name: Platform cross-linker
if: ${{ matrix.apt != '' && matrix.runner != 'linux-amd64' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- 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
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-gui
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-gui-${{ matrix.platform }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY || secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
strategy:
fail-fast: false
matrix:
platform: [linux, windows]
include:
- { platform: linux-aarch64, runner: linux-arm64 }
- { platform: linux-x86_64, runner: linux-amd64 }
- { platform: windows, runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri (Linux)
if: matrix.platform == 'linux'
- name: Tauri dependencies (Linux aarch64)
if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Ferramentas para compilação cruzada (Windows)
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Tauri dependencies (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
if [ "$(uname -m)" = aarch64 ]; then
dpkg --add-architecture amd64
. /etc/os-release
if [ "${ID:-}" = ubuntu ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
fi
fi
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi
- name: Cross-compilation tools (Windows)
if: matrix.platform == 'windows'
run: |
apt-get update -qq
apt-get install -y -qq nsis lld llvm clang
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
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
-17
View File
@@ -1,17 +0,0 @@
name: Relatar um problema
description: Algo não funcionou como esperado
labels: [bug]
body:
- type: textarea
id: o-que
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
validations: { required: true }
- type: input
id: sistema
attributes: { label: "Sistema operacional e versão do Sabiá", placeholder: "Ubuntu 24.04 / sabia 0.1.0" }
- type: input
id: adaptador
attributes: { label: "Adaptador Wi-Fi e modo", placeholder: "wlan0 (Intel AX200), modo monitor" }
- type: textarea
id: log
attributes: { label: "Saída do terminal (sabia scan / ui)", render: shell }
-7
View File
@@ -1,7 +0,0 @@
## O que muda e por quê
## 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)
-151
View File
@@ -1,151 +0,0 @@
name: Release
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
create-release:
name: Cria a release
runs-on: ubuntu-24.04
outputs:
release_id: ${{ steps.rel.outputs.result }}
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
id: notes
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
id: rel
with:
script: |
const fs = require('fs');
const tag = context.ref.replace('refs/tags/', '');
const r = await github.rest.repos.createRelease({
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
name: `Sabiá ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
draft: true, prerelease: tag.includes('-'),
});
return r.data.id;
cli:
name: CLI (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --locked -p sabia-cli --target ${{ matrix.target }}
- name: Empacota
shell: bash
run: |
name="sabia-${GITHUB_REF_NAME}-${{ matrix.name }}"
mkdir "$name"
if [ "$RUNNER_OS" = "Windows" ]; then
cp target/${{ matrix.target }}/release/sabia.exe "$name/"
else
cp target/${{ matrix.target }}/release/sabia "$name/"
fi
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
if [ "$RUNNER_OS" = "Windows" ]; then
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
else
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
fi
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
echo "ASSET=$asset" >> "$GITHUB_ENV"
- name: Envia para a release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
gui:
name: App desktop (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
- { name: windows-x86_64, os: windows-latest, args: "" }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/gui/src-tauri
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
publish:
name: Publica a release
needs: [create-release, cli, gui]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
await github.rest.repos.updateRelease({
owner: context.repo.owner, repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }},
name: `Sabiá ${context.ref.replace('refs/tags/', '')}`,
body: fs.readFileSync('notes.md', 'utf8'),
draft: false,
});
+24 -6
View File
@@ -1,13 +1,21 @@
/target
# Build artifacts
target/
apps/gui/src-tauri/target/
node_modules/
*.img
*.guara-session.json
.DS_Store
**/*.rs.bk
# Sabiá local captures and audit log
*.pcap
*.cap
.sabia-audit.log
node_modules/
apps/gui/src-tauri/target
# Configuração e memória local de agentes de IA — não faz parte do
# produto, fica só na máquina de desenvolvimento.
# Logo copied to the UI directory by build.rs
/apps/gui/ui/logo.png
# Local AI agent configuration and state — not part of the product
.claude/
.wolf/
.opencode/
@@ -19,6 +27,16 @@ CLAUDE.md
AGENTS.md
GEMINI.md
# Pacotes gerados por scripts/package-cli.sh
# Local environment files
.env
.env.*
# Local scratch files
*.eml
__pycache__/
*.pyc
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
/guara-v*
/sabia-v*
/dist-gui
+9
View File
@@ -4,6 +4,15 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.0.3] - 2026-10-30
### Fixed
- `deauth_directional` had no `#[cfg(target_os = "linux")]` of its own; every
non-Linux build failed with a duplicate-definition error and unresolved
Linux-only imports. `v1.0.1` and `v1.0.2` never got a working Windows
desktop app for this reason, and `v1.0.2`'s Linux package was mislabeled
`1.0.1` (the version in `Cargo.toml` was not bumped). Use `v1.0.3`.
## [1.0.2] - 2026-10-30
### Fixed
+9
View File
@@ -4,6 +4,15 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.0.3] - 2026-10-30
### Corrigido
- O `deauth_directional` não tinha o próprio `#[cfg(target_os = "linux")]`;
todo build fora do Linux falhava com uma definição duplicada e imports só de
Linux fora de escopo. Por isso a `v1.0.1` e a `v1.0.2` nunca tiveram um app
desktop funcional para Windows, e o pacote Linux da `v1.0.2` saiu rotulado
`1.0.1` (a versão no `Cargo.toml` não tinha sido atualizada). Use a `v1.0.3`.
## [1.0.2] - 2026-10-30
### Corrigido
+13 -13
View File
@@ -7,29 +7,25 @@ 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
cargo fmt --all
cargo clippy --workspace --all-targets # as in CI (RUSTFLAGS=-D warnings)
cargo test --workspace
node scripts/check-ui-i18n.mjs # UI translation coverage
scripts/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 only (Gitea has one runner); run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
CI runs on Linux; run the check before pushing: almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) skip CI.
Live capture exists only on Linux (`AF_PACKET` + netlink); the code behind Windows and macOS paths still compiles and is covered by `cargo check`, but neither runs in CI nor is built for release.
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
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 **aarch64** and Windows. The runner is arm64, so the Linux **x86_64** desktop app comes from an x86_64 Linux machine:
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`).
```bash
scripts/build-gui.sh linux # AppImage + .deb in dist-gui/
API=https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia TOKEN=... \
scripts/release-upload.sh vX.Y.Z dist-gui/*
```
Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first) and attach the Linux CLI (x86_64 and aarch64) and the desktop app for Linux (x86_64 and aarch64) and Windows.
There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
@@ -50,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.
+13 -13
View File
@@ -7,29 +7,25 @@ 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
cargo fmt --all
cargo clippy --workspace --all-targets # como no CI (RUSTFLAGS=-D warnings)
cargo test --workspace
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
scripts/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 só em Linux (o Gitea tem um único runner); rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
O CI roda em Linux; rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`, mas não roda no CI nem é compilado para release. são offline.
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`.
## Releases
## Releases e Versionamento
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 **aarch64** e Windows. O runner é arm64, então o app Linux **x86_64** vem de uma máquina Linux x86_64:
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`).
```bash
scripts/build-gui.sh linux # AppImage + .deb em dist-gui/
API=https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia TOKEN=... \
scripts/release-upload.sh vX.Y.Z dist-gui/*
```
Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes) e anexar o CLI para Linux (x86_64 e aarch64) e o app desktop para Linux (x86_64 e aarch64) e Windows.
Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
@@ -50,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.
Generated
+2 -2
View File
@@ -440,7 +440,7 @@ dependencies = [
[[package]]
name = "sabia-cli"
version = "1.0.1"
version = "1.0.4"
dependencies = [
"clap",
"ctrlc",
@@ -451,7 +451,7 @@ dependencies = [
[[package]]
name = "sabia-core"
version = "1.0.1"
version = "1.0.4"
dependencies = [
"aes",
"flate2",
+5 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/sabia-core", "crates/sabia-cli"]
[workspace.package]
version = "1.0.1"
version = "1.0.4"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
@@ -15,3 +15,7 @@ lto = "thin"
codegen-units = 1
opt-level = 3
strip = true
panic = "abort"
[profile.dev.package."*"]
opt-level = 3
+6 -2
View File
@@ -1,11 +1,13 @@
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
<h1 align="center">Sabiá</h1>
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows too · authorized use.</p>
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="docs/support.md">Support</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
<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.
+6 -2
View File
@@ -1,11 +1,13 @@
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
<h1 align="center">Sabiá</h1>
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline também em Windows · uso autorizado.</p>
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="docs/pt-BR/suporte.md">Suporte</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
<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.
+1 -1
View File
@@ -2,6 +2,6 @@
**English** · [Português](SECURITY.pt-BR.md)
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the repository's **Security → Report a vulnerability** tab on GitHub. Do not open a public issue before a fix.
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the private vulnerability reporting feature of the repository on Gitea. Do not open a public issue before a fix.
Sabiá is a tool for **authorized auditing**. Reports about using it to attack third-party networks are out of the project's security scope — see the **Authorized use** section of the [README](README.md).
+1 -1
View File
@@ -2,6 +2,6 @@
[English](SECURITY.md) · **Português**
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pelo recurso de relato privado de vulnerabilidade do repositório no Gitea. Não abra issue pública antes da correção.
O Sabiá é uma ferramenta de **auditoria autorizada**. Relatos sobre uso para atacar redes de terceiros não são escopo de segurança do projeto — veja a seção **Uso autorizado** do [README](README.pt-BR.md).
+2 -2
View File
@@ -2538,7 +2538,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sabia-core"
version = "1.0.1"
version = "1.0.4"
dependencies = [
"aes",
"flate2",
@@ -2555,7 +2555,7 @@ dependencies = [
[[package]]
name = "sabia-gui"
version = "1.0.1"
version = "1.0.4"
dependencies = [
"libc",
"sabia-core",
+5 -4
View File
@@ -1,11 +1,12 @@
# 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"
version = "1.0.1"
description = "Sabiá: graphical Wi-Fi security auditing interface"
version = "1.0.4"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Sabiá contributors"]
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
rust-version = "1.80"
+5 -3
View File
@@ -1,12 +1,14 @@
fn main() {
println!("cargo:rerun-if-changed=../../../logo.png");
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)]
{
// Modo monitor e captura crua exigem administrador no Windows: o
// 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();
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window permissions",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"]
}
@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}}
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

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

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 350 KiB

+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();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Sabia",
"version": "1.0.1",
"productName": "Sabiá",
"version": "1.0.4",
"identifier": "dev.sabia.app",
"build": {
"frontendDist": "../ui"
+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) {
+2 -2
View File
@@ -4,14 +4,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sabiá</title>
<link rel="icon" href="/sabia.png">
<link rel="icon" href="/logo.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<img class="logo" src="/sabia.png" alt="">
<img class="logo" src="/logo.png" alt="">
<div>
<strong>Sabiá</strong>
<small>auditoria de Wi-Fi</small>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2024 Paged.js contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
# Paged.js
`paged.polyfill.min.js` is the [Paged.js](https://pagedjs.org/) polyfill, version
0.4.3, used to paginate A4 reports in the browser.
- Project: https://gitlab.coko.foundation/pagedjs/pagedjs
- License: MIT (text in `PAGEDJS-LICENSE`)
- File: `paged.polyfill.min.js` (unmodified distribution build)
Vendorized so report generation works completely offline without external CDNs.
File renamed without changes.
+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,
+13 -13
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;
@@ -17,19 +17,19 @@ const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/paged.polyfill.min.js");
const LOGO_PNG: &[u8] = include_bytes!("../../../../logo.png");
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/vendor/paged.polyfill.min.js");
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
let token = gen_token();
let listener = TcpListener::bind(("127.0.0.1", port))?;
let engine = Arc::new(Engine::with_audit_path(audit_path));
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
// e as duas interfaces ficam presas em `managed no` pro
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
// o próximo início do engine se auto-curar.
// 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 || {
@@ -275,7 +275,7 @@ fn handle(
}
match req.path.as_str() {
"/paged.polyfill.min.js" => {
"/vendor/paged.polyfill.min.js" | "/paged.polyfill.min.js" => {
return respond(
&mut stream,
200,
@@ -308,7 +308,7 @@ fn handle(
I18N_EN_JS.as_bytes(),
)
}
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
"/logo.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
_ => {}
}
+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.
File renamed without changes.
File renamed without changes.
+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");
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more