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