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), and
28/// neutralizes backticks (so an attacker can't terminate a code span and
29/// inject Markdown structure).
30pub(super) struct MdSafe<'a>(pub &'a str);
31
32impl fmt::Display for MdSafe<'_> {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        let mut iter = self.0.chars();
35        while let Some(c) = iter.next() {
36            match c {
37                '\x1b' => {
38                    // Consume the rest of the ANSI escape sequence (CSI or
39                    // OSC). The byte right after ESC is the introducer
40                    // (`[` for CSI, `]` for OSC, etc.) — skip it
41                    // unconditionally so it isn't treated as a terminator.
42                    // Then look for the terminator: CSI ends on a byte in
43                    // `@`-`~`; OSC ends on BEL (0x07) or ST. Cap
44                    // consumption defensively.
45                    let _ = iter.next();
46                    for inner in iter.by_ref().take(64) {
47                        if matches!(inner as u32, 0x40..=0x7E) || inner == '\x07' {
48                            break;
49                        }
50                    }
51                }
52                '\n' | '\r' | '\t' => f.write_str(" ")?,
53                '`' => f.write_str("'")?,
54                c if c.is_control() => {}
55                c => f.write_char(c)?,
56            }
57        }
58        Ok(())
59    }
60}
61
62/// Markdown output formatter that produces clean, readable Markdown.
63pub struct MarkdownFormatter;
64
65impl Default for MarkdownFormatter {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl MarkdownFormatter {
72    pub fn new() -> Self {
73        Self
74    }
75
76    /// Renders the CAA policy as a Markdown section shared between SSL and
77    /// status reports.
78    fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
79        let mut out = Vec::new();
80        out.push(String::new());
81        out.push("### CAA Policy".to_string());
82        out.push(String::new());
83
84        if !caa.has_policy {
85            out.push("*No CAA records (any CA may issue)*".to_string());
86        } else {
87            if let Some(ref eff) = caa.effective_domain {
88                out.push(format!("- **Found at**: `{}`", MdSafe(eff)));
89            }
90            out.push(String::new());
91            out.push("| Flags | Tag | Value |".to_string());
92            out.push("| --- | --- | --- |".to_string());
93            for r in &caa.records {
94                out.push(format!(
95                    "| {} | `{}` | `{}` |",
96                    r.flags,
97                    MdSafe(&r.tag),
98                    MdSafe(&r.value)
99                ));
100            }
101        }
102
103        if let Some(m) = caa.issuer_match {
104            let rendered = match m {
105                IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
106                IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
107                IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
108                IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
109            };
110            out.push(String::new());
111            out.push(format!("- **Issuer vs CAA**: {}", rendered));
112        }
113
114        out.push(String::new());
115        out.push(format!("> **Note:** {}", caa.note));
116        out
117    }
118
119    /// Formats a contact section for RDAP entities.
120    fn format_rdap_contact(
121        &self,
122        output: &mut Vec<String>,
123        label: &str,
124        contact: &crate::rdap::ContactInfo,
125    ) {
126        if !contact.has_info() {
127            return;
128        }
129        output.push(String::new());
130        output.push(format!("### {}", label));
131        output.push(String::new());
132        if let Some(ref name) = contact.name {
133            output.push(format!("- **Name**: {}", MdSafe(name)));
134        }
135        if let Some(ref org) = contact.organization {
136            output.push(format!("- **Organization**: {}", MdSafe(org)));
137        }
138        if let Some(ref email) = contact.email {
139            output.push(format!("- **Email**: `{}`", MdSafe(email)));
140        }
141        if let Some(ref phone) = contact.phone {
142            output.push(format!("- **Phone**: {}", MdSafe(phone)));
143        }
144        if let Some(ref address) = contact.address {
145            output.push(format!("- **Address**: {}", MdSafe(address)));
146        }
147        if let Some(ref country) = contact.country {
148            output.push(format!("- **Country**: {}", MdSafe(country)));
149        }
150    }
151
152    /// Formats WHOIS contact fields as a markdown subsection.
153    fn format_whois_contact(
154        &self,
155        output: &mut Vec<String>,
156        label: &str,
157        name: &Option<String>,
158        organization: &Option<String>,
159        email: &Option<String>,
160        phone: &Option<String>,
161    ) {
162        let has_info =
163            name.is_some() || organization.is_some() || email.is_some() || phone.is_some();
164        if !has_info {
165            return;
166        }
167        output.push(String::new());
168        output.push(format!("### {}", label));
169        output.push(String::new());
170        if let Some(ref v) = *name {
171            output.push(format!("- **Name**: {}", MdSafe(v)));
172        }
173        if let Some(ref v) = *organization {
174            output.push(format!("- **Organization**: {}", MdSafe(v)));
175        }
176        if let Some(ref v) = *email {
177            output.push(format!("- **Email**: `{}`", MdSafe(v)));
178        }
179        if let Some(ref v) = *phone {
180            output.push(format!("- **Phone**: {}", MdSafe(v)));
181        }
182    }
183}
184
185// Thin dispatch layer: each trait method forwards to the inherent
186// method of the same name defined in the per-concern submodule. Rust
187// resolves the inherent method first, so this does not recurse.
188impl OutputFormatter for MarkdownFormatter {
189    fn format_whois(&self, response: &WhoisResponse) -> String {
190        self.format_whois(response)
191    }
192    fn format_rdap(&self, response: &RdapResponse) -> String {
193        self.format_rdap(response)
194    }
195    fn format_dns(&self, records: &[DnsRecord]) -> String {
196        self.format_dns(records)
197    }
198    fn format_propagation(&self, result: &PropagationResult) -> String {
199        self.format_propagation(result)
200    }
201    fn format_lookup(&self, result: &LookupResult) -> String {
202        self.format_lookup(result)
203    }
204    fn format_status(&self, response: &StatusResponse) -> String {
205        self.format_status(response)
206    }
207    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
208        self.format_follow_iteration(iteration)
209    }
210    fn format_follow(&self, result: &FollowResult) -> String {
211        self.format_follow(result)
212    }
213    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
214        self.format_availability(result)
215    }
216    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
217        self.format_tld(info)
218    }
219    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
220        self.format_dnssec(report)
221    }
222    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
223        self.format_dns_comparison(comparison)
224    }
225    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
226        self.format_subdomains(result)
227    }
228    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
229        self.format_diff(diff)
230    }
231    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
232        self.format_ssl(report)
233    }
234    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
235        self.format_watch(report)
236    }
237    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
238        self.format_domain_info(info)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    // --- MdSafe sanitization tests -----------------------------------------
247
248    fn md(s: &str) -> String {
249        format!("{}", MdSafe(s))
250    }
251
252    #[test]
253    fn test_mdsafe_strips_ansi_escape() {
254        assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
255    }
256
257    #[test]
258    fn test_mdsafe_collapses_newlines_cr_tab() {
259        assert_eq!(md("a\nb"), "a b");
260        assert_eq!(md("a\rb"), "a b");
261        assert_eq!(md("a\tb"), "a b");
262        // CRLF becomes two spaces (each replaced individually); that's fine —
263        // the goal is to prevent breaking the line, not perfect whitespace.
264        assert_eq!(md("a\r\nb"), "a  b");
265    }
266
267    #[test]
268    fn test_mdsafe_neutralizes_backticks() {
269        assert_eq!(md("`bad`"), "'bad'");
270        assert_eq!(md("a `b` c"), "a 'b' c");
271    }
272
273    #[test]
274    fn test_mdsafe_drops_other_control_chars() {
275        // NUL and DEL must vanish entirely.
276        assert_eq!(md("a\0b\x7fc"), "abc");
277    }
278
279    #[test]
280    fn test_mdsafe_preserves_unicode() {
281        assert_eq!(md("café — résumé"), "café — résumé");
282    }
283}