seer_core/output/markdown/mod.rs
1//! Markdown output (`--format markdown`). One inherent `format_*` method per
2//! report type, split into per-concern submodules; bullets go through the
3//! private `Bullets` writer and `code_list`, which escape every value
4//! (`MdSafe`) so domain data can't inject markdown.
5
6use std::fmt::{self, Write as _};
7
8use chrono::{DateTime, Utc};
9
10use super::OutputFormatter;
11
12// Shared with the per-concern submodules below (each does `use super::*`).
13pub(super) use super::contact::{self, Contact, FlatContacts};
14pub(super) use super::days_until;
15pub(super) use super::grouping::render_grouped;
16pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
17pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
18pub(super) use crate::lookup::LookupResult;
19pub(super) use crate::rdap::RdapResponse;
20pub(super) use crate::status::StatusResponse;
21pub(super) use crate::whois::WhoisResponse;
22
23mod delegation;
24mod diff;
25mod dns;
26mod domain_info;
27mod lookup;
28mod propagation;
29mod rdap;
30mod security;
31mod status;
32mod whois;
33
34/// `Display` adapter that renders attacker-controlled WHOIS/RDAP/DNS/SSL
35/// strings safely inside Markdown that will be forwarded to an LLM (via
36/// the MCP server). Strips ANSI escape sequences and ASCII control
37/// characters, collapses newlines/CR/tabs to spaces (so attacker text
38/// cannot break out of a table row or look like a new heading), neutralizes
39/// backticks (so an attacker can't terminate a code span and inject Markdown
40/// structure), escapes the table-cell delimiter `|` (so a value can't add
41/// columns or break out of a cell), and backslash-escapes `[`, `]`, `<`, `>`
42/// (so a value can't render as a live link, image, autolink, or raw HTML —
43/// e.g. a registrar of ``). `(`/`)` are
44/// left alone: without a `]` before them they carry no link syntax, and
45/// escaping them would add noise to ordinary text.
46pub(super) struct MdSafe<'a>(pub &'a str);
47
48impl fmt::Display for MdSafe<'_> {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 let mut iter = self.0.chars().peekable();
51 while let Some(c) = iter.next() {
52 match c {
53 '\x1b' => consume_escape(&mut iter),
54 '\n' | '\r' | '\t' => f.write_str(" ")?,
55 '`' => f.write_str("'")?,
56 // GFM table-cell delimiter: a bare `|` from attacker-controlled
57 // data would add columns / break out of the cell (and, with a
58 // backtick, escape the code-span defense). Backslash-pipe is the
59 // spec escape and renders as a literal `|` in and out of tables.
60 '|' => f.write_str("\\|")?,
61 // Link/image brackets and HTML/autolink angle brackets. A
62 // backslash-escaped ASCII punctuation char renders literally.
63 '[' | ']' | '<' | '>' => {
64 f.write_char('\\')?;
65 f.write_char(c)?;
66 }
67 c if c.is_control() => {}
68 c => f.write_char(c)?,
69 }
70 }
71 Ok(())
72 }
73}
74
75/// Maximum chars consumed while scanning for an ANSI escape terminator. A
76/// well-formed CSI/OSC/DCS sequence is far shorter; the cap stops a malformed
77/// sequence from eating an unbounded run of legitimate text.
78const ANSI_SCAN_CAP: usize = 64;
79
80/// Consumes the remainder of an ANSI escape sequence after the leading ESC has
81/// already been read, given a peekable char iterator positioned at the
82/// introducer.
83///
84/// Critically, the introducer is only consumed when it is *actually* one — a
85/// lone/truncated ESC (e.g. `ESC` then `X` or `|`) drops only the ESC and
86/// leaves the following character for normal escaping, instead of swallowing
87/// it. Scans also bail on a newline/CR so a terminator-less sequence can't eat
88/// across a line.
89fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
90 match iter.peek() {
91 // CSI: `ESC [` … final byte in `@`-`~` (0x40-0x7E).
92 Some('[') => {
93 iter.next(); // consume '['
94 for _ in 0..ANSI_SCAN_CAP {
95 match iter.peek() {
96 // Bail before consuming a newline/CR — it's layout, not
97 // part of the (malformed, unterminated) sequence.
98 Some('\n') | Some('\r') | None => break,
99 Some(&ch) => {
100 iter.next();
101 if matches!(ch as u32, 0x40..=0x7E) {
102 break;
103 }
104 }
105 }
106 }
107 }
108 // String-type sequences: OSC (`ESC ]`) and DCS (`ESC P`). Both run
109 // until BEL (0x07) or ST (`ESC \\`).
110 Some(']') | Some('P') => {
111 iter.next(); // consume introducer
112 for _ in 0..ANSI_SCAN_CAP {
113 match iter.peek() {
114 Some('\n') | Some('\r') | None => break,
115 Some('\x07') => {
116 iter.next();
117 break;
118 }
119 Some('\x1b') => {
120 // Possible ST: consume ESC, then `\\` if present.
121 iter.next();
122 if iter.peek() == Some(&'\\') {
123 iter.next();
124 }
125 break;
126 }
127 Some(_) => {
128 iter.next();
129 }
130 }
131 }
132 }
133 // Charset-designation escapes: `ESC ( <id>` / `ESC ) <id>` and the
134 // 96-char variants `ESC * <id>` / `ESC + <id>`. Consume the introducer
135 // plus exactly the one designator char (never a newline).
136 Some('(') | Some(')') | Some('*') | Some('+') => {
137 iter.next(); // consume introducer
138 if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
139 iter.next();
140 }
141 }
142 // Not a recognized introducer (lone/truncated ESC). Drop only the ESC;
143 // leave the next char to be escaped normally by the main loop.
144 _ => {}
145 }
146}
147
148/// Markdown output formatter that produces clean, readable Markdown.
149#[derive(Default)]
150pub struct MarkdownFormatter;
151
152impl MarkdownFormatter {
153 pub fn new() -> Self {
154 Self
155 }
156
157 /// Renders the CAA policy as a Markdown section shared between SSL and
158 /// status reports.
159 fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
160 let mut out = Vec::new();
161 out.push(String::new());
162 out.push("### CAA Policy".to_string());
163 out.push(String::new());
164
165 if !caa.has_policy {
166 out.push("*No CAA records (any CA may issue)*".to_string());
167 } else {
168 Bullets(&mut out).code_opt("Found at", &caa.effective_domain);
169 out.push(String::new());
170 out.push("| Flags | Tag | Value |".to_string());
171 out.push("| --- | --- | --- |".to_string());
172 for r in &caa.records {
173 out.push(format!(
174 "| {} | `{}` | `{}` |",
175 r.flags,
176 MdSafe(&r.tag),
177 MdSafe(&r.value)
178 ));
179 }
180 }
181
182 if let Some(m) = caa.issuer_match {
183 let rendered = match m {
184 IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
185 IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
186 IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
187 IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
188 };
189 out.push(String::new());
190 out.push(format!("- **Issuer vs CAA**: {}", rendered));
191 }
192
193 out.push(String::new());
194 out.push(format!("> **Note:** {}", caa.note));
195 out
196 }
197}
198
199/// `` `a`, `b` ``: each item in its own [`MdSafe`] code span, joined by a
200/// plain `, ` (the separator's backticks must never pass through `MdSafe`,
201/// which would turn them into apostrophes).
202fn code_list<S: AsRef<str>>(items: &[S]) -> String {
203 items
204 .iter()
205 .map(|item| format!("`{}`", MdSafe(item.as_ref())))
206 .collect::<Vec<_>>()
207 .join(", ")
208}
209
210/// Appends `- **Label**: value` bullets. Remote values go through [`MdSafe`]
211/// here, so no bullet can skip the Markdown-injection guard.
212struct Bullets<'a>(&'a mut Vec<String>);
213
214impl Bullets<'_> {
215 /// Appends a pre-built line as-is.
216 fn push(&mut self, line: String) {
217 self.0.push(line);
218 }
219
220 /// `- **label**: value` for a value the caller already rendered safely.
221 fn raw(&mut self, label: &str, value: impl fmt::Display) {
222 self.0.push(format!("- **{label}**: {value}"));
223 }
224
225 /// `- **label**: value` for remote text.
226 fn text(&mut self, label: &str, value: &str) {
227 self.raw(label, MdSafe(value));
228 }
229
230 /// [`Self::text`], skipped when the field is absent.
231 fn opt(&mut self, label: &str, value: &Option<String>) {
232 if let Some(value) = value {
233 self.text(label, value);
234 }
235 }
236
237 /// `` - **label**: `value` `` for remote text shown as a code span.
238 fn code(&mut self, label: &str, value: &str) {
239 self.0.push(format!("- **{label}**: `{}`", MdSafe(value)));
240 }
241
242 /// [`Self::code`], skipped when the field is absent.
243 fn code_opt(&mut self, label: &str, value: &Option<String>) {
244 if let Some(value) = value {
245 self.code(label, value);
246 }
247 }
248
249 /// `` - **label**: `a`, `b` `` (see [`code_list`]); nothing when empty.
250 fn code_list(&mut self, label: &str, items: &[String]) {
251 if !items.is_empty() {
252 self.raw(label, code_list(items));
253 }
254 }
255
256 /// `` - **label**: `YYYY-MM-DD` ``, skipped when absent.
257 fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
258 if let Some(date) = date {
259 self.0
260 .push(format!("- **{label}**: `{}`", date.format("%Y-%m-%d")));
261 }
262 }
263
264 /// `` - **Expires**: `YYYY-MM-DD` (N days) ``, skipped when absent.
265 fn expires(&mut self, date: Option<DateTime<Utc>>) {
266 if let Some(date) = date {
267 self.0.push(format!(
268 "- **Expires**: `{}` ({} days)",
269 date.format("%Y-%m-%d"),
270 days_until(date)
271 ));
272 }
273 }
274
275 /// One `### <role> Contact` subsection with a bullet per populated
276 /// field; nothing for an empty contact.
277 ///
278 /// Push these only *after* every domain-level bullet: a `###` heading
279 /// scopes everything below it, so a Created/Expires/Nameservers bullet
280 /// emitted after a contact section renders as part of that contact.
281 fn contact(&mut self, role: &str, c: Contact<'_>) {
282 if c.is_empty() {
283 return;
284 }
285 self.0
286 .extend([String::new(), format!("### {role} Contact"), String::new()]);
287 self.opt("Name", c.name);
288 self.opt("Organization", c.organization);
289 self.code_opt("Email", c.email);
290 self.opt("Phone", c.phone);
291 self.opt("Address", c.address);
292 self.opt("Country", c.country);
293 }
294
295 /// [`Self::contact`] for each of [`contact::ROLES`].
296 fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
297 for (role, c) in contact::ROLES.into_iter().zip(contacts) {
298 self.contact(role, c);
299 }
300 }
301}
302
303with_report_methods!(impl_forwarding!(MarkdownFormatter;));
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 // --- MdSafe sanitization tests -----------------------------------------
310
311 fn md(s: &str) -> String {
312 format!("{}", MdSafe(s))
313 }
314
315 #[test]
316 fn test_mdsafe_strips_ansi_escape() {
317 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
318 }
319
320 #[test]
321 fn test_mdsafe_collapses_newlines_cr_tab() {
322 assert_eq!(md("a\nb"), "a b");
323 assert_eq!(md("a\rb"), "a b");
324 assert_eq!(md("a\tb"), "a b");
325 // CRLF becomes two spaces (each replaced individually); that's fine —
326 // the goal is to prevent breaking the line, not perfect whitespace.
327 assert_eq!(md("a\r\nb"), "a b");
328 }
329
330 #[test]
331 fn test_mdsafe_neutralizes_backticks() {
332 assert_eq!(md("`bad`"), "'bad'");
333 assert_eq!(md("a `b` c"), "a 'b' c");
334 }
335
336 #[test]
337 fn test_mdsafe_escapes_table_pipe() {
338 // A bare `|` from attacker-controlled data (DNS TXT, cert subject,
339 // WHOIS contact) breaks out of a Markdown table cell / fabricates
340 // columns. GFM's cell escape is backslash-pipe, a literal `|` both
341 // inside and outside tables.
342 assert_eq!(md("a|b"), "a\\|b");
343 assert_eq!(md("x | y | z"), "x \\| y \\| z");
344 }
345
346 #[test]
347 fn test_mdsafe_drops_other_control_chars() {
348 // NUL and DEL must vanish entirely.
349 assert_eq!(md("a\0b\x7fc"), "abc");
350 }
351
352 #[test]
353 fn test_mdsafe_preserves_unicode() {
354 assert_eq!(md("café — résumé"), "café — résumé");
355 }
356
357 #[test]
358 fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
359 // A bare ESC not followed by a real ANSI introducer must drop only the
360 // ESC, not the following characters. `X` is not a CSI/OSC introducer, so
361 // "hello" must survive — and the `|` is still escaped for table safety.
362 assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
363 }
364
365 #[test]
366 fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
367 // ESC + introducer `[` with no terminator before a newline: the scan
368 // must bail at the newline (re-emitting it as a space) rather than
369 // consuming "Ignore" while hunting for a terminator that never comes.
370 assert_eq!(md("\x1b[\nIgnore"), " Ignore");
371 }
372
373 #[test]
374 fn test_mdsafe_still_strips_wellformed_ansi() {
375 // Regression: a complete CSI color sequence is still fully removed.
376 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
377 // OSC terminated by BEL is still removed.
378 assert_eq!(md("\x1b]0;title\x07rest"), "rest");
379 }
380
381 #[test]
382 fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
383 // After dropping a lone ESC, a following structural `|` must still be
384 // escaped — the ANSI handling must not bypass the table-cell defense.
385 assert_eq!(md("\x1b|col"), "\\|col");
386 }
387
388 #[test]
389 fn test_mdsafe_neutralizes_links_images_and_html() {
390 // Attacker-controlled WHOIS/RDAP text must not render as a live image
391 // (tracking pixel), link (phishing), autolink, or raw HTML in markdown
392 // forwarded to an LLM via MCP.
393 assert_eq!(
394 md(""),
395 "!\\[x\\](https://attacker.example/p.png)"
396 );
397 assert_eq!(
398 md("[Verify](https://phish.example)"),
399 "\\[Verify\\](https://phish.example)"
400 );
401 assert_eq!(
402 md("<img src=https://attacker.example/p.png>"),
403 "\\<img src=https://attacker.example/p.png\\>"
404 );
405 assert_eq!(md("<https://phish.example>"), "\\<https://phish.example\\>");
406 // Parentheses alone carry no link syntax and stay unescaped.
407 assert_eq!(md("Example (Holdings) LLC"), "Example (Holdings) LLC");
408 }
409
410 #[test]
411 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
412 // Mirror of the human-formatter regression: the PR #101
413 // registrar-detail + derived-lifecycle fields must be visible in
414 // `--format markdown`, not just JSON/YAML (2026-07-11 review).
415 let whois = WhoisResponse::parse(
416 "example.com",
417 "whois.test",
418 "Registrar: Example Registrar\n\
419 Creation Date: 2020-01-01T00:00:00Z\n\
420 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
421 Domain Status: clientTransferProhibited\n",
422 );
423 let mut info =
424 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
425 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
426 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
427 info.registrar_iana_id = Some("9999".to_string());
428 info.registrar_url = Some("https://registrar.test".to_string());
429
430 let out = MarkdownFormatter::new().format_domain_info(&info);
431 for needle in [
432 "Registrar Detail",
433 "abuse@registrar.test",
434 "+1.5555550100",
435 "9999",
436 "https://registrar.test",
437 "Days Until Expiry",
438 "Domain Age",
439 "Expiry Status",
440 "clientTransferProhibited",
441 ] {
442 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
443 }
444 }
445}