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