1use crate::model::{DomainState, Host, ProbeStatus, ScanState};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum Category {
5 Exposed,
7 Resolving,
9 PkiOnly,
11 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
54pub 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 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
173fn 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
183fn 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
191pub 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
237pub 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
254pub 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 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 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}