//! Paginated HTML/PDF wireless security audit report generator (powered by Paged.js). //! //! Shared between the live in-app report preview and standalone offline HTML export. //! //! //! 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 { /// Placeholder replaced with the inline `paged.polyfill.min.js` script in /// standalone offline HTML exports. pub inline_pagedjs: bool, } const PAGED_MARKER: &str = ""; const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png"); const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /// Encodes the Sabiá logo as a `data:image/png;base64,...` URI so standalone /// exported reports render offline without an HTTP server. /// /// /// fn base64(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let b0 = chunk[0]; let b1 = chunk.get(1).copied(); let b2 = chunk.get(2).copied(); out.push(B64[(b0 >> 2) as usize] as char); out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char); out.push(match b1 { Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char, None => '=', }); out.push(match b2 { Some(b2) => B64[(b2 & 0b111111) as usize] as char, None => '=', }); } out } fn esc(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d) } pub fn format_unix(ts: u64) -> String { if ts == 0 { return "—".into(); } let days = (ts / 86_400) as i64; let secs = ts % 86_400; let (y, m, d) = civil_from_days(days); format!( "{d:02}/{m:02}/{y} {:02}:{:02} UTC", secs / 3600, (secs % 3600) / 60 ) } fn severity_label(severity: &str) -> &'static str { match severity { "bad" => "crítico", "warn" => "atenção", _ => "informativo", } } fn stat_card(value: impl std::fmt::Display, label: &str, variant: &str) -> String { format!( "
{}{}
", if variant.is_empty() { String::new() } else { format!(" v-{variant}") }, esc(label), esc(&value.to_string()), ) } fn findings_html(model: &ReportModel) -> String { if model.analysis.findings.is_empty() { return "

Nenhum achado relevante nas regras atuais.

".into(); } let cards = model .analysis .findings .iter() .map(|f| { let scope = match (&f.ssid, &f.bssid) { (Some(ssid), Some(bssid)) => format!("{ssid} · {bssid}"), (Some(ssid), None) => ssid.clone(), (None, Some(bssid)) => bssid.clone(), _ => "—".into(), }; format!( "
{}

{}

{}

{}
", esc(&f.severity), esc(&f.severity), severity_label(&f.severity), esc(&f.title), esc(&f.detail), esc(&scope), ) }) .collect::>() .join(""); format!("
{cards}
") } fn channels_html(model: &ReportModel) -> String { if model.analysis.bands.is_empty() { return "

Nenhum AP com canal identificado.

".into(); } model .analysis .bands .iter() .map(|band| { let max = band .candidates .iter() .map(|c| c.score) .fold(0.0f32, f32::max) .max(0.01); let rows = band .candidates .iter() .map(|c| { let width = (c.score / max * 100.0).clamp(2.0, 100.0); let best = band.recommended == Some(c.channel); format!( "
{}{}{:.2}{} AP(s){}
", if best { " chan-best" } else { "" }, if c.score < 0.5 { "low" } else if c.score < 1.5 { "mid" } else { "high" }, c.channel, if c.dfs { "DFS" } else { "" }, if c.score < 0.5 { "low" } else if c.score < 1.5 { "mid" } else { "high" }, c.score, c.aps, if best { " · recomendado" } else { "" }, ) }) .collect::>() .join(""); format!( "

{}

canal recomendado: {}
{}
", esc(&band.band), band.recommended .map(|c| c.to_string()) .unwrap_or_else(|| "—".into()), rows ) }) .collect::>() .join("") } fn aps_html(model: &ReportModel) -> String { if model.access_points.is_empty() { return "

Nenhum AP capturado.

".into(); } let rows = model .access_points .iter() .map(|ap| { format!( "{}{}{}{}{}{}{}{}{}", esc(ap.ssid.as_deref().filter(|s| !s.is_empty()).unwrap_or("(oculta)")), esc(&ap.bssid), ap.channel.map(|c| c.to_string()).unwrap_or_else(|| "—".into()), esc(ap.security.as_deref().unwrap_or("—")), esc(ap.cipher.as_deref().unwrap_or("—")), if ap.pmf_required { "exigido" } else if ap.pmf { "sim" } else { "não" }, if ap.wps { if ap.wps_locked == Some(true) { "travado" } else { "ativo" } } else { "—" }, ap.signal_dbm.map(|s| s.to_string()).unwrap_or_else(|| "—".into()), ap.clients.len(), ) }) .collect::>() .join(""); format!( "{rows}
SSIDBSSIDCanalSegurançaCifraPMFWPSSinalClientes
" ) } fn clients_html(model: &ReportModel) -> String { if model.clients.is_empty() { return "

