use super::{clean, stats::SessionStats};
pub fn render(stats: &SessionStats) -> String {
let mut rows = String::new();
for (kind, count) in &stats.counts {
rows.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{count}</td></tr>\n",
html_escape(&clean(kind))
));
}
let mut blocked = String::new();
if stats.blocked_attempts.is_empty() {
blocked.push_str("<p class=\"muted\">No blocked attempts were observed. \
Remember: observation channels are optional (see notices) — enforcement is active regardless.</p>\n");
} else {
blocked.push_str(
"<table><tr><th>path</th><th>process</th><th>source</th><th>count</th></tr>\n",
);
for b in &stats.blocked_attempts {
blocked.push_str(&format!(
"<tr><td class=\"path\">{}</td><td>{}</td><td>{}</td><td class=\"num\">{}</td></tr>\n",
html_escape(&clean(&b.path)),
html_escape(&clean(&b.comm)),
html_escape(&clean(&b.source)),
b.count
));
}
blocked.push_str("</table>\n");
}
let mut net = String::new();
if !stats.net_requests.is_empty() {
net.push_str("<table><tr><th>host</th><th>port</th><th>decision</th></tr>\n");
for r in &stats.net_requests {
net.push_str(&format!(
"<tr><td>{}</td><td class=\"num\">{}</td><td class=\"{}\">{}</td></tr>\n",
html_escape(&clean(&r.host)),
r.port,
if r.allowed { "ok" } else { "deny" },
if r.allowed { "allow" } else { "DENIED" }
));
}
net.push_str("</table>\n");
} else {
net.push_str("<p class=\"muted\">No network requests (network is off by default).</p>\n");
}
let mut notices = String::new();
for n in &stats.notices {
notices.push_str(&format!("<li>{}</li>\n", html_escape(&clean(n))));
}
if notices.is_empty() {
notices.push_str("<li class=\"muted\">none</li>\n");
}
let mut suspicious = String::new();
if stats.suspicious_signals.is_empty() {
suspicious.push_str(
"<p class=\"muted\">None observed. This classifier is advisory and incomplete.</p>\n",
);
} else {
suspicious.push_str(
"<table><tr><th>severity</th><th>category</th><th>subject</th><th>reason</th><th>count</th></tr>\n",
);
for signal in &stats.suspicious_signals {
suspicious.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td class=\"path\">{}</td><td>{}</td><td class=\"num\">{}</td></tr>\n",
html_escape(&clean(&signal.severity)),
html_escape(&clean(&signal.category)),
html_escape(&clean(&signal.subject)),
html_escape(&clean(&signal.reason)),
signal.count
));
}
suspicious.push_str("</table>\n");
}
let histogram_svg = super::svg::render_category_histogram_svg(stats);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>vetto session report</title>
<style>
body {{ font-family: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
background: #101418; color: #cfd8dc; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }}
h1 {{ color: #4dd0e1; font-size: 1.3rem; }}
h2 {{ color: #80cbc4; font-size: 1.05rem; margin-top: 2rem; }}
table {{ border-collapse: collapse; width: 100%; margin: 0.5rem 0; }}
th, td {{ text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #263238; }}
th {{ color: #b0bec5; }}
.num {{ text-align: right; }}
.path {{ font-family: inherit; word-break: break-all; }}
.ok {{ color: #a5d6a7; }}
.deny {{ color: #ef9a9a; font-weight: bold; }}
.muted {{ color: #78909c; }}
.meta span {{ margin-right: 1.2rem; }}
footer {{ margin-top: 2.5rem; border-top: 1px solid #263238; padding-top: 0.8rem;
color: #78909c; font-size: 0.85rem; }}
</style>
</head>
<body>
<h1>vetto session report</h1>
<p class="meta">
<span>tier: <b>{tier}</b></span>
<span>net: <b>{net}</b></span>
<span>profile: <b>{profile}</b></span>
<span>exit: <b>{exit}</b></span>
<span>duration: <b>{dur}s</b></span>
</p>
<h2>Event category distribution</h2>
{histogram}
<h2>Event counts</h2>
<table>
<tr><th>event</th><th>count</th></tr>
{rows}
</table>
<p class="muted">file reads observed: {reads} · file writes observed: {writes} ·
observation is best-effort (/proc polling, ~100 ms granularity)</p>
<h2>Blocked attempts</h2>
{blocked}
<h2>Network requests</h2>
{net_tbl}
<h2>Suspicious signals (best-effort)</h2>
{suspicious}
<h2>Notices</h2>
<ul>
{notices}
</ul>
<footer>
Generated by vetto v{version}. Observations are BEST-EFFORT and never carry
enforcement authority; blocking is enforced by Landlock / namespaces / seccomp.
Secret sanitizer: BEST-EFFORT (false positives and misses are possible).
</footer>
</body>
</html>
"#,
tier = html_escape(&clean(&stats.tier)),
net = html_escape(&clean(&stats.net_mode)),
profile = html_escape(&clean(&stats.profile)),
exit = stats.exit_code,
dur = stats.duration_secs,
histogram = histogram_svg,
rows = rows,
reads = stats.file_reads,
writes = stats.file_writes,
blocked = blocked,
net_tbl = net,
suspicious = suspicious,
notices = notices,
version = env!("CARGO_PKG_VERSION"),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_strings_are_redacted_and_html_escaped() {
let secret = "ghp_0123456789abcdefghijklmnopqrstuvwxyz";
let stats = SessionStats {
tier: format!("tier-{secret}"),
net_mode: "off<script>".into(),
profile: "profile".into(),
notices: vec![format!("message={secret}")],
..SessionStats::default()
};
let report = render(&stats);
assert!(!report.contains(secret), "secret leaked: {report}");
assert!(report.contains("off<script>"), "HTML was not escaped");
}
}