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::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
26use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
27use std::fmt::Write;
28
29/// Render `outcome` as a Markdown report (see module docs §`render` output
30/// sections for the schema).
31pub fn render(outcome: &ScanOutcome) -> String {
32    let mut s = String::new();
33    write_header(&mut s, &outcome.report);
34    write_inventory(&mut s, &outcome.report);
35    write_diff(&mut s, outcome);
36    write_footer(&mut s, outcome);
37    s
38}
39
40fn write_header(s: &mut String, report: &ScanReport) {
41    let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
42    let _ = writeln!(s);
43    let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
44    let _ = writeln!(s);
45}
46
47fn write_inventory(s: &mut String, report: &ScanReport) {
48    let _ = writeln!(s, "## Inventory");
49    let _ = writeln!(s);
50    if report.paths.is_empty() {
51        let _ = writeln!(s, "_no targets present._");
52        let _ = writeln!(s);
53        return;
54    }
55    let _ = writeln!(s, "| category | path | size | sha256 |");
56    let _ = writeln!(s, "|---|---|---|---|");
57    for p in &report.paths {
58        let _ = writeln!(
59            s,
60            "| {} | `{}` | {} | `{}` |",
61            p.category,
62            p.path.display(),
63            p.size,
64            &p.sha256[..16]
65        );
66    }
67    let _ = writeln!(s);
68}
69
70fn write_diff(s: &mut String, outcome: &ScanOutcome) {
71    let Some(diff) = &outcome.diff else {
72        let _ = writeln!(s, "## Diff vs previous snapshot");
73        let _ = writeln!(s);
74        let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
75        let _ = writeln!(s);
76        return;
77    };
78    let _ = writeln!(s, "## Diff vs previous snapshot");
79    let _ = writeln!(s);
80    if diff.is_empty() {
81        let _ = writeln!(s, "_no changes._");
82        let _ = writeln!(s);
83        return;
84    }
85    if !diff.added.is_empty() {
86        let _ = writeln!(s, "### Added ({})", diff.added.len());
87        for e in &diff.added {
88            let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
89        }
90        let _ = writeln!(s);
91    }
92    if !diff.modified.is_empty() {
93        let _ = writeln!(s, "### Modified ({})", diff.modified.len());
94        for c in &diff.modified {
95            let _ = writeln!(
96                s,
97                "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
98                c.path.display(),
99                c.prev_size,
100                c.curr_size,
101                &c.prev_sha256[..8],
102                &c.curr_sha256[..8]
103            );
104        }
105        let _ = writeln!(s);
106    }
107    if !diff.removed.is_empty() {
108        let _ = writeln!(s, "### Removed ({})", diff.removed.len());
109        for e in &diff.removed {
110            let _ = writeln!(s, "- `{}`", e.path.display());
111        }
112        let _ = writeln!(s);
113    }
114}
115
116/// Security-critical inventory categories. Changes to these categories are
117/// prefixed with `🚨 **CRITICAL**` in the diff render.
118const CRITICAL_CATEGORIES: &[&str] = &[
119    "local_config", // ~/.claude.json — mcpServers / hooks / permissions
120    "settings",     // ~/.claude/settings.json
121    "settings_local",
122    "settings_project",
123    "mcp_project", // .mcp.json — project MCP server list
124];
125
126fn is_critical_category(category: &str) -> bool {
127    CRITICAL_CATEGORIES.contains(&category)
128}
129
130/// Render a standalone diff report as Markdown.
131///
132/// Schema:
133///
134/// - `# AgentSec diff vs <baseline-path-or-"latest snapshot">` — header.
135/// - If the diff is empty, a single `_no changes._` line and nothing else.
136/// - Otherwise the three `### Added` / `### Modified` / `### Removed`
137///   subsections, each omitted when its vector is empty.
138/// - Lines whose inventory category is security-critical are prefixed with
139///   `🚨 **CRITICAL**`; within each section, critical entries appear before
140///   non-critical entries (highest-severity first).
141///
142/// Pass `baseline_hint` as `Some("/path/to/snapshot")` to put the
143/// snapshot path in the header, or `None` for the generic phrasing.
144pub fn render_diff(diff: &DiffReport, baseline_hint: Option<&str>) -> String {
145    let mut s = String::new();
146    let header = match baseline_hint {
147        Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
148        None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
149    };
150    s.push_str(&header);
151    if diff.is_empty() {
152        let _ = writeln!(s, "_no changes._");
153        return s;
154    }
155    if !diff.added.is_empty() {
156        let _ = writeln!(s, "### Added ({})", diff.added.len());
157        // Sort: critical first, then by path.
158        let mut sorted: Vec<_> = diff.added.iter().collect();
159        sorted.sort_by(|a, b| {
160            let a_crit = is_critical_category(&a.category);
161            let b_crit = is_critical_category(&b.category);
162            b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
163        });
164        for e in sorted {
165            let prefix = if is_critical_category(&e.category) {
166                "🚨 **CRITICAL** "
167            } else {
168                ""
169            };
170            let _ = writeln!(s, "- {prefix}`{}` ({} bytes)", e.path.display(), e.size);
171        }
172        let _ = writeln!(s);
173    }
174    if !diff.modified.is_empty() {
175        let _ = writeln!(s, "### Modified ({})", diff.modified.len());
176        // Sort: critical first (use path to look up category from the diff entries).
177        // For modified entries we don't carry the category directly in `Change`, so
178        // we detect "critical" by checking if the path string contains any critical
179        // category-indicating path segments (e.g. ".claude.json", "settings.json",
180        // ".mcp.json"). For simplicity we mark all modified entries as potentially
181        // critical based on their known path patterns.
182        let mut sorted_modified: Vec<_> = diff.modified.iter().collect();
183        sorted_modified.sort_by(|a, b| {
184            let a_crit = is_critical_path(&a.path.to_string_lossy());
185            let b_crit = is_critical_path(&b.path.to_string_lossy());
186            b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
187        });
188        for c in sorted_modified {
189            let path_str = c.path.to_string_lossy();
190            let prefix = if is_critical_path(&path_str) {
191                "🚨 **CRITICAL** "
192            } else {
193                ""
194            };
195            let _ = writeln!(
196                s,
197                "- {prefix}`{}` ({} → {} bytes, sha256 `{}` → `{}`)",
198                c.path.display(),
199                c.prev_size,
200                c.curr_size,
201                &c.prev_sha256[..8],
202                &c.curr_sha256[..8]
203            );
204        }
205        let _ = writeln!(s);
206    }
207    if !diff.removed.is_empty() {
208        let _ = writeln!(s, "### Removed ({})", diff.removed.len());
209        let mut sorted: Vec<_> = diff.removed.iter().collect();
210        sorted.sort_by(|a, b| {
211            let a_crit = is_critical_category(&a.category);
212            let b_crit = is_critical_category(&b.category);
213            b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
214        });
215        for e in sorted {
216            let prefix = if is_critical_category(&e.category) {
217                "🚨 **CRITICAL** "
218            } else {
219                ""
220            };
221            let _ = writeln!(s, "- {prefix}`{}`", e.path.display());
222        }
223        let _ = writeln!(s);
224    }
225    s
226}
227
228/// Heuristic: a path is critical if it matches known security-sensitive
229/// filenames. Used for `modified` entries where the category is not
230/// carried directly.
231fn is_critical_path(path_str: &str) -> bool {
232    path_str.ends_with(".claude.json")
233        || path_str.contains("settings.json")
234        || path_str.ends_with(".mcp.json")
235}
236
237/// Render BlackList classify verdicts as Markdown.
238///
239/// Empty input ⇒ a single `_no MCP servers found in .mcp.json / .claude.json._`
240/// notice. Otherwise four subsections in order:
241/// `### Known-good`, `### Likely typosquat (warn)`,
242/// `### Informational typosquat (info)`, `### Unknown`.
243/// Each subsection is omitted when its bucket is empty.
244///
245/// Typosquat entries include a `×N projects` count and a bullet path list.
246pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
247    let mut s = String::new();
248    let _ = writeln!(s, "# AgentSec BlackList check");
249    let _ = writeln!(s);
250    let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
251    let _ = writeln!(s);
252    if verdicts.is_empty() {
253        let _ = writeln!(s, "_no MCP servers found in .mcp.json / .claude.json._");
254        return s;
255    }
256    let mut good = Vec::new();
257    let mut likely = Vec::new();
258    let mut informational = Vec::new();
259    let mut unk = Vec::new();
260    for v in verdicts {
261        match &v.verdict {
262            UnknownVerdictKind::KnownGood => good.push(v),
263            UnknownVerdictKind::LikelyTyposquat { .. } => likely.push(v),
264            UnknownVerdictKind::InformationalTyposquat { .. } => informational.push(v),
265            UnknownVerdictKind::Unknown => unk.push(v),
266        }
267    }
268    if !good.is_empty() {
269        let _ = writeln!(s, "### Known-good ({})", good.len());
270        for v in good {
271            let paths_line = v.paths.join(", ");
272            let _ = writeln!(
273                s,
274                "- `{}` ×{} — {} (from `{}`)",
275                v.name, v.count, v.reason, paths_line
276            );
277        }
278        let _ = writeln!(s);
279    }
280    if !likely.is_empty() {
281        let _ = writeln!(s, "### Likely typosquat (warn) ({})", likely.len());
282        for v in likely {
283            let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
284            for p in &v.paths {
285                let _ = writeln!(s, "  - `{p}`");
286            }
287        }
288        let _ = writeln!(s);
289    }
290    if !informational.is_empty() {
291        let _ = writeln!(
292            s,
293            "### Informational typosquat (info) ({})",
294            informational.len()
295        );
296        for v in informational {
297            let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
298            for p in &v.paths {
299                let _ = writeln!(s, "  - `{p}`");
300            }
301        }
302        let _ = writeln!(s);
303    }
304    if !unk.is_empty() {
305        let _ = writeln!(s, "### Unknown ({})", unk.len());
306        for v in unk {
307            let paths_line = v.paths.join(", ");
308            let _ = writeln!(s, "- `{}` ×{} (from `{}`)", v.name, v.count, paths_line);
309        }
310        let _ = writeln!(s);
311    }
312    s
313}
314
315/// Render an [`InfoReport`] as Markdown.
316///
317/// Section schema:
318///
319/// - `# AgentSec — info`
320/// - `## Version`
321/// - `## Paths`
322/// - `## Environment` — one row per tracked env var with source +
323///   value (redacted if secret).
324pub fn render_info(report: &InfoReport) -> String {
325    let mut s = String::new();
326    let _ = writeln!(s, "# AgentSec — info");
327    let _ = writeln!(s);
328    let _ = writeln!(s, "## Version");
329    let _ = writeln!(s);
330    let _ = writeln!(s, "- agentsec-core: `{}`", report.version);
331    let _ = writeln!(s);
332    let _ = writeln!(s, "## Paths");
333    let _ = writeln!(s);
334    let p = &report.paths;
335    let _ = writeln!(s, "- home:       `{}`", p.home.display());
336    let _ = writeln!(s, "- user_home:  `{}`", p.user_home.display());
337    let _ = writeln!(s, "- snapshots:  `{}`", p.snapshots.display());
338    let _ = writeln!(s, "- scans:      `{}`", p.scans.display());
339    let _ = writeln!(s, "- web_log:    `{}`", p.web_log.display());
340    let _ = writeln!(s, "- paste_log:  `{}`", p.paste_log.display());
341    let _ = writeln!(s);
342    let _ = writeln!(s, "## Environment");
343    let _ = writeln!(s);
344    let _ = writeln!(s, "| Key | Source | Value |");
345    let _ = writeln!(s, "|---|---|---|");
346    for ev in &report.env {
347        let source = render_env_source(&ev.source);
348        let value = ev.value_display.clone().unwrap_or_else(|| "—".into());
349        let _ = writeln!(s, "| `{}` | {source} | `{value}` |", ev.key);
350    }
351    s
352}
353
354fn render_env_source(src: &EnvSource) -> String {
355    match src {
356        EnvSource::Process => "process env".into(),
357        EnvSource::DotenvFile { path, line } => {
358            format!("dotenv: `{}:{}`", path.display(), line)
359        }
360        EnvSource::DotenvFileShadowed { path, line } => {
361            format!(
362                "dotenv `{}:{}` (shadowed by process env)",
363                path.display(),
364                line
365            )
366        }
367        EnvSource::Default(_) => "builtin default".into(),
368        EnvSource::Unset => "unset".into(),
369    }
370}
371
372/// Render a [`StatusReport`] as Markdown.
373pub fn render_status(report: &StatusReport) -> String {
374    let mut s = String::new();
375    let _ = writeln!(s, "# AgentSec — status");
376    let _ = writeln!(s);
377    let _ = writeln!(s, "| Metric | Value |");
378    let _ = writeln!(s, "|---|---|");
379    let _ = writeln!(s, "| snapshots          | {} |", report.snapshots_count);
380    let _ = writeln!(
381        s,
382        "| latest snapshot    | {} |",
383        report.latest_snapshot.as_deref().unwrap_or("—")
384    );
385    let _ = writeln!(s, "| paste_log entries  | {} |", report.paste_log_count);
386    let _ = writeln!(s, "| web_log entries    | {} |", report.web_log_count);
387    let _ = writeln!(
388        s,
389        "| plain mode active  | {} |",
390        if report.plain_mode_active {
391            "yes"
392        } else {
393            "no"
394        }
395    );
396    let _ = writeln!(s, "| plain mode entries | {} |", report.plain_mode_entries);
397    let _ = writeln!(s, "| registry entries   | {} |", report.registry_entries);
398
399    if !report.storage.is_empty() {
400        let _ = writeln!(s);
401        let _ = writeln!(s, "## Storage");
402        let _ = writeln!(s);
403        let _ = writeln!(s, "| Kind | Path | Count | Size | Latest |");
404        let _ = writeln!(s, "|---|---|---|---|---|");
405        for item in &report.storage {
406            let _ = writeln!(
407                s,
408                "| {} | `{}` | {} | {} | {} |",
409                item.kind,
410                item.path.display(),
411                item.count,
412                crate::diagnostics::format_size(item.size_bytes),
413                item.latest.as_deref().unwrap_or("—"),
414            );
415        }
416    }
417
418    s
419}
420
421/// Render a [`RecentActivityReport`] as Markdown.
422pub fn render_recent(report: &RecentActivityReport) -> String {
423    let mut s = String::new();
424    let _ = writeln!(s, "# AgentSec — recent activity");
425    let _ = writeln!(s);
426    write_audit_section(&mut s, "paste_log", &report.paste);
427    write_audit_section(&mut s, "web_log", &report.web);
428    s
429}
430
431fn write_audit_section(s: &mut String, label: &str, rows: &[crate::diagnostics::AuditTail]) {
432    let _ = writeln!(s, "## {label} ({} rows)", rows.len());
433    let _ = writeln!(s);
434    if rows.is_empty() {
435        let _ = writeln!(s, "_empty._");
436        let _ = writeln!(s);
437        return;
438    }
439    for row in rows {
440        let _ = writeln!(s, "### `{}` ({} bytes)", row.filename, row.size);
441        let _ = writeln!(s);
442        let _ = writeln!(s, "```");
443        let _ = writeln!(s, "{}", row.excerpt);
444        let _ = writeln!(s, "```");
445        let _ = writeln!(s);
446    }
447}
448
449/// Render a [`DoctorReport`] as Markdown.
450pub fn render_doctor(report: &DoctorReport) -> String {
451    let mut s = String::new();
452    let _ = writeln!(s, "# AgentSec — doctor");
453    let _ = writeln!(s);
454    let mut pass = 0usize;
455    let mut warn = 0usize;
456    let mut fail = 0usize;
457    for c in &report.checks {
458        match c.status {
459            DoctorStatus::Pass => pass += 1,
460            DoctorStatus::Warn => warn += 1,
461            DoctorStatus::Fail => fail += 1,
462        }
463    }
464    let _ = writeln!(
465        s,
466        "Summary: **{pass} PASS / {warn} WARN / {fail} FAIL** ({} checks)",
467        report.checks.len()
468    );
469    let _ = writeln!(s);
470    let _ = writeln!(s, "| Status | Check | Detail |");
471    let _ = writeln!(s, "|---|---|---|");
472    for c in &report.checks {
473        let badge = match c.status {
474            DoctorStatus::Pass => "✅ PASS",
475            DoctorStatus::Warn => "⚠️ WARN",
476            DoctorStatus::Fail => "❌ FAIL",
477        };
478        let detail = c.message.replace('\n', " ").replace('|', "\\|");
479        let _ = writeln!(s, "| {badge} | {} | {detail} |", c.name);
480    }
481    s
482}
483
484/// Short one-line diff summary for hook stderr output.
485///
486/// Format: `N added, M modified, K removed` (omits zero-count terms).
487/// Returns `"no changes"` for an empty diff.
488///
489/// # Examples
490///
491/// ```
492/// use agentsec_core::output::markdown::diff_summary_line;
493/// use agentsec_core::scan::diff::DiffReport;
494///
495/// let empty = DiffReport { added: vec![], modified: vec![], removed: vec![] };
496/// assert_eq!(diff_summary_line(&empty), "no changes");
497/// ```
498pub fn diff_summary_line(diff: &DiffReport) -> String {
499    if diff.is_empty() {
500        return "no changes".to_string();
501    }
502    let mut parts = Vec::new();
503    if !diff.added.is_empty() {
504        parts.push(format!("{} added", diff.added.len()));
505    }
506    if !diff.modified.is_empty() {
507        parts.push(format!("{} modified", diff.modified.len()));
508    }
509    if !diff.removed.is_empty() {
510        parts.push(format!("{} removed", diff.removed.len()));
511    }
512    parts.join(", ")
513}
514
515fn write_footer(s: &mut String, outcome: &ScanOutcome) {
516    let _ = writeln!(
517        s,
518        "_snapshot written to: `{}`_",
519        outcome.snapshot_path.display()
520    );
521}