//! Relatório de auditoria em HTML paginado com Paged.js. //! //! O mesmo modelo alimenta a pré-visualização na interface e o arquivo //! standalone. A paginação é feita no navegador (A4, margens, cabeçalho e //! rodapé), então o PDF sai pelo diálogo de impressão sem depender de //! Chromium no servidor. use crate::analysis::Analysis; use crate::audit::AuditEntry; use crate::handshake::HandshakeInfo; use crate::scan::{AccessPoint, Client, ScanStats}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] pub struct ReportModel { pub title: String, pub version: String, pub iface: String, pub running: bool, pub generated_unix: u64, pub stats: ScanStats, pub access_points: Vec, pub clients: Vec, pub handshakes: Vec, pub analysis: Analysis, pub audit: Vec, } #[derive(Debug, Clone, Copy, Default)] pub struct ReportOptions { /// Troca o `".to_string() }; let stats = &model.stats; let crackable = model.handshakes.iter().filter(|h| h.crackable).count(); let critical = model .analysis .findings .iter() .filter(|f| f.severity == "bad") .count(); let warnings = model .analysis .findings .iter() .filter(|f| f.severity == "warn") .count(); let date = format_unix(model.generated_unix); let title = if model.title.is_empty() { "Relatório de auditoria Wi-Fi".to_string() } else { model.title.clone() }; let logo_data_uri = format!("data:image/png;base64,{}", base64(LOGO_PNG)); let footline = format!("Uso autorizado · gerado em {date}"); let overall = if critical > 0 { "bad" } else if warnings > 0 { "warn" } else { "ok" }; format!( r##" {title} {paged}
Sabiá
Uso autorizado

Relatório de auditoria · redes sem fio

{title}

Achados de configuração, canais, clientes, handshakes e trilha de auditoria da captura, com verificação criptográfica — nenhuma senha é inventada.

Interface
{iface}
Gerado em
{date}
Versão do Sabiá
{version}
Captura
{capture_state}

Documento gerado localmente. Use somente em redes próprias ou com autorização explícita do responsável. Cada ação ativa fica registrada na trilha de auditoria encadeada por hash (seção final).

Sumário

  1. 01 Sumário executivo
  2. 02 Achados
  3. 03 Canais e interferência
  4. 04 Redes observadas
  5. 05 Clientes
  6. 06 Handshakes e PMKID
  7. 07 WPA3 e PMF
  8. 08 Trilha de auditoria
  9. 09 Metodologia e limites

Sumário executivo

{stat_aps}{stat_clients}{stat_hs}{stat_crackable}
{critical} achado(s) crítico(s) e {warnings} de atenção. O relatório lista as redes por segurança anunciada, os canais com menor interferência e o material de handshake disponível para verificação de senha.

{summary}

Escopo observado

Captura em {iface}, com {frames} quadros processados, {eapol} EAPOL e {deauth} quadros de desautenticação/desassociação. Os dados refletem apenas o que foi visto no ar durante a sessão; não há varredura ativa além das ações listadas na auditoria.

Achados

{findings}

Canais e interferência

Pontuação menor é melhor. O cálculo pondera sobreposição espectral e intensidade de cada AP; em 2,4 GHz a recomendação fica restrita a 1, 6 e 11.

{channels}

Redes observadas

{aps}

Clientes

Exibindo até 120 estações ordenadas por volume de quadros.

{clients}

Handshakes e PMKID

{handshakes}

Handshakes com senha encontrada aparecem com o valor em texto; os demais podem ser exportados em .pcap ou .hc22000 na interface.

WPA3 e PMF

{wpa3}

Trilha de auditoria

Últimas 60 entradas, com hash encadeado. A verificação completa fica disponível na interface.

{audit}

Metodologia e limites

O Sabiá opera em modo monitor, agrega beacons, respostas de probe, associação e EAPOL, e só injeta quadros quando uma ação é confirmada na interface. A análise de canais é uma estimativa de sobreposição, não uma medição de espectro por SDR. A detecção de evil twin é heurística: nomes iguais com seguranças diferentes e rajadas de gerenciamento merecem verificação manual.

