Skip to main content

agentsec_core/output/
markdown.rs

1//! Markdown renderers.
2//!
3//! Two entry points:
4//!
5//! - [`render`] — full scan report from a [`ScanOutcome`].
6//! - [`render_diff`] — standalone diff section from a
7//!   [`crate::scan::diff::DiffReport`], used by `agentsec diff` and the
8//!   `scan_diff` MCP tool.
9//!
10//! ## `render` output sections (always in this order)
11//!
12//! 1. `# AgentSec scan — <RFC 3339 UTC>` header + total path count.
13//! 2. `## Inventory` table with one row per [`crate::scan::inventory::PathEntry`].
14//! 3. `## Diff vs previous snapshot` — `_no previous snapshot — this is the
15//!    baseline._` on first run, `_no changes._` when the diff is empty,
16//!    otherwise three subsections (`### Added`, `### Modified`,
17//!    `### Removed`).
18//! 4. Footer line with the absolute snapshot path.
19//!
20//! Pure functions: no I/O, no allocation beyond the output string.
21
22use 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
31/// Render `outcome` as a Markdown report (see module docs §`render` output
32/// sections for the schema).
33pub 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
118/// Collect every category each probe flags as security-critical.
119/// Changes to these categories are prefixed with `🚨 **CRITICAL**` in
120/// the diff render. The set is built per render call rather than
121/// being a `const` so that platform-specific categories live with the
122/// owning probe (see [`PlatformProbe::critical_categories`]).
123fn 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
133/// Render a standalone diff report as Markdown.
134///
135/// Schema:
136///
137/// - `# AgentSec diff vs <baseline-path-or-"latest snapshot">` — header.
138/// - If the diff is empty, a single `_no changes._` line and nothing else.
139/// - Otherwise the three `### Added` / `### Modified` / `### Removed`
140///   subsections, each omitted when its vector is empty.
141/// - Lines whose inventory category is security-critical are prefixed with
142///   `🚨 **CRITICAL**`; within each section, critical entries appear before
143///   non-critical entries (highest-severity first).
144///
145/// Pass `baseline_hint` as `Some("/path/to/snapshot")` to put the
146/// snapshot path in the header, or `None` for the generic phrasing.
147pub 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        // Sort: critical first, then by path.
167        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        // Sort: critical first, then by path. `Change` carries the
186        // category through from the original `PathEntry`, so the same
187        // probe-driven critical set drives Modified entries too.
188        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
233/// Render BlackList classify verdicts as Markdown.
234///
235/// Empty input ⇒ a single `_no MCP servers found in any platform
236/// config._` notice. Otherwise four subsections in order:
237/// `### Known-good`, `### Likely typosquat (warn)`,
238/// `### Informational typosquat (info)`, `### Unknown`.
239/// Each subsection is omitted when its bucket is empty.
240///
241/// Typosquat entries include a `×N projects` count and a bullet path list.
242pub 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
311/// Render an [`InfoReport`] as Markdown.
312///
313/// Section schema:
314///
315/// - `# AgentSec — info`
316/// - `## Version`
317/// - `## Paths`
318/// - `## Environment` — one row per tracked env var with source +
319///   value (redacted if secret).
320pub 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
368/// Render a [`StatusReport`] as Markdown.
369pub 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
417/// Render a [`RecentActivityReport`] as Markdown.
418pub 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
445/// Render a [`DoctorReport`] as Markdown.
446pub 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
480/// Short one-line diff summary for hook stderr output.
481///
482/// Format: `N added, M modified, K removed` (omits zero-count terms).
483/// Returns `"no changes"` for an empty diff.
484///
485/// # Examples
486///
487/// ```
488/// use agentsec_core::output::markdown::diff_summary_line;
489/// use agentsec_core::scan::diff::DiffReport;
490///
491/// let empty = DiffReport { added: vec![], modified: vec![], removed: vec![] };
492/// assert_eq!(diff_summary_line(&empty), "no changes");
493/// ```
494pub 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}