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
544 lines
17 KiB
Rust
544 lines
17 KiB
Rust
mod web;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use sabia_core::engine::{Engine, ScanOptions};
|
|
use sabia_core::iface;
|
|
use std::io::IsTerminal;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "sabia",
|
|
about = "Sabiá — auditoria e monitoramento de redes Wi-Fi",
|
|
version
|
|
)]
|
|
struct Cli {
|
|
/// Audit log file path (default: `.sabia-audit.log`).
|
|
#[arg(long, global = true, default_value = ".sabia-audit.log")]
|
|
audit: PathBuf,
|
|
|
|
#[command(subcommand)]
|
|
cmd: Cmd,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// List interfaces, channels, and toggle monitor mode.
|
|
Iface {
|
|
#[command(subcommand)]
|
|
action: IfaceAction,
|
|
},
|
|
/// Capture live traffic and display networks, clients, and handshakes.
|
|
Scan {
|
|
iface: String,
|
|
/// Fixed channel; when omitted, hops across supported channels.
|
|
#[arg(long)]
|
|
channel: Option<u32>,
|
|
/// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`.
|
|
#[arg(long)]
|
|
band: Option<String>,
|
|
/// Channel dwell time in milliseconds.
|
|
#[arg(long, default_value_t = 350)]
|
|
hop_ms: u64,
|
|
/// Stop scanning after N seconds (default: run until Ctrl+C).
|
|
#[arg(long)]
|
|
seconds: Option<u64>,
|
|
/// Save captured frames to a `.pcap` file.
|
|
#[arg(long)]
|
|
out: Option<PathBuf>,
|
|
/// After capturing, attempt a wordlist crack on the first handshake.
|
|
#[arg(long, value_name = "DICIONARIO")]
|
|
crack: Option<PathBuf>,
|
|
/// Output only the final JSON summary.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Analyze a `.pcap` file offline (networks, clients, and handshakes).
|
|
Analyze {
|
|
pcap: PathBuf,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Run a dictionary attack against a handshake in a `.pcap` file.
|
|
Crack {
|
|
pcap: PathBuf,
|
|
/// Wordlist file (one candidate passphrase per line).
|
|
#[arg(short, long)]
|
|
wordlist: PathBuf,
|
|
#[arg(long)]
|
|
bssid: Option<String>,
|
|
#[arg(long)]
|
|
station: Option<String>,
|
|
/// Apply common mutation rules to each word (capitalization, numeric
|
|
/// suffixes, simple leetspeak) — multiplies total runtime.
|
|
#[arg(long)]
|
|
rules: bool,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Send deauthentication frames to an AP/station (authorized testing only).
|
|
Deauth {
|
|
iface: String,
|
|
#[arg(long)]
|
|
bssid: String,
|
|
/// 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,
|
|
/// Confirm explicit authorization to test this target network.
|
|
#[arg(long)]
|
|
yes: bool,
|
|
},
|
|
/// Diagnose privileges, external tools, and wireless interfaces.
|
|
Doctor {
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Inspect and verify the hash-chained audit trail.
|
|
Audit {
|
|
#[command(subcommand)]
|
|
action: AuditAction,
|
|
},
|
|
/// Check for and apply `sabia` CLI updates.
|
|
Update {
|
|
#[arg(long)]
|
|
apply: bool,
|
|
},
|
|
/// Launch the web UI (local HTTP server + browser).
|
|
Ui {
|
|
#[arg(long, default_value = "4337")]
|
|
port: u16,
|
|
/// Do not open the browser automatically.
|
|
#[arg(long)]
|
|
no_open: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum IfaceAction {
|
|
List,
|
|
/// List channels supported by the wireless phy.
|
|
Channels {
|
|
iface: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Enable or disable monitor mode on a Wi-Fi interface.
|
|
Monitor {
|
|
iface: String,
|
|
#[arg(value_enum)]
|
|
state: OnOff,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AuditAction {
|
|
/// Verify the cryptographic hash chain integrity.
|
|
Verify { path: Option<PathBuf> },
|
|
/// Show the last N entries.
|
|
Show {
|
|
#[arg(long, default_value_t = 20)]
|
|
limit: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
|
enum OnOff {
|
|
On,
|
|
Off,
|
|
}
|
|
|
|
fn main() {
|
|
let cli = Cli::parse();
|
|
if let Err(e) = run(cli) {
|
|
eprintln!("erro: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn run(cli: Cli) -> sabia_core::Result<()> {
|
|
let audit_path = cli.audit.clone();
|
|
let engine = Engine::with_audit_path(&audit_path);
|
|
|
|
match cli.cmd {
|
|
Cmd::Iface { action } => match action {
|
|
IfaceAction::List => {
|
|
for i in iface::list_all()? {
|
|
let tag = if i.phy.is_some() { "wifi" } else { "—" };
|
|
println!(
|
|
"{:<16} {:<18} {:<6} {:<10} {}",
|
|
i.name,
|
|
i.mac,
|
|
tag,
|
|
format!("{:?}", i.mode).to_lowercase(),
|
|
if i.up { "up" } else { "down" }
|
|
);
|
|
}
|
|
}
|
|
IfaceAction::Channels { iface: name, json } => {
|
|
let channels = engine.channels(&name)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&channels)?);
|
|
} else {
|
|
for c in channels {
|
|
println!(
|
|
"canal {:<4} {:>5} MHz {:<6} {}{}",
|
|
c.channel,
|
|
c.freq_mhz,
|
|
c.band.label(),
|
|
if c.disabled { "desabilitado" } else { "ok" },
|
|
c.max_power_dbm
|
|
.map(|p| format!(" até {p:.1} dBm"))
|
|
.unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
IfaceAction::Monitor { iface: name, state } => {
|
|
let enable = matches!(state, OnOff::On);
|
|
engine.set_monitor(&name, enable)?;
|
|
println!(
|
|
"{name}: modo monitor {}",
|
|
if enable { "ativado" } else { "desativado" }
|
|
);
|
|
}
|
|
},
|
|
|
|
Cmd::Scan {
|
|
iface: name,
|
|
channel,
|
|
band,
|
|
hop_ms,
|
|
seconds,
|
|
out,
|
|
crack,
|
|
json,
|
|
} => {
|
|
let options = ScanOptions {
|
|
channel,
|
|
band,
|
|
hop_ms,
|
|
pcap_out: out,
|
|
duration_secs: seconds,
|
|
};
|
|
engine.start_scan_with(&name, options)?;
|
|
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
let r = running.clone();
|
|
let _ = ctrlc::set_handler(move || r.store(false, Ordering::SeqCst));
|
|
|
|
let interactive = std::io::stdout().is_terminal() && !json;
|
|
while running.load(Ordering::SeqCst) {
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
let snapshot = match engine.scan_snapshot(&name) {
|
|
Ok(s) => s,
|
|
Err(_) => break,
|
|
};
|
|
if !snapshot.running {
|
|
break;
|
|
}
|
|
if interactive {
|
|
print!("\x1b[2J\x1b[H");
|
|
print_snapshot(&snapshot);
|
|
}
|
|
}
|
|
|
|
engine.stop_scan(&name);
|
|
let snapshot = engine.scan_snapshot(&name)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
|
} else {
|
|
if interactive {
|
|
print!("\x1b[2J\x1b[H");
|
|
}
|
|
print_snapshot(&snapshot);
|
|
}
|
|
|
|
if let Some(wordlist) = crack {
|
|
crack_first(&engine, &name, &wordlist)?;
|
|
}
|
|
}
|
|
|
|
Cmd::Analyze { pcap, json } => {
|
|
let snapshot = engine.analyze_pcap(&pcap)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
|
} else {
|
|
print_snapshot(&snapshot);
|
|
}
|
|
}
|
|
|
|
Cmd::Crack {
|
|
pcap,
|
|
wordlist,
|
|
bssid,
|
|
station,
|
|
rules,
|
|
json,
|
|
} => {
|
|
let mut last = 0u64;
|
|
let report = sabia_core::crack::crack_pcap(
|
|
&pcap,
|
|
&wordlist,
|
|
bssid.as_deref(),
|
|
station.as_deref(),
|
|
rules,
|
|
|tried| {
|
|
if tried.saturating_sub(last) >= 4096 {
|
|
eprint!("\r{tried} senhas testadas…");
|
|
last = tried;
|
|
}
|
|
},
|
|
)?;
|
|
if !json {
|
|
eprintln!();
|
|
}
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
} else if let Some(password) = &report.password {
|
|
println!(
|
|
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms ({})",
|
|
report.tried,
|
|
report.elapsed_ms,
|
|
report.method.as_deref().unwrap_or("?")
|
|
);
|
|
} else {
|
|
println!(
|
|
"senha não encontrada ({} tentativas em {} ms)",
|
|
report.tried, report.elapsed_ms
|
|
);
|
|
}
|
|
}
|
|
|
|
Cmd::Deauth {
|
|
iface: name,
|
|
bssid,
|
|
station,
|
|
count,
|
|
reason,
|
|
yes,
|
|
} => {
|
|
if !yes {
|
|
return Err(sabia_core::SabiaError::InvalidInput(
|
|
"desautenticação derruba clientes; confirme com --yes apenas em rede própria ou com autorização".into(),
|
|
));
|
|
}
|
|
let report = engine.deauth(&name, &bssid, station.as_deref(), count, reason)?;
|
|
println!(
|
|
"{} quadros enviados para {} via {} (motivo {})",
|
|
report.frames_sent, report.station, report.iface, report.reason
|
|
);
|
|
}
|
|
|
|
Cmd::Doctor { json } => {
|
|
let report = engine.doctor();
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
} else {
|
|
print_doctor(&report);
|
|
}
|
|
}
|
|
|
|
Cmd::Audit { action } => match action {
|
|
AuditAction::Verify { path } => {
|
|
let path = path.unwrap_or_else(|| audit_path.clone());
|
|
if !path.exists() {
|
|
println!(
|
|
"{} não existe (nenhuma ação registrada ainda)",
|
|
path.display()
|
|
);
|
|
} else {
|
|
match sabia_core::audit::AuditLog::verify(&path)? {
|
|
None => println!("cadeia de auditoria íntegra ({})", path.display()),
|
|
Some(idx) => {
|
|
println!("cadeia ROMPIDA na entrada {idx} ({})", path.display())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AuditAction::Show { limit } => {
|
|
let entries = sabia_core::audit::AuditLog::read_entries(&audit_path, Some(limit))?;
|
|
if entries.is_empty() {
|
|
println!("nenhuma entrada em {}", audit_path.display());
|
|
}
|
|
for e in entries {
|
|
println!(
|
|
"{} {:<18} {:<18} {}",
|
|
e.timestamp, e.action, e.target, e.detail
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
Cmd::Update { apply } => {
|
|
let current = env!("CARGO_PKG_VERSION");
|
|
if apply {
|
|
let exe = std::env::current_exe()?;
|
|
let version = sabia_core::update::apply(&exe)?;
|
|
println!("atualizado para {version}");
|
|
} else {
|
|
let status = sabia_core::update::check(current)?;
|
|
if status.update_available {
|
|
println!(
|
|
"atualização disponível: {} -> {} (rode `sabia update --apply`)",
|
|
status.current_version,
|
|
status.latest_version.unwrap_or_default()
|
|
);
|
|
} else {
|
|
println!("já na última versão ({})", status.current_version);
|
|
}
|
|
}
|
|
}
|
|
|
|
Cmd::Ui { port, no_open } => {
|
|
web::serve(port, audit_path, !no_open)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::Result<()> {
|
|
let Some(hs) = engine
|
|
.handshakes(Some(iface))
|
|
.into_iter()
|
|
.find(|h| h.crackable)
|
|
else {
|
|
println!("\nnenhum handshake quebrável capturado");
|
|
return Ok(());
|
|
};
|
|
println!(
|
|
"\nquebrando handshake de {} ({}) com {}…",
|
|
hs.ssid.as_deref().unwrap_or("?"),
|
|
hs.bssid,
|
|
wordlist.display()
|
|
);
|
|
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?;
|
|
loop {
|
|
let job = engine.crack_job(&id).expect("job");
|
|
if job.status == "running" {
|
|
eprint!("\r{} senhas testadas…", job.tried);
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
continue;
|
|
}
|
|
eprintln!();
|
|
match job.report {
|
|
Some(report) => match report.password {
|
|
Some(password) => println!(
|
|
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms",
|
|
report.tried, report.elapsed_ms
|
|
),
|
|
None => println!(
|
|
"senha não encontrada ({} tentativas em {} ms)",
|
|
report.tried, report.elapsed_ms
|
|
),
|
|
},
|
|
None => println!("falha: {}", job.error.unwrap_or_default()),
|
|
}
|
|
break;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_snapshot(snapshot: &sabia_core::scan::ScanSnapshot) {
|
|
println!(
|
|
"{} {}{} {} quadros · {} APs · {} clientes · {} handshakes",
|
|
snapshot.iface,
|
|
if snapshot.running { "● " } else { "" },
|
|
snapshot
|
|
.channel
|
|
.map(|c| format!("canal {c}"))
|
|
.unwrap_or_default(),
|
|
snapshot.stats.frames,
|
|
snapshot.stats.aps,
|
|
snapshot.stats.clients,
|
|
snapshot.stats.handshakes
|
|
);
|
|
if !snapshot.access_points.is_empty() {
|
|
println!(
|
|
"\n{:<18} {:<24} {:<6} {:<8} {:<12} CLIENTES",
|
|
"BSSID", "SSID", "CANAL", "SINAL", "SEGURANÇA"
|
|
);
|
|
for ap in &snapshot.access_points {
|
|
println!(
|
|
"{:<18} {:<24} {:<6} {:<8} {:<12} {}",
|
|
ap.bssid,
|
|
ap.ssid.clone().unwrap_or_else(|| "<oculta>".into()),
|
|
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
|
ap.signal_dbm
|
|
.map(|s| format!("{s} dBm"))
|
|
.unwrap_or_default(),
|
|
ap.security.clone().unwrap_or_default(),
|
|
ap.clients.len()
|
|
);
|
|
}
|
|
}
|
|
if !snapshot.handshakes.is_empty() {
|
|
println!("\nHANDSHAKES");
|
|
for hs in &snapshot.handshakes {
|
|
println!(
|
|
"{:<18} {:<18} {:<16} msgs {:<8} {}{}",
|
|
hs.bssid,
|
|
hs.station,
|
|
hs.ssid.clone().unwrap_or_default(),
|
|
hs.messages,
|
|
hs.kind,
|
|
if hs.crackable { " · quebrável" } else { "" }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_doctor(report: &sabia_core::doctor::DoctorReport) {
|
|
println!("Sabiá {} ({})", report.version, report.platform);
|
|
println!(
|
|
"root: {} · iw: {} · ip: {} · curl: {} · captura: {}",
|
|
yes_no(report.root),
|
|
yes_no(report.iw),
|
|
yes_no(report.ip),
|
|
yes_no(report.curl),
|
|
report.capture_backend
|
|
);
|
|
println!(
|
|
"auditoria: {} ({})",
|
|
report.audit_path,
|
|
if report.audit_ok {
|
|
"íntegra"
|
|
} else {
|
|
"corrompida"
|
|
}
|
|
);
|
|
if !report.interfaces.is_empty() {
|
|
println!("\nINTERFACES");
|
|
for i in &report.interfaces {
|
|
println!(
|
|
"{:<16} {:<18} {:<10} monitor: {:<3} driver: {}",
|
|
i.name,
|
|
i.mac,
|
|
i.mode,
|
|
yes_no(i.monitor_supported),
|
|
i.driver.as_deref().unwrap_or("?")
|
|
);
|
|
}
|
|
}
|
|
if !report.notes.is_empty() {
|
|
println!("\nAVISOS");
|
|
for note in &report.notes {
|
|
println!("- {note}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn yes_no(value: bool) -> &'static str {
|
|
if value {
|
|
"sim"
|
|
} else {
|
|
"não"
|
|
}
|
|
}
|