O teste de PMF observa reassociação após desautenticação; um resultado inconclusivo não prova que o PMF está ativo. O ataque WPS depende de reaver/bully instalados e de o AP aceitar PIN. Nenhuma senha é inventada: handshakes só aparecem como quebrados quando a verificação criptográfica local confirma a candidata.

Relatório gerado pelo Sabiá {version} em {date}.

"##, title = esc(&title), css = CSS, paged = paged, logo = logo_data_uri, footline = esc(&footline), iface = esc(&model.iface), date = esc(&date), version = esc(&model.version), capture_state = if model.running { "em andamento" } else { "encerrada" }, stat_aps = stat_card(stats.aps, "redes", ""), stat_clients = stat_card(stats.clients, "clientes", ""), stat_hs = stat_card(stats.handshakes, "handshakes", ""), stat_crackable = stat_card( crackable, "quebráveis", if crackable > 0 { "bad" } else { "" } ), overall = overall, critical = critical, warnings = warnings, summary = summary_html(model), frames = stats.frames, eapol = stats.eapol_frames, deauth = stats.deauth_frames, findings = findings_html(model), channels = channels_html(model), aps = aps_html(model), clients = clients_html(model), handshakes = handshakes_html(model), wpa3 = wpa3_html(model), audit = audit_html(model), ) } fn summary_html(model: &ReportModel) -> String { let open = model .access_points .iter() .filter(|a| a.security.as_deref() == Some("Open")) .count(); let wps = model.access_points.iter().filter(|a| a.wps).count(); let no_pmf = model .access_points .iter() .filter(|a| { !a.pmf && matches!( a.security.as_deref(), Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3") ) }) .count(); let mut parts = Vec::new(); parts.push(format!( "Foram observadas {} redes e {} estações.", model.stats.aps, model.stats.clients )); if open > 0 { parts.push(format!("{open} rede(s) aberta(s)")); } if wps > 0 { parts.push(format!("{wps} com WPS anunciado")); } if no_pmf > 0 { parts.push(format!("{no_pmf} sem PMF em WPA2")); } if model.stats.handshakes > 0 { parts.push(format!( "{} handshake(s)/PMKID capturado(s)", model.stats.handshakes )); } format!("{}.", parts.join("; ")) } #[cfg(test)] mod tests { use super::*; use crate::analysis::Analysis; use crate::scan::ScanStats; fn model() -> ReportModel { ReportModel { title: "Teste".into(), version: "0.1.0".into(), iface: "wlan0mon".into(), running: false, generated_unix: 1_700_000_000, stats: ScanStats::default(), access_points: Vec::new(), clients: Vec::new(), handshakes: Vec::new(), analysis: Analysis::default(), audit: Vec::new(), } } #[test] fn renders_escaped_standalone_html() { let mut m = model(); m.title = "Rede ".into(); let html = render(&m, ReportOptions::default()); assert!(html.contains("Rede <x>")); assert!(html.contains("paged.polyfill.min.js")); assert!(html.contains("@page")); } #[test] fn renders_cover_toc_and_section_anchors() { let html = render(&model(), ReportOptions::default()); assert!(html.contains("class=\"cover\"")); assert!(html.contains("data:image/png;base64,")); assert!(html.contains("class=\"toc\"")); assert!(html.contains("href=\"#sec-exec\"")); assert!(html.contains("id=\"sec-exec\"")); assert!(html.contains("id=\"sec-method\"")); } #[test] fn base64_matches_known_vectors() { // RFC 4648 test vectors. assert_eq!(base64(b""), ""); assert_eq!(base64(b"f"), "Zg=="); assert_eq!(base64(b"fo"), "Zm8="); assert_eq!(base64(b"foo"), "Zm9v"); assert_eq!(base64(b"foob"), "Zm9vYg=="); assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } #[test] fn inline_option_uses_placeholder() { let html = render( &model(), ReportOptions { inline_pagedjs: true, }, ); assert!(html.contains(PAGED_MARKER)); } #[test] fn formats_dates() { assert_eq!(format_unix(0), "—"); assert!(format_unix(1_700_000_000).ends_with("UTC")); } }