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