1use crate::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
23use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
24use std::fmt::Write;
25
26pub fn render(outcome: &ScanOutcome) -> String {
29 let mut s = String::new();
30 write_header(&mut s, &outcome.report);
31 write_inventory(&mut s, &outcome.report);
32 write_diff(&mut s, outcome);
33 write_footer(&mut s, outcome);
34 s
35}
36
37fn write_header(s: &mut String, report: &ScanReport) {
38 let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
39 let _ = writeln!(s);
40 let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
41 let _ = writeln!(s);
42}
43
44fn write_inventory(s: &mut String, report: &ScanReport) {
45 let _ = writeln!(s, "## Inventory");
46 let _ = writeln!(s);
47 if report.paths.is_empty() {
48 let _ = writeln!(s, "_no targets present._");
49 let _ = writeln!(s);
50 return;
51 }
52 let _ = writeln!(s, "| category | path | size | sha256 |");
53 let _ = writeln!(s, "|---|---|---|---|");
54 for p in &report.paths {
55 let _ = writeln!(
56 s,
57 "| {} | `{}` | {} | `{}` |",
58 p.category,
59 p.path.display(),
60 p.size,
61 &p.sha256[..16]
62 );
63 }
64 let _ = writeln!(s);
65}
66
67fn write_diff(s: &mut String, outcome: &ScanOutcome) {
68 let Some(diff) = &outcome.diff else {
69 let _ = writeln!(s, "## Diff vs previous snapshot");
70 let _ = writeln!(s);
71 let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
72 let _ = writeln!(s);
73 return;
74 };
75 let _ = writeln!(s, "## Diff vs previous snapshot");
76 let _ = writeln!(s);
77 if diff.is_empty() {
78 let _ = writeln!(s, "_no changes._");
79 let _ = writeln!(s);
80 return;
81 }
82 if !diff.added.is_empty() {
83 let _ = writeln!(s, "### Added ({})", diff.added.len());
84 for e in &diff.added {
85 let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
86 }
87 let _ = writeln!(s);
88 }
89 if !diff.modified.is_empty() {
90 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
91 for c in &diff.modified {
92 let _ = writeln!(
93 s,
94 "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
95 c.path.display(),
96 c.prev_size,
97 c.curr_size,
98 &c.prev_sha256[..8],
99 &c.curr_sha256[..8]
100 );
101 }
102 let _ = writeln!(s);
103 }
104 if !diff.removed.is_empty() {
105 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
106 for e in &diff.removed {
107 let _ = writeln!(s, "- `{}`", e.path.display());
108 }
109 let _ = writeln!(s);
110 }
111}
112
113pub fn render_diff(diff: &DiffReport, baseline_hint: Option<&str>) -> String {
125 let mut s = String::new();
126 let header = match baseline_hint {
127 Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
128 None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
129 };
130 s.push_str(&header);
131 if diff.is_empty() {
132 let _ = writeln!(s, "_no changes._");
133 return s;
134 }
135 if !diff.added.is_empty() {
136 let _ = writeln!(s, "### Added ({})", diff.added.len());
137 for e in &diff.added {
138 let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
139 }
140 let _ = writeln!(s);
141 }
142 if !diff.modified.is_empty() {
143 let _ = writeln!(s, "### Modified ({})", diff.modified.len());
144 for c in &diff.modified {
145 let _ = writeln!(
146 s,
147 "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
148 c.path.display(),
149 c.prev_size,
150 c.curr_size,
151 &c.prev_sha256[..8],
152 &c.curr_sha256[..8]
153 );
154 }
155 let _ = writeln!(s);
156 }
157 if !diff.removed.is_empty() {
158 let _ = writeln!(s, "### Removed ({})", diff.removed.len());
159 for e in &diff.removed {
160 let _ = writeln!(s, "- `{}`", e.path.display());
161 }
162 let _ = writeln!(s);
163 }
164 s
165}
166
167pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
174 let mut s = String::new();
175 let _ = writeln!(s, "# AgentSec BlackList check");
176 let _ = writeln!(s);
177 let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
178 let _ = writeln!(s);
179 if verdicts.is_empty() {
180 let _ = writeln!(s, "_no MCP servers found in .mcp.json / .claude.json._");
181 return s;
182 }
183 let mut good = Vec::new();
184 let mut typo = Vec::new();
185 let mut unk = Vec::new();
186 for v in verdicts {
187 match &v.verdict {
188 UnknownVerdictKind::KnownGood => good.push(v),
189 UnknownVerdictKind::Typosquat { .. } => typo.push(v),
190 UnknownVerdictKind::Unknown => unk.push(v),
191 }
192 }
193 if !good.is_empty() {
194 let _ = writeln!(s, "### Known-good ({})", good.len());
195 for v in good {
196 let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
197 }
198 let _ = writeln!(s);
199 }
200 if !typo.is_empty() {
201 let _ = writeln!(s, "### Typosquat candidates ({})", typo.len());
202 for v in typo {
203 let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
204 }
205 let _ = writeln!(s);
206 }
207 if !unk.is_empty() {
208 let _ = writeln!(s, "### Unknown ({})", unk.len());
209 for v in unk {
210 let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
211 }
212 let _ = writeln!(s);
213 }
214 s
215}
216
217pub fn diff_summary_line(diff: &DiffReport) -> String {
232 if diff.is_empty() {
233 return "no changes".to_string();
234 }
235 let mut parts = Vec::new();
236 if !diff.added.is_empty() {
237 parts.push(format!("{} added", diff.added.len()));
238 }
239 if !diff.modified.is_empty() {
240 parts.push(format!("{} modified", diff.modified.len()));
241 }
242 if !diff.removed.is_empty() {
243 parts.push(format!("{} removed", diff.removed.len()));
244 }
245 parts.join(", ")
246}
247
248fn write_footer(s: &mut String, outcome: &ScanOutcome) {
249 let _ = writeln!(
250 s,
251 "_snapshot written to: `{}`_",
252 outcome.snapshot_path.display()
253 );
254}