1use crate::diagnostics::{
23 DoctorReport, DoctorStatus, EnvSource, InfoReport, RecentActivityReport, StatusReport,
24};
25use crate::platform::PlatformProbe;
26use crate::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
27use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
28use std::collections::HashSet;
29use std::fmt::Write;
30
31pub fn render(outcome: &ScanOutcome) -> String {
34 let mut s = String::new();
35 write_header(&mut s, &outcome.report);
36 write_inventory(&mut s, &outcome.report);
37 write_diff(&mut s, outcome);
38 write_footer(&mut s, outcome);
39 s
40}
41
42fn write_header(s: &mut String, report: &ScanReport) {
43 let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
44 let _ = writeln!(s);
45 let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
46 let _ = writeln!(s);
47}
48
49fn write_inventory(s: &mut String, report: &ScanReport) {
50 let _ = writeln!(s, "## Inventory");
51 let _ = writeln!(s);
52 if report.paths.is_empty() {
53 let _ = writeln!(s, "_no targets present._");
54 let _ = writeln!(s);
55 return;
56 }
57 let _ = writeln!(s, "| category | path | size | sha256 |");
58 let _ = writeln!(s, "|---|---|---|---|");
59 for p in &report.paths {
60 let _ = writeln!(
61 s,
62 "| {} | `{}` | {} | `{}` |",
63 p.category,
64 p.path.display(),
65 p.size,
66 &p.sha256[..16]
67 );
68 }
69 let _ = writeln!(s);
70}
71
72fn write_diff(s: &mut String, outcome: &ScanOutcome) {
73 let Some(diff) = &outcome.diff else {
74 let _ = writeln!(s, "## Diff vs previous snapshot");
75 let _ = writeln!(s);
76 let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
77 let _ = writeln!(s);
78 return;
79 };
80 let _ = writeln!(s, "## Diff vs previous snapshot");
81 let _ = writeln!(s);
82 if diff.is_empty() {
83 let _ = writeln!(s, "_no changes._");
84 let _ = writeln!(s);
85 return;
86 }
87 if !diff.added.is_empty() {
88 let _ = writeln!(s, "### Added ({})", diff.added.len());
89 for e in &diff.added {
90 let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
91 }
92 let _ = writeln!(s);
93 }
94 if !diff.modified.is_empty() {
95 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
96 for c in &diff.modified {
97 let _ = writeln!(
98 s,
99 "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
100 c.path.display(),
101 c.prev_size,
102 c.curr_size,
103 &c.prev_sha256[..8],
104 &c.curr_sha256[..8]
105 );
106 }
107 let _ = writeln!(s);
108 }
109 if !diff.removed.is_empty() {
110 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
111 for e in &diff.removed {
112 let _ = writeln!(s, "- `{}`", e.path.display());
113 }
114 let _ = writeln!(s);
115 }
116}
117
118fn critical_set<'a>(probes: &'a [&dyn PlatformProbe]) -> HashSet<&'a str> {
124 let mut set = HashSet::new();
125 for probe in probes {
126 for cat in probe.critical_categories() {
127 set.insert(*cat);
128 }
129 }
130 set
131}
132
133pub fn render_diff(
148 diff: &DiffReport,
149 baseline_hint: Option<&str>,
150 probes: &[&dyn PlatformProbe],
151) -> String {
152 let crit = critical_set(probes);
153 let is_crit_cat = |c: &str| crit.contains(c);
154 let mut s = String::new();
155 let header = match baseline_hint {
156 Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
157 None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
158 };
159 s.push_str(&header);
160 if diff.is_empty() {
161 let _ = writeln!(s, "_no changes._");
162 return s;
163 }
164 if !diff.added.is_empty() {
165 let _ = writeln!(s, "### Added ({})", diff.added.len());
166 let mut sorted: Vec<_> = diff.added.iter().collect();
168 sorted.sort_by(|a, b| {
169 let a_crit = is_crit_cat(&a.category);
170 let b_crit = is_crit_cat(&b.category);
171 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
172 });
173 for e in sorted {
174 let prefix = if is_crit_cat(&e.category) {
175 "🚨 **CRITICAL** "
176 } else {
177 ""
178 };
179 let _ = writeln!(s, "- {prefix}`{}` ({} bytes)", e.path.display(), e.size);
180 }
181 let _ = writeln!(s);
182 }
183 if !diff.modified.is_empty() {
184 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
185 let mut sorted_modified: Vec<_> = diff.modified.iter().collect();
189 sorted_modified.sort_by(|a, b| {
190 let a_crit = is_crit_cat(&a.category);
191 let b_crit = is_crit_cat(&b.category);
192 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
193 });
194 for c in sorted_modified {
195 let prefix = if is_crit_cat(&c.category) {
196 "🚨 **CRITICAL** "
197 } else {
198 ""
199 };
200 let _ = writeln!(
201 s,
202 "- {prefix}`{}` ({} → {} bytes, sha256 `{}` → `{}`)",
203 c.path.display(),
204 c.prev_size,
205 c.curr_size,
206 &c.prev_sha256[..8],
207 &c.curr_sha256[..8]
208 );
209 }
210 let _ = writeln!(s);
211 }
212 if !diff.removed.is_empty() {
213 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
214 let mut sorted: Vec<_> = diff.removed.iter().collect();
215 sorted.sort_by(|a, b| {
216 let a_crit = is_crit_cat(&a.category);
217 let b_crit = is_crit_cat(&b.category);
218 b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
219 });
220 for e in sorted {
221 let prefix = if is_crit_cat(&e.category) {
222 "🚨 **CRITICAL** "
223 } else {
224 ""
225 };
226 let _ = writeln!(s, "- {prefix}`{}`", e.path.display());
227 }
228 let _ = writeln!(s);
229 }
230 s
231}
232
233pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
243 let mut s = String::new();
244 let _ = writeln!(s, "# AgentSec BlackList check");
245 let _ = writeln!(s);
246 let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
247 let _ = writeln!(s);
248 if verdicts.is_empty() {
249 let _ = writeln!(s, "_no MCP servers found in any platform config._");
250 return s;
251 }
252 let mut good = Vec::new();
253 let mut likely = Vec::new();
254 let mut informational = Vec::new();
255 let mut unk = Vec::new();
256 for v in verdicts {
257 match &v.verdict {
258 UnknownVerdictKind::KnownGood => good.push(v),
259 UnknownVerdictKind::LikelyTyposquat { .. } => likely.push(v),
260 UnknownVerdictKind::InformationalTyposquat { .. } => informational.push(v),
261 UnknownVerdictKind::Unknown => unk.push(v),
262 }
263 }
264 if !good.is_empty() {
265 let _ = writeln!(s, "### Known-good ({})", good.len());
266 for v in good {
267 let paths_line = v.paths.join(", ");
268 let _ = writeln!(
269 s,
270 "- `{}` ×{} — {} (from `{}`)",
271 v.name, v.count, v.reason, paths_line
272 );
273 }
274 let _ = writeln!(s);
275 }
276 if !likely.is_empty() {
277 let _ = writeln!(s, "### Likely typosquat (warn) ({})", likely.len());
278 for v in likely {
279 let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
280 for p in &v.paths {
281 let _ = writeln!(s, " - `{p}`");
282 }
283 }
284 let _ = writeln!(s);
285 }
286 if !informational.is_empty() {
287 let _ = writeln!(
288 s,
289 "### Informational typosquat (info) ({})",
290 informational.len()
291 );
292 for v in informational {
293 let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
294 for p in &v.paths {
295 let _ = writeln!(s, " - `{p}`");
296 }
297 }
298 let _ = writeln!(s);
299 }
300 if !unk.is_empty() {
301 let _ = writeln!(s, "### Unknown ({})", unk.len());
302 for v in unk {
303 let paths_line = v.paths.join(", ");
304 let _ = writeln!(s, "- `{}` ×{} (from `{}`)", v.name, v.count, paths_line);
305 }
306 let _ = writeln!(s);
307 }
308 s
309}
310
311pub fn render_info(report: &InfoReport) -> String {
321 let mut s = String::new();
322 let _ = writeln!(s, "# AgentSec — info");
323 let _ = writeln!(s);
324 let _ = writeln!(s, "## Version");
325 let _ = writeln!(s);
326 let _ = writeln!(s, "- agentsec-core: `{}`", report.version);
327 let _ = writeln!(s);
328 let _ = writeln!(s, "## Paths");
329 let _ = writeln!(s);
330 let p = &report.paths;
331 let _ = writeln!(s, "- home: `{}`", p.home.display());
332 let _ = writeln!(s, "- user_home: `{}`", p.user_home.display());
333 let _ = writeln!(s, "- snapshots: `{}`", p.snapshots.display());
334 let _ = writeln!(s, "- scans: `{}`", p.scans.display());
335 let _ = writeln!(s, "- web_log: `{}`", p.web_log.display());
336 let _ = writeln!(s, "- paste_log: `{}`", p.paste_log.display());
337 let _ = writeln!(s);
338 let _ = writeln!(s, "## Environment");
339 let _ = writeln!(s);
340 let _ = writeln!(s, "| Key | Source | Value |");
341 let _ = writeln!(s, "|---|---|---|");
342 for ev in &report.env {
343 let source = render_env_source(&ev.source);
344 let value = ev.value_display.clone().unwrap_or_else(|| "—".into());
345 let _ = writeln!(s, "| `{}` | {source} | `{value}` |", ev.key);
346 }
347 s
348}
349
350fn render_env_source(src: &EnvSource) -> String {
351 match src {
352 EnvSource::Process => "process env".into(),
353 EnvSource::DotenvFile { path, line } => {
354 format!("dotenv: `{}:{}`", path.display(), line)
355 }
356 EnvSource::DotenvFileShadowed { path, line } => {
357 format!(
358 "dotenv `{}:{}` (shadowed by process env)",
359 path.display(),
360 line
361 )
362 }
363 EnvSource::Default(_) => "builtin default".into(),
364 EnvSource::Unset => "unset".into(),
365 }
366}
367
368pub fn render_status(report: &StatusReport) -> String {
370 let mut s = String::new();
371 let _ = writeln!(s, "# AgentSec — status");
372 let _ = writeln!(s);
373 let _ = writeln!(s, "| Metric | Value |");
374 let _ = writeln!(s, "|---|---|");
375 let _ = writeln!(s, "| snapshots | {} |", report.snapshots_count);
376 let _ = writeln!(
377 s,
378 "| latest snapshot | {} |",
379 report.latest_snapshot.as_deref().unwrap_or("—")
380 );
381 let _ = writeln!(s, "| paste_log entries | {} |", report.paste_log_count);
382 let _ = writeln!(s, "| web_log entries | {} |", report.web_log_count);
383 let _ = writeln!(
384 s,
385 "| plain mode active | {} |",
386 if report.plain_mode_active {
387 "yes"
388 } else {
389 "no"
390 }
391 );
392 let _ = writeln!(s, "| plain mode entries | {} |", report.plain_mode_entries);
393 let _ = writeln!(s, "| registry entries | {} |", report.registry_entries);
394
395 if !report.storage.is_empty() {
396 let _ = writeln!(s);
397 let _ = writeln!(s, "## Storage");
398 let _ = writeln!(s);
399 let _ = writeln!(s, "| Kind | Path | Count | Size | Latest |");
400 let _ = writeln!(s, "|---|---|---|---|---|");
401 for item in &report.storage {
402 let _ = writeln!(
403 s,
404 "| {} | `{}` | {} | {} | {} |",
405 item.kind,
406 item.path.display(),
407 item.count,
408 crate::diagnostics::format_size(item.size_bytes),
409 item.latest.as_deref().unwrap_or("—"),
410 );
411 }
412 }
413
414 s
415}
416
417pub fn render_recent(report: &RecentActivityReport) -> String {
419 let mut s = String::new();
420 let _ = writeln!(s, "# AgentSec — recent activity");
421 let _ = writeln!(s);
422 write_audit_section(&mut s, "paste_log", &report.paste);
423 write_audit_section(&mut s, "web_log", &report.web);
424 s
425}
426
427fn write_audit_section(s: &mut String, label: &str, rows: &[crate::diagnostics::AuditTail]) {
428 let _ = writeln!(s, "## {label} ({} rows)", rows.len());
429 let _ = writeln!(s);
430 if rows.is_empty() {
431 let _ = writeln!(s, "_empty._");
432 let _ = writeln!(s);
433 return;
434 }
435 for row in rows {
436 let _ = writeln!(s, "### `{}` ({} bytes)", row.filename, row.size);
437 let _ = writeln!(s);
438 let _ = writeln!(s, "```");
439 let _ = writeln!(s, "{}", row.excerpt);
440 let _ = writeln!(s, "```");
441 let _ = writeln!(s);
442 }
443}
444
445pub fn render_doctor(report: &DoctorReport) -> String {
447 let mut s = String::new();
448 let _ = writeln!(s, "# AgentSec — doctor");
449 let _ = writeln!(s);
450 let mut pass = 0usize;
451 let mut warn = 0usize;
452 let mut fail = 0usize;
453 for c in &report.checks {
454 match c.status {
455 DoctorStatus::Pass => pass += 1,
456 DoctorStatus::Warn => warn += 1,
457 DoctorStatus::Fail => fail += 1,
458 }
459 }
460 let _ = writeln!(
461 s,
462 "Summary: **{pass} PASS / {warn} WARN / {fail} FAIL** ({} checks)",
463 report.checks.len()
464 );
465 let _ = writeln!(s);
466 let _ = writeln!(s, "| Status | Check | Detail |");
467 let _ = writeln!(s, "|---|---|---|");
468 for c in &report.checks {
469 let badge = match c.status {
470 DoctorStatus::Pass => "✅ PASS",
471 DoctorStatus::Warn => "⚠️ WARN",
472 DoctorStatus::Fail => "❌ FAIL",
473 };
474 let detail = c.message.replace('\n', " ").replace('|', "\\|");
475 let _ = writeln!(s, "| {badge} | {} | {detail} |", c.name);
476 }
477 s
478}
479
480pub fn diff_summary_line(diff: &DiffReport) -> String {
495 if diff.is_empty() {
496 return "no changes".to_string();
497 }
498 let mut parts = Vec::new();
499 if !diff.added.is_empty() {
500 parts.push(format!("{} added", diff.added.len()));
501 }
502 if !diff.modified.is_empty() {
503 parts.push(format!("{} modified", diff.modified.len()));
504 }
505 if !diff.removed.is_empty() {
506 parts.push(format!("{} removed", diff.removed.len()));
507 }
508 parts.join(", ")
509}
510
511fn write_footer(s: &mut String, outcome: &ScanOutcome) {
512 let _ = writeln!(
513 s,
514 "_snapshot written to: `{}`_",
515 outcome.snapshot_path.display()
516 );
517}