Skip to main content

seer_core/output/human/
mod.rs

1use chrono::TimeDelta;
2use once_cell::sync::Lazy;
3use regex::Regex;
4
5use super::OutputFormatter;
6
7// Shared with the per-concern submodules below (each does `use super::*`).
8pub(super) use super::grouping::render_grouped;
9pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
10pub(super) use crate::colors::CatppuccinExt;
11pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
12pub(super) use crate::lookup::LookupResult;
13pub(super) use crate::rdap::RdapResponse;
14pub(super) use crate::status::StatusResponse;
15pub(super) use crate::whois::WhoisResponse;
16pub(super) use colored::Colorize;
17
18mod diff;
19mod dns;
20mod domain_info;
21mod lookup;
22mod propagation;
23mod rdap;
24mod status;
25mod whois;
26
27/// Strips ANSI escape sequences from untrusted external strings to prevent
28/// terminal injection via malicious WHOIS/RDAP response data. The OSC branch
29/// accepts both BEL (`\x07`) and ST (`\x1b\\`) terminators (and excludes ESC
30/// from the payload run so it can't over-consume across sequences).
31static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
32    Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]")
33        .expect("Invalid ANSI escape regex")
34});
35
36/// Sanitizes untrusted external text (WHOIS/RDAP field values) for safe display
37/// on a terminal. First removes well-formed ANSI escape sequences, then drops
38/// any remaining C0/C1 control characters (bare CR, backspace, BEL, stray ESC,
39/// the C1 range, DEL) that could overwrite or spoof rendered lines — keeping
40/// only `\n` and `\t`, which are legitimate layout. Mirrors the markdown
41/// `MdSafe` guard so the human path is no longer the weaker one (issue #53).
42pub(super) fn sanitize_display(s: &str) -> String {
43    ANSI_ESCAPE_RE
44        .replace_all(s, "")
45        .chars()
46        .filter(|&c| c == '\n' || c == '\t' || !c.is_control())
47        .collect()
48}
49
50/// Formats a [`TimeDelta`] as a compact human duration (`Ns` / `Nm Ns` /
51/// `Nh Nm`). Shared across the human and markdown formatters — `pub(crate)` so
52/// the markdown follow formatter can reuse it instead of duplicating the
53/// breakdown.
54pub(crate) fn format_duration(duration: TimeDelta) -> String {
55    let total_secs = duration.num_seconds();
56    if total_secs < 60 {
57        format!("{}s", total_secs)
58    } else if total_secs < 3600 {
59        let mins = total_secs / 60;
60        let secs = total_secs % 60;
61        format!("{}m {}s", mins, secs)
62    } else {
63        let hours = total_secs / 3600;
64        let mins = (total_secs % 3600) / 60;
65        format!("{}h {}m", hours, mins)
66    }
67}
68
69pub struct HumanFormatter {
70    use_colors: bool,
71}
72
73impl Default for HumanFormatter {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl HumanFormatter {
80    pub fn new() -> Self {
81        Self { use_colors: true }
82    }
83
84    pub fn without_colors(mut self) -> Self {
85        self.use_colors = false;
86        self
87    }
88
89    fn label(&self, text: &str) -> String {
90        if self.use_colors {
91            text.sky().bold().to_string()
92        } else {
93            text.to_string()
94        }
95    }
96
97    fn value(&self, text: &str) -> String {
98        if self.use_colors {
99            text.ctp_white().to_string()
100        } else {
101            text.to_string()
102        }
103    }
104
105    fn success(&self, text: &str) -> String {
106        if self.use_colors {
107            text.ctp_green().bold().to_string()
108        } else {
109            text.to_string()
110        }
111    }
112
113    fn warning(&self, text: &str) -> String {
114        if self.use_colors {
115            text.ctp_yellow().bold().to_string()
116        } else {
117            text.to_string()
118        }
119    }
120
121    fn error(&self, text: &str) -> String {
122        if self.use_colors {
123            text.ctp_red().bold().to_string()
124        } else {
125            text.to_string()
126        }
127    }
128
129    fn dim(&self, text: &str) -> String {
130        if self.use_colors {
131            text.overlay1().to_string()
132        } else {
133            text.to_string()
134        }
135    }
136
137    fn header(&self, text: &str) -> String {
138        // Underline width is the character count, not the byte length: a header
139        // containing an IDN / non-ASCII domain would otherwise be over-ruled by
140        // one or more extra dashes per multi-byte char. (Matches the diff
141        // formatter's chars().count() convention.)
142        let width = text.chars().count();
143        if self.use_colors {
144            format!(
145                "\n{}\n{}",
146                text.lavender().bold(),
147                "─".repeat(width).subtext0()
148            )
149        } else {
150            format!("\n{}\n{}", text, "-".repeat(width))
151        }
152    }
153
154    /// Renders the CAA policy block (records, issuer match, note) shared
155    /// between `format_status` and `format_ssl`. `indent` is the leading
156    /// whitespace per line — typically `"  "`.
157    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
158        let mut out = Vec::new();
159        out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
160
161        if !caa.has_policy {
162            out.push(format!(
163                "{}  {}",
164                indent,
165                self.value("No CAA records (any CA may issue)")
166            ));
167        } else {
168            if let Some(ref eff) = caa.effective_domain {
169                out.push(format!(
170                    "{}  {}: {}",
171                    indent,
172                    self.label("Found at"),
173                    self.value(&sanitize_display(eff))
174                ));
175            }
176            for r in &caa.records {
177                out.push(format!(
178                    "{}  {} {} \"{}\"",
179                    indent,
180                    self.value(&r.flags.to_string()),
181                    self.label(&r.tag),
182                    sanitize_display(&r.value)
183                ));
184            }
185        }
186
187        if let Some(m) = caa.issuer_match {
188            let rendered = match m {
189                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
190                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
191                IssuerCaaMatch::Mismatch => self
192                    .warning("issuer not in current CAA policy (informational — see note below)"),
193                IssuerCaaMatch::Indeterminate => {
194                    self.warning("CAA present but no issue/issuewild tags")
195                }
196            };
197            out.push(format!(
198                "{}  {}: {}",
199                indent,
200                self.label("Issuer vs CAA"),
201                rendered
202            ));
203        }
204
205        // Note is appended separately by the caller so it can sit at the
206        // very bottom of the overall output, un-indented.
207        out
208    }
209
210    /// Appends the trailing CAA note as the very last lines of an output
211    /// buffer: a blank separator line followed by `note: …` with no
212    /// indentation, so the explanation reads as a footer to the whole
213    /// report rather than part of the CAA block.
214    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
215        out.push(String::new());
216        out.push(format!("note: {}", caa.note));
217    }
218
219    /// Formats an expiration date with a human-readable status suffix.
220    ///
221    /// Behaviour:
222    /// - already expired (negative days): red "expired N days ago"
223    /// - <30 days remaining: red "expires in N days!"
224    /// - <90 days remaining: yellow "expires in N days"
225    /// - otherwise: green "expires in N days"
226    fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
227        if days_until < 0 {
228            self.error(&format!(
229                "{} (expired {} days ago)",
230                expiry_str, -days_until
231            ))
232        } else if days_until < 30 {
233            self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
234        } else if days_until < 90 {
235            self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
236        } else {
237            self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
238        }
239    }
240}
241
242// Thin dispatch layer: each trait method forwards to the inherent
243// method of the same name defined in the per-concern submodule. Rust
244// resolves the inherent method first, so this does not recurse.
245impl OutputFormatter for HumanFormatter {
246    fn format_whois(&self, response: &WhoisResponse) -> String {
247        self.format_whois(response)
248    }
249    fn format_rdap(&self, response: &RdapResponse) -> String {
250        self.format_rdap(response)
251    }
252    fn format_dns(&self, records: &[DnsRecord]) -> String {
253        self.format_dns(records)
254    }
255    fn format_propagation(&self, result: &PropagationResult) -> String {
256        self.format_propagation(result)
257    }
258    fn format_lookup(&self, result: &LookupResult) -> String {
259        self.format_lookup(result)
260    }
261    fn format_status(&self, response: &StatusResponse) -> String {
262        self.format_status(response)
263    }
264    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
265        self.format_follow_iteration(iteration)
266    }
267    fn format_follow(&self, result: &FollowResult) -> String {
268        self.format_follow(result)
269    }
270    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
271        self.format_availability(result)
272    }
273    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
274        self.format_dnssec(report)
275    }
276    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
277        self.format_tld(info)
278    }
279    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
280        self.format_dns_comparison(comparison)
281    }
282    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
283        self.format_subdomains(result)
284    }
285    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
286        self.format_diff(diff)
287    }
288    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
289        self.format_ssl(report)
290    }
291    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
292        self.format_watch(report)
293    }
294    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
295        self.format_domain_info(info)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn formatter() -> HumanFormatter {
304        HumanFormatter::new().without_colors()
305    }
306
307    #[test]
308    fn expired_shows_days_ago() {
309        let f = formatter();
310        let out = f.format_expiry_status("2024-01-01", -3);
311        assert!(out.contains("expired 3 days ago"), "got: {}", out);
312        assert!(!out.contains("-3"), "got: {}", out);
313    }
314
315    #[test]
316    fn expiring_soon_shows_expires_in() {
317        let f = formatter();
318        let out = f.format_expiry_status("2026-05-01", 15);
319        assert!(out.contains("expires in 15 days"), "got: {}", out);
320        assert!(!out.contains("days ago"), "got: {}", out);
321    }
322
323    #[test]
324    fn warning_window_uses_expires_in() {
325        let f = formatter();
326        let out = f.format_expiry_status("2026-07-01", 60);
327        assert!(out.contains("expires in 60 days"), "got: {}", out);
328        assert!(!out.contains("!"), "got: {}", out);
329    }
330
331    #[test]
332    fn healthy_expiry_uses_expires_in() {
333        let f = formatter();
334        let out = f.format_expiry_status("2027-01-01", 300);
335        assert!(out.contains("expires in 300 days"), "got: {}", out);
336        assert!(!out.contains("!"), "got: {}", out);
337    }
338
339    #[test]
340    fn expired_one_day_is_pluralized_simply() {
341        // We don't singularize; verify the raw format.
342        let f = formatter();
343        let out = f.format_expiry_status("2024-01-01", -1);
344        assert!(out.contains("expired 1 days ago"), "got: {}", out);
345    }
346
347    #[test]
348    fn boundary_30_days_is_warning_not_error() {
349        let f = formatter();
350        // 30 days -> not <30, so warning branch, no "!"
351        let out = f.format_expiry_status("2026-05-15", 30);
352        assert!(out.contains("expires in 30 days"), "got: {}", out);
353        assert!(!out.contains("!"), "got: {}", out);
354    }
355
356    // --- #53: terminal-escape / control-char sanitization ---------------
357
358    #[test]
359    fn sanitize_display_strips_bare_control_chars_keeps_newline_tab() {
360        // The old regex only removed ESC-introduced sequences; bare C0/C1
361        // control chars (CR, backspace, BEL, C1 CSI 0x9B) survived and could
362        // overwrite or spoof rendered lines. They must be stripped; newline
363        // and tab are legitimate layout and must be preserved.
364        let evil = "a\rb\x08c\x07d\u{009b}e";
365        let clean = sanitize_display(evil);
366        for bad in ['\r', '\x08', '\x07', '\u{009b}', '\u{001b}'] {
367            assert!(
368                !clean.contains(bad),
369                "{bad:?} must be stripped from {clean:?}"
370            );
371        }
372        assert!(
373            clean.contains('a') && clean.contains('e'),
374            "text kept: {clean:?}"
375        );
376        assert_eq!(
377            sanitize_display("line1\nline2\tcol"),
378            "line1\nline2\tcol",
379            "newline and tab must be preserved"
380        );
381    }
382
383    #[test]
384    fn sanitize_display_strips_osc_with_st_terminator() {
385        // OSC terminated by ST (ESC \\) rather than BEL previously slipped
386        // through, leaking the payload as visible text.
387        let clean = sanitize_display("\x1b]0;malicious title\x1b\\visible");
388        assert_eq!(
389            clean, "visible",
390            "OSC+ST sequence must be fully removed: {clean:?}"
391        );
392        assert!(!clean.contains('\x1b'));
393        // Regression: classic CSI color codes still stripped.
394        assert_eq!(sanitize_display("\x1b[31mred\x1b[0m"), "red");
395    }
396
397    #[test]
398    fn domain_info_sanitizes_nameserver_and_status_fields() {
399        // Nameserver/status values come from attacker-controlled WHOIS parsing
400        // and were printed without sanitize_display (unlike the adjacent DNSSEC
401        // field), so an injected ANSI/OSC sequence reached the terminal.
402        let whois = WhoisResponse::parse(
403            "evil.example",
404            "whois.test",
405            "Name Server: ns1.evil\x1b[31mhidden\n\
406             Domain Status: ok\x1b]0;pwn\x07\n\
407             Registrar: Test Registrar\n\
408             Creation Date: 2020-01-01T00:00:00Z\n",
409        );
410        let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
411        let out = formatter().format_domain_info(&info);
412        assert!(
413            !out.contains('\x1b'),
414            "ESC must not reach terminal: {out:?}"
415        );
416        assert!(
417            !out.contains('\x07'),
418            "BEL must not reach terminal: {out:?}"
419        );
420    }
421}