Nenhum cliente capturado.

".into(); } let rows = model .clients .iter() .take(120) .map(|c| { format!( "{}{}{}{}{}{}{}", esc(&c.mac), esc(c.vendor.as_deref().unwrap_or("—")), esc(c.bssid.as_deref().unwrap_or("—")), c.signal_dbm.map(|s| s.to_string()).unwrap_or_else(|| "—".into()), c.packets, if c.associated { "sim" } else { "não" }, esc(&c.probes.join(", ")), ) }) .collect::>() .join(""); format!( "{rows}
MACFabricanteBSSIDSinalPacotesAssociadoProbes
" ) } fn handshakes_html(model: &ReportModel) -> String { if model.handshakes.is_empty() { return "

Nenhum handshake ou PMKID capturado.

".into(); } let rows = model .handshakes .iter() .map(|h| { format!( "{}{}{}{}{}{}", esc(&h.station), esc(h.ssid.as_deref().unwrap_or("—")), esc(h.kind.as_str()), esc(&h.messages), if h.pmkid.is_some() { "PMKID" } else if h.crackable { "sim" } else { "não" }, esc(h.password.as_deref().unwrap_or("—")), ) }) .collect::>() .join(""); format!( "{rows}
EstaçãoSSIDTipoMensagensQuebrávelSenha
" ) } fn wpa3_html(model: &ReportModel) -> String { if model.analysis.wpa3.is_empty() { return "

Nenhuma rede WPA3 observada.

