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:
1 parent
91aacba939
commit
7b39c67830
3 files changed
+816
-1
No files matched your search
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
|
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'self' data: blob:; object-src 'none'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||||
|
*/
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
|
||||||
@@ -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('&', "&")
|
||||||
|
.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!(
|
||||||
|
"<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 <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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user