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