".into(); } model .analysis .wpa3 .iter() .map(|w| { let notes = if w.notes.is_empty() { "
  • Nenhuma observação adicional.
  • ".to_string() } else { w.notes .iter() .map(|n| format!("
  • {}
  • ", esc(n))) .collect::>() .join("") }; format!( "
    {}{}{}

    PMF: {} · obrigatório: {} · transição: {}

      {}
    ", esc(w.ssid.as_deref().unwrap_or("(oculta)")), esc(&w.bssid), esc(&w.mode), if w.pmf { "sim" } else { "não" }, if w.pmf_required { "sim" } else { "não" }, if w.transition { "sim" } else { "não" }, notes ) }) .collect::>() .join("") } fn audit_html(model: &ReportModel) -> String { if model.audit.is_empty() { return "

    Trilha de auditoria vazia.

    ".into(); } let rows = model .audit .iter() .take(60) .map(|e| { let detail = if e.detail.chars().count() > 140 { format!("{}…", e.detail.chars().take(140).collect::()) } else { e.detail.clone() }; format!( "{}{}{}{}{}", esc(&e.timestamp), esc(&e.action), esc(&e.target), esc(&detail), esc(&e.hash[..12.min(e.hash.len())]), ) }) .collect::>() .join(""); format!( "{rows}
    QuandoAçãoAlvoDetalheHash
    " ) } const CSS: &str = r#" :root { --ink: #17211a; --muted: #5b6760; --faint: #838f87; --line: #d8dfdb; --soft: #f3f6f3; --accent: #92400e; --accent-soft: #fbe9d9; --deep: #241705; --deep2: #140d02; --signal: #0e7490; --signal-soft: #d9f0f3; --bad: #b3261e; --bad-soft: #fbe4e2; --warn: #92400e; --warn-soft: #fbe9d9; --info: #0e7490; --info-soft: #d9f0f3; --display: "Source Serif 4", Georgia, serif; --body: "Inter", "Segoe UI", system-ui, sans-serif; --mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { font-family: var(--body); color: var(--ink); font-size: 10.5pt; line-height: 1.55; background: #dfe4e1; font-variant-numeric: tabular-nums; } .report { background: #fff; } h1, h2, h3 { font-family: var(--display); line-height: 1.15; margin: 0; } h2 { font-size: 18pt; letter-spacing: -0.01em; margin: 0 0 1mm; color: var(--deep); break-after: avoid; string-set: section content(); } h2::before { content: ""; display: block; width: 9mm; height: 1mm; background: var(--accent); margin-bottom: 3mm; border-radius: 1mm; } h3 { font-size: 12.5pt; margin: 0 0 6pt; break-after: avoid; color: var(--deep); } p { margin: 0 0 8pt; } .mono { font-family: var(--mono); font-size: 8.8pt; } .muted { color: var(--muted); } .small { font-size: 8.2pt; } .strong { font-weight: 600; } .num { text-align: right; font-variant-numeric: tabular-nums; } .eyebrow { font-family: var(--mono); font-size: 8pt; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 700; margin: 0 0 3mm; } .desc { color: var(--muted); font-size: 9.6pt; margin: 0 0 5mm; max-width: 150mm; } section.chapter { margin: 0 0 9mm; break-before: page; } section.chapter:first-of-type { break-before: avoid; } /* ---------- cover ---------- */ .cover { page: cover; height: 297mm; width: 210mm; margin: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 22mm 18mm; break-after: page; position: relative; overflow: hidden; background: radial-gradient(120% 120% at 100% 0%, #3a2409 0%, var(--deep) 45%, var(--deep2) 100%); color: #fff; } .cover .top { display: flex; justify-content: space-between; align-items: flex-start; } .cover .brand { display: flex; align-items: center; gap: 3mm; } .cover .brand img { width: 11mm; height: 11mm; border-radius: 3mm; display: block; } .cover .brand span { font-family: var(--display); font-size: 14pt; font-weight: 700; letter-spacing: -0.01em; } .cover .badge { font: 700 8pt var(--mono); letter-spacing: .14em; text-transform: uppercase; color: #ffd38a; border: 1px solid #a66a2e; background: rgba(255,211,138,.08); padding: 2.5mm 4mm; border-radius: 1mm; white-space: nowrap; } .cover .mid { margin-top: auto; } .cover .eyebrow { color: #ffb963; margin-bottom: 4mm; } .cover h1 { font-size: 40pt; letter-spacing: -0.02em; max-width: 150mm; color: #fff; margin-bottom: 6mm; } .cover .lead { max-width: 125mm; font-size: 12.5pt; color: #e7d9c4; line-height: 1.6; } .cover .meta { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6mm; margin-top: 16mm; padding-top: 6mm; border-top: 1px solid rgba(255,255,255,.18); } .cover .meta dt { font: 500 7.5pt var(--mono); text-transform: uppercase; letter-spacing: .12em; color: #c9b599; margin-bottom: 1.5mm; } .cover .meta dd { margin: 0; font-size: 10.5pt; color: #fff; } .cover .foot { font-size: 8.3pt; color: #c9b599; margin-top: 10mm; max-width: 150mm; } /* ---------- table of contents ---------- */ .toc { break-after: page; } .toc h2::before { display: none; } .toc ol { list-style: none; margin: 7mm 0 0; padding: 0; } .toc a { display: flex; align-items: baseline; gap: 3mm; color: var(--ink); text-decoration: none; padding: 2.8mm 0; border-bottom: .5pt solid var(--line); font: 500 11pt var(--body); } .toc a::after { content: target-counter(attr(href), page); font: 700 10pt var(--mono); color: var(--accent); margin-left: auto; } .toc .n { font: 700 9.5pt var(--mono); color: var(--faint); width: 8mm; flex: none; } /* ---------- kpis ---------- */ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 0 0 7mm; } .stat { border-top: 1mm solid var(--accent); background: var(--soft); padding: 4mm 4.5mm 4.5mm; break-inside: avoid; } .stat.v-bad { border-top-color: var(--bad); } .stat.v-warn { border-top-color: var(--warn); } .stat.v-ok { border-top-color: #1a7f37; } .stat .l { display: block; font: 500 7.3pt var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-bottom: 2mm; } .stat strong { display: block; font-family: var(--display); font-size: 25pt; line-height: 1; letter-spacing: -0.01em; color: var(--deep); } .stat.v-bad strong { color: var(--bad); } .stat.v-warn strong { color: var(--warn); } /* ---------- findings ---------- */ .findings { display: grid; grid-template-columns: 1fr 1fr; gap: 4mm; } .finding { border: 1px solid var(--line); border-left: 1mm solid var(--info); padding: 4mm 4.5mm; break-inside: avoid; background: #fff; } .finding-bad { border-left-color: var(--bad); } .finding-warn { border-left-color: var(--warn); } .finding header { display: flex; align-items: center; gap: 3mm; margin-bottom: 2mm; } .finding h3 { margin: 0; font-size: 11pt; } .finding p { font-size: 9.3pt; color: #36443e; } .finding .scope { color: var(--muted); font-size: 8pt; } .tag { display: inline-block; border-radius: 1mm; padding: 1mm 2.3mm; font: 700 7.3pt var(--mono); text-transform: uppercase; letter-spacing: .08em; background: #e8ecea; color: #47544e; } .tag-bad { background: var(--bad-soft); color: var(--bad); } .tag-warn { background: var(--warn-soft); color: var(--warn); } .tag-info { background: var(--info-soft); color: var(--info); } /* ---------- tables ---------- */ table { width: 100%; border-collapse: collapse; margin: 0 0 6mm; font-size: 8.8pt; } thead { display: table-header-group; } th { text-align: left; font: 500 7.5pt var(--mono); text-transform: uppercase; letter-spacing: .09em; color: var(--muted); border-bottom: 1.3pt solid var(--ink); padding: 2.2mm 2.3mm; vertical-align: bottom; } td { border-bottom: .5pt solid var(--line); padding: 1.9mm 2.3mm; vertical-align: top; overflow-wrap: anywhere; } tbody tr { break-inside: avoid; } tbody tr:nth-child(even) td { background: #f7f9f7; } .band { margin: 0 0 7mm; break-inside: avoid; } .band header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3mm; } .chan-row { display: grid; grid-template-columns: 11mm 1fr 12mm 30mm; gap: 2mm; align-items: center; font-size: 8.6pt; padding: 1mm 0; } .chan-n { font-weight: 600; } .chan-bar { height: 3.2mm; background: #eef2ef; border-radius: 999px; overflow: hidden; } .chan-bar i { display: block; height: 100%; background: #b8c8bf; border-radius: 999px; } .chan-bar i.diff-low { background: linear-gradient(90deg, #1a7f37, #4fae6c); } .chan-bar i.diff-mid { background: linear-gradient(90deg, #92400e, #c2792e); } .chan-bar i.diff-high { background: linear-gradient(90deg, #b3261e, #d4635c); } .chan-best { background: var(--accent-soft); border-radius: 1mm; padding: 1.4mm 2mm; } .chan-score { text-align: right; font-variant-numeric: tabular-nums; } .chan-aps { color: var(--muted); font-size: 8pt; } .mini-tag { display: inline-block; margin-left: 1.5mm; border: .5pt solid var(--line); border-radius: 1mm; padding: 0 1.2mm; font-size: 6.5pt; color: var(--muted); } .wpa3 { border: 1px solid var(--line); border-left: 1mm solid var(--signal); padding: 4mm 4.5mm; margin-bottom: 3mm; break-inside: avoid; background: #fff; } .wpa3 header { display: flex; gap: 3mm; align-items: center; margin-bottom: 2mm; } .wpa3 ul { margin: 2mm 0 0 4mm; padding: 0; } .wpa3 li { margin-bottom: 1mm; } .callout { background: var(--accent-soft); border-left: 1mm solid var(--accent); padding: 4mm 4.5mm; margin: 0 0 5mm; } .callout strong { color: var(--deep); } .callout.v-bad { background: var(--bad-soft); border-left-color: var(--bad); } .callout.v-bad strong { color: var(--bad); } .callout.v-warn { background: var(--warn-soft); border-left-color: var(--warn); } .callout.v-warn strong { color: var(--warn); } .callout.v-ok { background: #e6f4ea; border-left-color: #1a7f37; } .callout.v-ok strong { color: #1a7f37; } .pagedjs_page { background: #fff; } .pagedjs_pages { margin: 0 auto; } @page { size: A4; margin: 24mm 16mm 20mm 16mm; background: #fff; @top-left { content: "SABIÁ · AUDITORIA WI-FI"; font: 600 7.5pt var(--mono); letter-spacing: .1em; color: var(--faint); vertical-align: bottom; padding-bottom: 4mm; } @top-center { content: string(section); font: 500 7.5pt var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); vertical-align: bottom; padding-bottom: 4mm; text-align: center; } @top-right { content: "\25CF USO AUTORIZADO"; font: 700 7.5pt var(--mono); letter-spacing: .1em; color: var(--accent); vertical-align: bottom; padding-bottom: 4mm; text-align: right; white-space: nowrap; } @bottom-left { content: string(footline); font: 400 7.5pt var(--mono); color: var(--faint); vertical-align: top; padding-top: 4mm; } @bottom-right { content: counter(page) " / " counter(pages); font: 700 8pt var(--mono); color: var(--ink); vertical-align: top; padding-top: 4mm; text-align: right; } } @page cover { margin: 0; @top-left { content: none; } @top-center { content: none; } @top-right { content: none; } @bottom-left { content: none; } @bottom-right { content: none; } } .doc-meta { string-set: footline attr(data-footline); } #pagedjs-ignore { display: none; } @media print { body { background: #fff; } } "#; pub fn render(model: &ReportModel, options: ReportOptions) -> String { let paged = if options.inline_pagedjs { PAGED_MARKER.to_string() } else { "".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("/vendor/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")); } }