Skip to main content

seer_core/output/markdown/
mod.rs

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