v0.1.0: logo, i18n pt/en, CI com caches, testes e documentação

- Logo oficial (sabia.png) e conjunto de ícones do app desktop.
- Interface em português e inglês: tradutor por observação do DOM, seletor
  de idioma e checagem de cobertura de tradução no CI.
- CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release
  multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates
  de issue/PR.
- Suíte de testes de integração do sabia-core (scan e2e, handshake/crack
  4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes.
- Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING,
  CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso
  autorizado.
- Correções de clippy (-D warnings) e formatação em toda a árvore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5.5 committed 2026-10-23 12:46:19 -03:00
1 parent df27974359
commit 791538c9d9
112 files changed
+15620 -494

No files matched your search

+413 -60
View File
@@ -1,9 +1,9 @@
//! Agregação de quadros capturados em uma tabela de APs e clientes vistos,
//! no espírito do `airodump-ng` — mas como uma struct reaproveitável tanto
//! pela CLI quanto pela interface gráfica.
//! Agregação de quadros capturados em tabelas de redes, clientes e
//! handshakes — o coração do que a CLI e a interface mostram.
use crate::dot11::{self, FrameKind, Security};
use crate::radiotap;
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
use crate::radiotap::{self, Band};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -11,17 +11,73 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub struct AccessPoint {
pub bssid: String,
pub ssid: Option<String>,
pub hidden: bool,
pub channel: Option<u32>,
pub band: Option<Band>,
pub freq_mhz: Option<u16>,
pub security: Option<String>,
pub last_signal_dbm: Option<i8>,
pub beacons_seen: u64,
pub cipher: Option<String>,
pub akm: Vec<String>,
pub pmf: bool,
pub wps: bool,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
pub beacons: u64,
pub data_frames: u64,
pub clients: Vec<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub mac: String,
pub bssid: Option<String>,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
pub packets: u64,
pub probes: Vec<String>,
pub associated: bool,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct IngestEvent {
pub eapol: bool,
pub handshake_updated: bool,
pub deauth: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanSnapshot {
pub iface: String,
pub running: bool,
pub channel: Option<u32>,
pub stats: ScanStats,
pub access_points: Vec<AccessPoint>,
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct ScanStats {
pub frames: u64,
pub eapol_frames: u64,
pub deauth_frames: u64,
pub aps: usize,
pub clients: usize,
pub handshakes: usize,
}
#[derive(Default)]
pub struct ScanTable {
aps: HashMap<String, AccessPoint>,
clients: HashMap<String, Client>,
handshakes: HandshakeTracker,
frames: u64,
eapol_frames: u64,
deauth_frames: u64,
}
impl ScanTable {
@@ -29,91 +85,388 @@ impl ScanTable {
Self::default()
}
/// Processa um quadro radiotap+802.11 crú, atualizando a tabela.
/// Retorna `true` se o quadro continha um pacote EAPOL (indício de
/// handshake WPA em andamento — útil para o chamador decidir salvar em
/// pcap).
/// Processa um quadro radiotap+802.11 crú. Retorna `true` se o quadro
/// continha EAPOL (mantido para chamadores antigos).
pub fn ingest(&mut self, raw: &[u8]) -> bool {
let rt = match radiotap::parse(raw) {
Some(rt) => rt,
None => return false,
let now = unix_now();
self.ingest_at(raw, now).eapol
}
pub fn ingest_at(&mut self, raw: &[u8], now: u64) -> IngestEvent {
let mut event = IngestEvent::default();
let Some(rt) = radiotap::parse(raw) else {
return event;
};
if rt.header_len >= raw.len() {
return false;
return event;
}
let dot11_buf = &raw[rt.header_len..];
let frame = match dot11::parse(dot11_buf) {
Some(f) => f,
None => return false,
let Some(frame) = dot11::parse(&raw[rt.header_len..]) else {
return event;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.frames += 1;
match frame.kind {
FrameKind::Beacon | FrameKind::ProbeResponse => {
if let Some(bssid) = frame.bssid_str() {
let info = frame.management_info();
let channel = info
.channel_ie
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
let entry = self
.aps
.entry(bssid.clone())
.or_insert_with(|| AccessPoint {
bssid,
ssid: None,
channel: None,
security: None,
last_signal_dbm: None,
beacons_seen: 0,
clients: Vec::new(),
last_seen_unix: now,
});
if let Some(ssid) = info.ssid {
entry.ssid = Some(ssid);
let Some(bssid) = frame.bssid_str() else {
return event;
};
let info = frame.management_info();
let channel = info
.channel
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
let band = rt.freq_mhz.map(radiotap::band_for_freq);
let ap = self
.aps
.entry(bssid.clone())
.or_insert_with(|| AccessPoint {
bssid: bssid.clone(),
ssid: None,
hidden: false,
channel: None,
band: None,
freq_mhz: None,
security: None,
cipher: None,
akm: Vec::new(),
pmf: false,
wps: false,
signal_dbm: None,
max_signal_dbm: None,
beacons: 0,
data_frames: 0,
clients: Vec::new(),
first_seen_unix: now,
last_seen_unix: now,
});
if let Some(ssid) = info.ssid.clone() {
if !ssid.is_empty() || ap.ssid.is_none() {
ap.ssid = Some(ssid);
}
if channel.is_some() {
entry.channel = channel;
}
entry.security = info.security.map(security_label);
entry.last_signal_dbm = rt.signal_dbm;
entry.beacons_seen += 1;
entry.last_seen_unix = now;
ap.hidden = info.hidden;
}
if channel.is_some() {
ap.channel = channel;
}
if rt.freq_mhz.is_some() {
ap.freq_mhz = rt.freq_mhz;
ap.band = band;
}
ap.security = info.security.map(security_label);
ap.cipher = info.cipher.clone();
ap.akm = info.akm.clone();
ap.pmf = info.pmf;
ap.wps = info.wps;
ap.signal_dbm = rt.signal_dbm;
if let Some(sig) = rt.signal_dbm {
ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig)));
}
ap.beacons += 1;
ap.last_seen_unix = now;
if let Some(ssid) = ap.ssid.clone().filter(|s| !s.is_empty()) {
self.handshakes.update_ssid(&bssid, &ssid);
}
}
FrameKind::Data => {
// Associa o cliente (endereço de origem) ao BSSID observado,
// quando a direção do quadro indicar que vem de uma estação.
if let (Some(bssid), Some(src)) = (frame.bssid_str(), frame.src_str()) {
FrameKind::ProbeRequest => {
let Some(client) = frame.src_str() else {
return event;
};
let info = frame.management_info();
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
if !c.probes.contains(&ssid) {
c.probes.push(ssid);
}
}
}
FrameKind::AssociationRequest => {
let client = frame.src_str();
let bssid = frame
.addr1
.map(|m| dot11::mac_to_string(&m))
.or_else(|| frame.bssid_str());
if let (Some(client), Some(bssid)) = (client, bssid) {
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
c.bssid = Some(bssid.clone());
c.associated = true;
if let Some(ap) = self.aps.get_mut(&bssid) {
if src != bssid && !ap.clients.contains(&src) {
ap.clients.push(src);
if !ap.clients.contains(&client) {
ap.clients.push(client);
}
}
}
return dot11::is_eapol(frame.body);
}
FrameKind::Data => {
let bssid = match (frame.to_ds, frame.from_ds) {
(true, false) => frame.addr1.map(|m| dot11::mac_to_string(&m)),
(false, true) => frame.addr2.map(|m| dot11::mac_to_string(&m)),
_ => frame.bssid_str(),
};
let client = match (frame.to_ds, frame.from_ds) {
(true, false) => frame.src_str(),
(false, true) => frame.dst_str(),
_ => frame.src_str(),
};
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
if client != bssid {
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
c.bssid = Some(bssid.clone());
c.associated = true;
if let Some(ap) = self.aps.get_mut(&bssid) {
ap.data_frames += 1;
if !ap.clients.contains(&client) {
ap.clients.push(client.clone());
}
}
}
}
if frame.eapol().is_some() {
event.eapol = true;
self.eapol_frames += 1;
let hint = bssid
.as_deref()
.and_then(|b| self.aps.get(b))
.and_then(|ap| ap.ssid.clone())
.filter(|s| !s.is_empty());
if self.handshakes.ingest(&frame, raw, hint.as_deref(), now) {
event.handshake_updated = true;
}
}
}
FrameKind::Deauth | FrameKind::Disassoc => {
self.deauth_frames += 1;
event.deauth = true;
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m));
let bssid = frame.bssid_str();
let client = match (&a1, &a2, &bssid) {
(Some(a1), Some(_), Some(b)) if a1 != b => Some(a1.clone()),
(Some(_), Some(a2), Some(b)) if a2 != b => Some(a2.clone()),
_ => None,
};
if let Some(client) = client {
if let Some(c) = self.clients.get_mut(&client) {
c.associated = false;
c.last_seen_unix = now;
}
}
}
_ => {}
}
false
event
}
fn touch_client(&mut self, mac: &str, now: u64, signal: Option<i8>) -> &mut Client {
let c = self
.clients
.entry(mac.to_string())
.or_insert_with(|| Client {
mac: mac.to_string(),
bssid: None,
signal_dbm: None,
max_signal_dbm: None,
packets: 0,
probes: Vec::new(),
associated: false,
first_seen_unix: now,
last_seen_unix: now,
});
c.last_seen_unix = now;
c.signal_dbm = signal;
if let Some(sig) = signal {
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
}
c
}
pub fn access_points(&self) -> Vec<AccessPoint> {
let mut v: Vec<_> = self.aps.values().cloned().collect();
v.sort_by_key(|a| std::cmp::Reverse(a.beacons_seen));
v.sort_by(|a, b| {
b.beacons
.cmp(&a.beacons)
.then_with(|| a.ssid.cmp(&b.ssid))
.then_with(|| a.bssid.cmp(&b.bssid))
});
v
}
pub fn clients(&self) -> Vec<Client> {
let mut v: Vec<_> = self.clients.values().cloned().collect();
v.sort_by(|a, b| b.packets.cmp(&a.packets).then_with(|| a.mac.cmp(&b.mac)));
v
}
pub fn handshakes(&self) -> Vec<HandshakeInfo> {
self.handshakes.infos()
}
pub fn handshake(&self, bssid: &str, station: &str) -> Option<&Handshake> {
self.handshakes.get(bssid, station)
}
/// Handshakes completos (com quadros crus) para exportação.
pub fn handshake_entries(&self) -> Vec<Handshake> {
self.handshakes.all().into_iter().cloned().collect()
}
pub fn first_crackable(
&self,
bssid: Option<&str>,
station: Option<&str>,
) -> Option<&Handshake> {
self.handshakes.first_crackable(bssid, station)
}
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
self.handshakes.set_password(bssid, station, password);
}
pub fn stats(&self) -> ScanStats {
ScanStats {
frames: self.frames,
eapol_frames: self.eapol_frames,
deauth_frames: self.deauth_frames,
aps: self.aps.len(),
clients: self.clients.len(),
handshakes: self.handshakes.len(),
}
}
}
fn security_label(s: Security) -> String {
match s {
Security::Open => "Aberta".into(),
Security::Open => "Open".into(),
Security::Wep => "WEP".into(),
Security::Wpa => "WPA".into(),
Security::Wpa2 => "WPA2".into(),
Security::Wpa2Enterprise => "WPA2-EAP".into(),
Security::Wpa3 => "WPA3".into(),
Security::Wpa3Enterprise => "WPA3-EAP".into(),
Security::Owe => "OWE".into(),
Security::Wpa2Wpa3 => "WPA2/WPA3".into(),
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{
ap, beacon_frame, frame_with_eapol, handshake_frames, probe_request, radiotap_wrap, sta,
};
fn beacon_raw(ssid: &str, akms: &[u8]) -> Vec<u8> {
let frame = beacon_frame(ap(), ssid, 6, akms);
radiotap_wrap(&frame, 2437, -40)
}
#[test]
fn builds_ap_table_from_beacon() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 100);
let aps = table.access_points();
assert_eq!(aps.len(), 1);
let ap = &aps[0];
assert_eq!(ap.ssid.as_deref(), Some("Casa"));
assert_eq!(ap.bssid, "00:11:22:33:44:55");
assert_eq!(ap.channel, Some(6));
assert_eq!(ap.band, Some(Band::B2G4));
assert_eq!(ap.security.as_deref(), Some("WPA2"));
assert_eq!(ap.cipher.as_deref(), Some("CCMP"));
assert_eq!(ap.signal_dbm, Some(-40));
assert_eq!(ap.beacons, 1);
assert_eq!(ap.first_seen_unix, 100);
}
#[test]
fn tracks_probes_and_data_clients() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
let probe = radiotap_wrap(&probe_request(sta(), "Casa"), 2437, -55);
table.ingest_at(&probe, 2);
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1, 2, 3];
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
let data = radiotap_wrap(&data, 2437, -50);
table.ingest_at(&data, 3);
let clients = table.clients();
assert_eq!(clients.len(), 1);
let c = &clients[0];
assert_eq!(c.mac, "66:77:88:99:aa:bb");
assert_eq!(c.bssid.as_deref(), Some("00:11:22:33:44:55"));
assert!(c.associated);
assert!(c.probes.contains(&"Casa".to_string()));
assert_eq!(c.packets, 2);
assert_eq!(table.access_points()[0].clients.len(), 1);
assert_eq!(table.stats().frames, 3);
}
#[test]
fn captures_handshake_from_raw_frames() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("RedeWPA", &[2]), 1);
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let raw1 = radiotap_wrap(&f1, 2437, -40);
let e1 = table.ingest_at(&raw1, 2);
assert!(e1.eapol);
assert!(e1.handshake_updated);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
let raw2 = radiotap_wrap(&f2, 2437, -41);
let e2 = table.ingest_at(&raw2, 3);
assert!(e2.eapol);
let hs = table.handshakes();
assert_eq!(hs.len(), 1);
assert!(hs[0].complete);
assert!(hs[0].crackable);
assert_eq!(hs[0].ssid.as_deref(), Some("RedeWPA"));
assert_eq!(hs[0].messages, "1,2");
assert!(table.first_crackable(None, None).is_some());
assert!(table
.first_crackable(Some("00:11:22:33:44:55"), None)
.is_some());
assert!(table
.first_crackable(Some("ff:ff:ff:ff:ff:ff"), None)
.is_none());
table.set_password("00:11:22:33:44:55", "66:77:88:99:aa:bb", "password");
assert_eq!(table.handshakes()[0].password.as_deref(), Some("password"));
assert_eq!(table.stats().eapol_frames, 2);
}
#[test]
fn counts_deauth_and_marks_client_disconnected() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1];
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
table.ingest_at(&radiotap_wrap(&data, 2437, -50), 2);
let mut deauth = Vec::new();
deauth.extend_from_slice(&0x00c0u16.to_le_bytes());
deauth.extend_from_slice(&0u16.to_le_bytes());
deauth.extend_from_slice(&sta());
deauth.extend_from_slice(&ap());
deauth.extend_from_slice(&ap());
deauth.extend_from_slice(&[0u8; 2]);
deauth.extend_from_slice(&7u16.to_le_bytes());
let event = table.ingest_at(&radiotap_wrap(&deauth, 2437, -50), 3);
assert!(event.deauth);
assert_eq!(table.stats().deauth_frames, 1);
assert!(!table.clients()[0].associated);
}
}