Skip to main content

pq_map/render/
text.rs

1use crate::model::{DomainState, Host, ProbeStatus, ScanState};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum Category {
5    /// Valid certificate + service observed.
6    Exposed,
7    /// Valid certificate + DNS exists, service not yet identified.
8    Resolving,
9    /// Valid certificate + no public DNS.
10    PkiOnly,
11    /// No valid certificate + no current service signal.
12    Unresolved,
13}
14
15impl Category {
16    const ALL: [Self; 4] = [
17        Self::Exposed,
18        Self::Resolving,
19        Self::PkiOnly,
20        Self::Unresolved,
21    ];
22
23    fn title(&self) -> &'static str {
24        match self {
25            Self::Exposed => "EXPOSED",
26            Self::Resolving => "RESOLVING",
27            Self::PkiOnly => "PKI-ONLY",
28            Self::Unresolved => "UNRESOLVED",
29        }
30    }
31
32    fn of(host: &Host) -> Self {
33        if matches!(
34            host.endpoint.as_ref().map(|e| &e.status),
35            Some(ProbeStatus::Ok)
36        ) || host.ports.iter().any(|p| p.state == ScanState::Open)
37        {
38            return Self::Exposed;
39        }
40        if matches!(
41            host.dns_status,
42            Some(crate::model::DnsStatus::Ok) | Some(crate::model::DnsStatus::NoData)
43        ) {
44            return Self::Resolving;
45        }
46        if !host.ct_refs.is_empty() {
47            Self::PkiOnly
48        } else {
49            Self::Unresolved
50        }
51    }
52}
53
54/// Combined view (default output mode).
55pub fn combined(state: &DomainState) -> String {
56    let mut out = String::new();
57    out.push_str(&format!("Discovered names: {}\n", state.hosts.len()));
58
59    let all: Vec<&Host> = state.hosts.values().collect();
60    let host_w = 39
61        .max(all.iter().map(|h| h.name.len()).max().unwrap_or(0) + 2)
62        .max("HOST / SERVICE".len());
63    let service_w = 15;
64    let mx_targets: std::collections::BTreeSet<&str> =
65        state.mx.iter().map(|rec| rec.target.as_str()).collect();
66
67    for category in Category::ALL {
68        let hosts = all
69            .iter()
70            .filter(|h| Category::of(h) == category)
71            .copied()
72            .collect::<Vec<_>>();
73        if hosts.is_empty() {
74            continue;
75        }
76        out.push_str(&format!("\n{} ({})\n", category.title(), hosts.len()));
77        match category {
78            Category::Exposed => {
79                out.push_str(&format!(
80                    "{:<host_w$}{:<service_w$}{}\n",
81                    "HOST / SERVICE", "SERVICE", "OBSERVED KX"
82                ));
83                for host in ordered(&hosts) {
84                    let mut rows: Vec<(&str, &str)> = Vec::new();
85                    if matches!(
86                        host.endpoint.as_ref().map(|e| &e.status),
87                        Some(ProbeStatus::Ok)
88                    ) {
89                        let kx = host
90                            .endpoint
91                            .as_ref()
92                            .and_then(|e| e.negotiated.as_ref())
93                            .and_then(|n| n.kx_group.as_deref())
94                            .unwrap_or("-");
95                        rows.push(("TLS:443", kx));
96                    }
97                    for scan in &host.ports {
98                        if scan.state != ScanState::Open {
99                            continue;
100                        }
101                        let mut label = crate::model::port_service(scan.port).to_string();
102                        label.push(':');
103                        label.push_str(&scan.port.to_string());
104                        if scan.port == 25 && mx_targets.contains(host.name.as_str()) {
105                            label.push_str("/MX");
106                        }
107                        let kx = scan
108                            .negotiated
109                            .as_ref()
110                            .and_then(|n| n.kx_group.as_deref())
111                            .unwrap_or("-");
112                        rows.push((Box::leak(label.into_boxed_str()), kx));
113                    }
114                    for (service, kx) in rows {
115                        out.push_str(&format!(
116                            "{:<host_w$}{:<service_w$}{}\n",
117                            host.name, service, kx
118                        ));
119                    }
120                }
121            }
122            Category::Resolving | Category::PkiOnly | Category::Unresolved => {
123                out.push_str(&format!("{:<host_w$}\n", "HOST"));
124                for host in ordered(&hosts) {
125                    out.push_str(&format!("{:<host_w$}\n", host.name));
126                }
127            }
128        }
129    }
130
131    if !state.srv.is_empty() {
132        out.push('\n');
133        let mut prev_fqdn: Option<&str> = None;
134        for rec in &state.srv {
135            let label_line = format!(
136                "{} → {}:{}",
137                crate::discover::dns::service_label(&rec.service),
138                rec.target,
139                rec.port
140            );
141            let fqdn = if prev_fqdn == Some(rec.fqdn.as_str()) {
142                ""
143            } else {
144                rec.fqdn.as_str()
145            };
146            out.push_str(&format!("{:<host_w$}{:<service_w$}\n", fqdn, label_line));
147            prev_fqdn = Some(rec.fqdn.as_str());
148        }
149    }
150
151    for rec in &state.mx {
152        let label_line = format!("MX → {} ({})", rec.target, rec.priority);
153        out.push_str(&format!("{:<host_w$}{:<service_w$}\n", "", label_line));
154    }
155
156    // SVCB/HTTPS records on the apex, same row shape as SRV.
157    for rec in &state.https {
158        let params = if rec.params.is_empty() {
159            String::new()
160        } else {
161            format!(" {}", rec.params)
162        };
163        let label_line = format!("{} → {}{}", rec.rr_type, rec.target, params);
164        out.push_str(&format!(
165            "{:<host_w$}{:<service_w$}\n",
166            state.domain, label_line
167        ));
168    }
169
170    out
171}
172
173/// Alphabetical, with `www.` variants adjacent to their bare host.
174fn ordered<'a>(hosts: &[&'a Host]) -> Vec<&'a Host> {
175    let mut hosts: Vec<&Host> = hosts.to_vec();
176    hosts.sort_by_key(|h| {
177        let (base, is_www) = www_key(&h.name);
178        (base, is_www, h.name.clone())
179    });
180    hosts
181}
182
183/// "www.x" → ("x", 1), "x" → ("x", 0) — bare host sorts before its www variant.
184fn www_key(name: &str) -> (String, u8) {
185    match name.strip_prefix("www.") {
186        Some(base) => (base.to_string(), 1),
187        None => (name.to_string(), 0),
188    }
189}
190
191/// ct-inv2.sh `summary` parity.
192pub fn summary(state: &DomainState) -> String {
193    let mut identities = std::collections::BTreeSet::new();
194    let mut concrete = std::collections::BTreeSet::new();
195    let mut wildcards = std::collections::BTreeSet::new();
196    for cert in state.certs.values().filter(|c| c.currently_valid) {
197        for name in &cert.identities {
198            identities.insert(name.clone());
199            if crate::discover::ct::is_wildcard(name) {
200                wildcards.insert(name.clone());
201            } else {
202                concrete.insert(name.clone());
203            }
204        }
205    }
206    let row = |label: &str, value: usize| format!("  {label:<31}{value}\n");
207    let swept: Vec<&crate::model::PortScan> =
208        state.hosts.values().flat_map(|h| h.ports.iter()).collect();
209    let open_ports: usize = swept.iter().filter(|s| s.state == ScanState::Open).count();
210    let open_hosts: usize = state
211        .hosts
212        .values()
213        .filter(|h| h.ports.iter().any(|s| s.state == ScanState::Open))
214        .count();
215    format!(
216        "Certificate estate: '{}'\n\n{}{}{}\n{}{}{}\n{}{}",
217        state.domain,
218        row("CT certificates:", state.ct_stats.rows),
219        row("Currently valid:", state.ct_stats.valid),
220        row("Future-valid:", state.ct_stats.future),
221        row("Unique identities:", identities.len()),
222        row("Concrete hostnames:", concrete.len()),
223        row("Wildcard identities:", wildcards.len()),
224        row("Alt-port swept hosts:", swept.len()),
225        if swept.is_empty() {
226            String::new()
227        } else {
228            format!(
229                "{}{}",
230                row("Alt-port open hosts:", open_hosts),
231                row("Alt-port open services:", open_ports)
232            )
233        }
234    )
235}
236
237/// ct-inv2.sh `--hosts` parity: concrete names of currently valid certificates.
238pub fn hosts(state: &DomainState) -> String {
239    let mut names = std::collections::BTreeSet::new();
240    for cert in state.certs.values().filter(|c| c.currently_valid) {
241        for name in &cert.identities {
242            if !crate::discover::ct::is_wildcard(name) {
243                names.insert(name.clone());
244            }
245        }
246    }
247    let mut out = names.iter().map(|n| format!("{n}\n")).collect::<String>();
248    if !out.ends_with('\n') {
249        out.push('\n');
250    }
251    out
252}
253
254/// ct-inv2.sh `--certs` parity.
255pub fn certs(state: &DomainState) -> String {
256    let mut out = String::new();
257    out.push_str(&format!(
258        "{:<12}  {:<30}  {:<30}  {:<20}  {}\n",
259        "CT ID", "NOT BEFORE", "NOT AFTER", "COMMON NAME", "IDENTITIES"
260    ));
261    out.push_str(&format!(
262        "{:<12}  {:<30}  {:<30}  {:<20}  {}\n",
263        "------------",
264        "------------------------------",
265        "------------------------------",
266        "--------------------",
267        "----------"
268    ));
269    for cert in state.certs.values().filter(|c| c.currently_valid) {
270        let id = cert
271            .ct_ids
272            .first()
273            .map(|i| i.to_string())
274            .unwrap_or_else(|| "-".to_string());
275        let nb = fmt_date(cert.not_before);
276        let na = fmt_date(cert.not_after);
277        let cn = cert.common_name.clone().unwrap_or_else(|| "-".to_string());
278        out.push_str(&format!(
279            "{:<12}  {:<30}  {:<30}  {:<20}  {}\n",
280            id,
281            nb,
282            na,
283            cn,
284            cert.identities.join(",")
285        ));
286    }
287    out
288}
289
290fn fmt_date(dt: Option<chrono::DateTime<chrono::Utc>>) -> String {
291    dt.map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string())
292        .unwrap_or_else(|| "-".to_string())
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::model::{MatchKind, fixture};
299
300    /// Cells of the first table row whose host column starts with `name`.
301    fn row_cells(text: &str, name: &str) -> String {
302        text.lines()
303            .find(|l| l.starts_with(&format!("{name} ")))
304            .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
305            .unwrap_or_else(|| panic!("no row for {name}"))
306    }
307
308    /// Cells of the last table row for `name` (host row first, DNS record rows later).
309    fn last_row_cells(text: &str, name: &str) -> String {
310        text.lines()
311            .rfind(|l| l.starts_with(&format!("{name} ")))
312            .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
313            .unwrap_or_else(|| panic!("no row for {name}"))
314    }
315
316    #[test]
317    fn combined_matches_layout() {
318        let state = fixture();
319        let text = combined(&state);
320
321        assert!(text.starts_with("Discovered names: 5\n"));
322        for section in ["EXPOSED (3)", "RESOLVING (1)", "PKI-ONLY (1)"] {
323            assert!(text.contains(section));
324        }
325        assert_eq!(
326            row_cells(&text, "www.example.com"),
327            "www.example.com TLS:443 X25519MLKEM768"
328        );
329        assert_eq!(row_cells(&text, "vpn.example.com"), "vpn.example.com");
330        assert_eq!(row_cells(&text, "old.example.com"), "old.example.com");
331        assert_eq!(
332            row_cells(&text, "eas.example.com"),
333            "eas.example.com SMTPS:465 X25519"
334        );
335        assert_eq!(
336            last_row_cells(&text, "eas.example.com"),
337            "eas.example.com SMTP:25/MX X25519"
338        );
339        assert!(text.contains("_sip._tls.example.com"));
340        assert!(text.contains("SIP/TLS → sipdir.online.lync.com:443"));
341        assert_eq!(
342            last_row_cells(&text, "example.com"),
343            "example.com HTTPS → . alpn=h2,h3"
344        );
345        assert!(!text.contains("=== "));
346    }
347
348    #[test]
349    fn www_variants_group_with_bare_host() {
350        let state = fixture();
351        let text = combined(&state);
352        let lines: Vec<&str> = text.lines().collect();
353        let apex = lines
354            .iter()
355            .position(|l| l.starts_with("example.com "))
356            .unwrap();
357        let www = lines
358            .iter()
359            .position(|l| l.starts_with("www.example.com "))
360            .unwrap();
361        assert_eq!(www, apex + 1, "www.example.com directly after example.com");
362    }
363
364    #[test]
365    fn summary_counts_fixture() {
366        let state = fixture();
367        let text = summary(&state);
368        assert!(text.contains("Certificate estate: 'example.com'"));
369        let line = text
370            .lines()
371            .find(|l| l.contains("CT certificates:"))
372            .expect("summary row");
373        assert!(line.trim_end().ends_with('2'), "rows counted: {line}");
374    }
375
376    #[test]
377    fn wildcard_refs_rendered_by_probe_views() {
378        let state = fixture();
379        let old = state.hosts.get("old.example.com").unwrap();
380        assert!(
381            old.ct_refs
382                .iter()
383                .any(|r| r.kind == MatchKind::Wildcard && r.serial == "bb22")
384        );
385    }
386}