feat: relatório de auditoria redesenhado (capa, sumário, cabeçalho paginado)

- report.rs: capa em página A4 cheia com o logo do Sabiá embutido (base64,
  funciona offline), sumário com âncoras pra cada seção, cabeçalho/rodapé
  corridos via @page margin boxes (seção atual, numeração X/Y, selo "uso
  autorizado"), cards de KPI e achados com cor por severidade, tabelas
  com cabeçalho/zebra consistentes — paginação em A4 continua feita pelo
  Paged.js no navegador (sem depender de Chromium no servidor).
- Vendoriza paged.polyfill.min.js (MIT) — antes era carregado de fora.
- tauri.conf.json: CSP libera frame-src pro preview do relatório embutido
  (iframe) no app desktop.
This commit is contained in:
Lucas Rayan committed 2026-10-24 14:54:21 -03:00
1 parent 91aacba939
commit 7b39c67830
3 files changed
+816 -1

No files matched your search

+811
View File
@@ -0,0 +1,811 @@
//! 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<AccessPoint>,
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
pub analysis: Analysis,
pub audit: Vec<AuditEntry>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ReportOptions {
/// Troca o `<script src>` por um marcador para o servidor embutir o
/// polyfill e gerar um HTML que funciona offline.
pub inline_pagedjs: bool,
}
const PAGED_MARKER: &str = "<!--SABIA_PAGEDJS-->";
const LOGO_PNG: &[u8] = include_bytes!("../../../apps/gui/ui/sabia.png");
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Embute o logo como `data:` URI — sem isso o relatório standalone (modo
/// offline, pensado pra abrir sem servidor nenhum) teria um `<img
/// src="/sabia.png">` que nunca resolve fora do `sabia ui`. Pequeno o
/// bastante (poucos KB) pra não valer a pena puxar uma crate de base64 só
/// por isso.
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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!(
"<div class=\"stat{}\"><span class=\"l\">{}</span><strong>{}</strong></div>",
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 "<p class=\"muted\">Nenhum achado relevante nas regras atuais.</p>".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!(
"<article class=\"finding finding-{}\"><header><span class=\"tag tag-{}\">{}</span><h3>{}</h3></header><p>{}</p><div class=\"scope mono\">{}</div></article>",
esc(&f.severity),
esc(&f.severity),
severity_label(&f.severity),
esc(&f.title),
esc(&f.detail),
esc(&scope),
)
})
.collect::<Vec<_>>()
.join("");
format!("<div class=\"findings\">{cards}</div>")
}
fn channels_html(model: &ReportModel) -> String {
if model.analysis.bands.is_empty() {
return "<p class=\"muted\">Nenhum AP com canal identificado.</p>".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!(
"<div class=\"chan-row{} diff-{}\"><span class=\"chan-n mono\">{}{}</span><span class=\"chan-bar\"><i class=\"diff-{}\" style=\"width:{width:.1}%\"></i></span><span class=\"chan-score\">{:.2}</span><span class=\"chan-aps\">{} AP(s){} </span></div>",
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 { "<span class=\"mini-tag\">DFS</span>" } 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::<Vec<_>>()
.join("");
format!(
"<section class=\"band\"><header><h3>{}</h3><span class=\"muted\">canal recomendado: {}</span></header>{}</section>",
esc(&band.band),
band.recommended
.map(|c| c.to_string())
.unwrap_or_else(|| "—".into()),
rows
)
})
.collect::<Vec<_>>()
.join("")
}
fn aps_html(model: &ReportModel) -> String {
if model.access_points.is_empty() {
return "<p class=\"muted\">Nenhum AP capturado.</p>".into();
}
let rows = model
.access_points
.iter()
.map(|ap| {
format!(
"<tr><td class=\"strong\">{}</td><td class=\"mono\">{}</td><td class=\"mono\">{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td></tr>",
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::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>SSID</th><th>BSSID</th><th>Canal</th><th>Segurança</th><th>Cifra</th><th>PMF</th><th>WPS</th><th>Sinal</th><th>Clientes</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn clients_html(model: &ReportModel) -> String {
if model.clients.is_empty() {
return "<p class=\"muted\">Nenhum cliente capturado.</p>".into();
}
let rows = model
.clients
.iter()
.take(120)
.map(|c| {
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td><td>{}</td><td>{}</td></tr>",
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::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>MAC</th><th>Fabricante</th><th>BSSID</th><th>Sinal</th><th>Pacotes</th><th>Associado</th><th>Probes</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn handshakes_html(model: &ReportModel) -> String {
if model.handshakes.is_empty() {
return "<p class=\"muted\">Nenhum handshake ou PMKID capturado.</p>".into();
}
let rows = model
.handshakes
.iter()
.map(|h| {
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
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::<Vec<_>>()
.join("");
format!(
"<table><thead><tr><th>Estação</th><th>SSID</th><th>Tipo</th><th>Mensagens</th><th>Quebrável</th><th>Senha</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn wpa3_html(model: &ReportModel) -> String {
if model.analysis.wpa3.is_empty() {
return "<p class=\"muted\">Nenhuma rede WPA3 observada.</p>".into();
}
model
.analysis
.wpa3
.iter()
.map(|w| {
let notes = if w.notes.is_empty() {
"<li>Nenhuma observação adicional.</li>".to_string()
} else {
w.notes
.iter()
.map(|n| format!("<li>{}</li>", esc(n)))
.collect::<Vec<_>>()
.join("")
};
format!(
"<article class=\"wpa3\"><header><strong>{}</strong><span class=\"mono\">{}</span><span class=\"tag tag-info\">{}</span></header><p>PMF: {} · obrigatório: {} · transição: {}</p><ul>{}</ul></article>",
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::<Vec<_>>()
.join("")
}
fn audit_html(model: &ReportModel) -> String {
if model.audit.is_empty() {
return "<p class=\"muted\">Trilha de auditoria vazia.</p>".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::<String>())
} else {
e.detail.clone()
};
format!(
"<tr><td class=\"mono\">{}</td><td>{}</td><td class=\"mono\">{}</td><td class=\"mono small\">{}</td><td class=\"mono small\">{}</td></tr>",
esc(&e.timestamp),
esc(&e.action),
esc(&e.target),
esc(&detail),
esc(&e.hash[..12.min(e.hash.len())]),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<table class=\"compact\"><thead><tr><th>Quando</th><th>Ação</th><th>Alvo</th><th>Detalhe</th><th>Hash</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
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 {
"<script src=\"/paged.polyfill.min.js\"></script>".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##"<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>{css}</style>
{paged}
</head>
<body>
<div class="doc-meta" data-footline="{footline}"></div>
<main class="report">
<section class="cover">
<div class="top">
<div class="brand"><img src="{logo}" alt=""><span>Sabiá</span></div>
<span class="badge">Uso autorizado</span>
</div>
<div class="mid">
<p class="eyebrow">Relatório de auditoria · redes sem fio</p>
<h1>{title}</h1>
<p class="lead">Achados de configuração, canais, clientes, handshakes e trilha de auditoria da captura, com verificação criptográfica — nenhuma senha é inventada.</p>
</div>
<div>
<dl class="meta">
<div><dt>Interface</dt><dd class="mono">{iface}</dd></div>
<div><dt>Gerado em</dt><dd>{date}</dd></div>
<div><dt>Versão do Sabiá</dt><dd>{version}</dd></div>
<div><dt>Captura</dt><dd>{capture_state}</dd></div>
</dl>
<p class="foot">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).</p>
</div>
</section>
<section class="toc">
<h2>Sumário</h2>
<ol>
<li><a href="#sec-exec"><span class="n">01</span> Sumário executivo</a></li>
<li><a href="#sec-findings"><span class="n">02</span> Achados</a></li>
<li><a href="#sec-channels"><span class="n">03</span> Canais e interferência</a></li>
<li><a href="#sec-aps"><span class="n">04</span> Redes observadas</a></li>
<li><a href="#sec-clients"><span class="n">05</span> Clientes</a></li>
<li><a href="#sec-hs"><span class="n">06</span> Handshakes e PMKID</a></li>
<li><a href="#sec-wpa3"><span class="n">07</span> WPA3 e PMF</a></li>
<li><a href="#sec-audit"><span class="n">08</span> Trilha de auditoria</a></li>
<li><a href="#sec-method"><span class="n">09</span> Metodologia e limites</a></li>
</ol>
</section>
<section class="chapter" id="sec-exec">
<h2>Sumário executivo</h2>
<div class="stats">
{stat_aps}{stat_clients}{stat_hs}{stat_crackable}
</div>
<div class="callout v-{overall}"><strong>{critical} achado(s) crítico(s) e {warnings} de atenção.</strong> 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.</div>
<p>{summary}</p>
<h3 style="margin-top:8mm">Escopo observado</h3>
<p class="muted">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.</p>
</section>
<section class="chapter" id="sec-findings">
<h2>Achados</h2>
{findings}
</section>
<section class="chapter" id="sec-channels">
<h2>Canais e interferência</h2>
<p class="desc">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.</p>
{channels}
</section>
<section class="chapter" id="sec-aps">
<h2>Redes observadas</h2>
{aps}
</section>
<section class="chapter" id="sec-clients">
<h2>Clientes</h2>
<p class="desc">Exibindo até 120 estações ordenadas por volume de quadros.</p>
{clients}
</section>
<section class="chapter" id="sec-hs">
<h2>Handshakes e PMKID</h2>
{handshakes}
<p class="muted">Handshakes com senha encontrada aparecem com o valor em texto; os demais podem ser exportados em .pcap ou .hc22000 na interface.</p>
</section>
<section class="chapter" id="sec-wpa3">
<h2>WPA3 e PMF</h2>
{wpa3}
</section>
<section class="chapter" id="sec-audit">
<h2>Trilha de auditoria</h2>
<p class="desc">Últimas 60 entradas, com hash encadeado. A verificação completa fica disponível na interface.</p>
{audit}
</section>
<section class="chapter" id="sec-method">
<h2>Metodologia e limites</h2>
<p>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.</p>
<p>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.</p>
<p class="muted">Relatório gerado pelo Sabiá {version} em {date}.</p>
</section>
</main>
<script>
(function () {{
function ready() {{
try {{
parent.postMessage({{ type: 'sabia-report-ready', pages: document.querySelectorAll('.pagedjs_page').length }}, '*');
}} catch (_) {{}}
}}
if (window.PagedPolyfill && window.PagedPolyfill.preview) {{
window.PagedPolyfill.preview().then(ready).catch(ready);
}} else {{
window.addEventListener('load', ready);
}}
window.addEventListener('message', function (event) {{
if (event.data && event.data.type === 'sabia-report-print') window.print();
}});
}})();
</script>
</body>
</html>"##,
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 <x>".into();
let html = render(&m, ReportOptions::default());
assert!(html.contains("Rede &lt;x&gt;"));
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"));
}
}