Skip to main content

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 `![x](https://attacker.example/p.png)`). `(`/`)` 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("![x](https://attacker.example/p.png)"),
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}