1use std::collections::{BTreeSet, HashMap};
18
19use serde::{Deserialize, Serialize};
20
21use crate::dns::DnsResolver;
22use crate::takeover::{resolve_host, truncate_to_cap};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
28struct WildcardAnswer {
29 cname: Option<String>,
30}
31
32const MAX_CLASSIFY_NAMES: usize = 2000;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SubdomainStatus {
47 Live,
49 Dead,
51 Wildcard,
55 Unknown,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ClassifiedSubdomain {
63 pub name: String,
64 pub status: SubdomainStatus,
65 #[serde(skip_serializing_if = "Vec::is_empty")]
66 pub addresses: Vec<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub cname: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub takeover_risk: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SubdomainClassification {
78 pub domain: String,
79 pub wildcard_detected: bool,
82 pub subdomains: Vec<ClassifiedSubdomain>,
83 #[serde(default)]
88 pub names_skipped: usize,
89}
90
91fn cname_key(cname: Option<&str>) -> Option<String> {
93 cname.map(|c| c.trim_end_matches('.').to_ascii_lowercase())
94}
95
96fn classify_one(
108 name: String,
109 addresses: Vec<String>,
110 cname: Option<String>,
111 provider: Option<&'static str>,
112 lookup_failed: bool,
113 wildcard: Option<&WildcardAnswer>,
114) -> ClassifiedSubdomain {
115 let status = if addresses.is_empty() && lookup_failed {
116 SubdomainStatus::Unknown
119 } else if addresses.is_empty() {
120 SubdomainStatus::Dead
121 } else {
122 match wildcard {
123 Some(w) if cname_key(cname.as_deref()) == cname_key(w.cname.as_deref()) => {
124 SubdomainStatus::Wildcard
125 }
126 _ => SubdomainStatus::Live,
127 }
128 };
129
130 let takeover_risk = match status {
133 SubdomainStatus::Dead => provider.map(str::to_string),
134 _ => None,
135 };
136
137 ClassifiedSubdomain {
138 name,
139 status,
140 addresses,
141 cname,
142 takeover_risk,
143 }
144}
145
146fn parent_of(name: &str) -> Option<&str> {
149 name.split_once('.').map(|(_, parent)| parent)
150}
151
152async fn probe_parents(
156 resolver: &DnsResolver,
157 parents: BTreeSet<String>,
158 concurrency: usize,
159) -> HashMap<String, Option<WildcardAnswer>> {
160 use futures::stream::{self, StreamExt};
161
162 stream::iter(parents)
163 .map(|parent| async move {
164 let probe = format!("{}.{parent}", crate::dns::random_probe_label());
165 let r = resolve_host(resolver, &probe).await;
166 let answer = (!r.addresses.is_empty()).then_some(WildcardAnswer { cname: r.cname });
167 (parent, answer)
168 })
169 .buffer_unordered(concurrency)
170 .collect()
171 .await
172}
173
174pub async fn classify_subdomains(
181 resolver: &DnsResolver,
182 domain: &str,
183 mut names: Vec<String>,
184 concurrency: usize,
185) -> SubdomainClassification {
186 use futures::stream::{self, StreamExt};
187
188 let names_skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
192 let concurrency = concurrency.max(1);
193
194 let parents: BTreeSet<String> = names
197 .iter()
198 .filter_map(|n| parent_of(n))
199 .map(str::to_string)
200 .collect();
201 let wildcards = probe_parents(resolver, parents, concurrency).await;
202 let wildcard_detected = wildcards.values().any(Option::is_some);
203 let wildcards = &wildcards;
204
205 let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
206 .map(|name| async move {
207 let r = resolve_host(resolver, &name).await;
210 let failed = r.lookup_error.is_some();
211 let wildcard = parent_of(&name)
212 .and_then(|p| wildcards.get(p))
213 .and_then(Option::as_ref);
214 let provider = r.provider.map(|(p, _)| p.provider);
215 classify_one(name, r.addresses, r.cname, provider, failed, wildcard)
216 })
217 .buffer_unordered(concurrency)
218 .collect()
219 .await;
220
221 subdomains.sort_by(|a, b| {
224 let rank = |s: &ClassifiedSubdomain| {
225 if s.takeover_risk.is_some() {
226 0
227 } else if s.status == SubdomainStatus::Live {
228 1
229 } else {
230 2
231 }
232 };
233 rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
234 });
235
236 SubdomainClassification {
237 domain: domain.to_string(),
238 wildcard_detected,
239 subdomains,
240 names_skipped,
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::takeover::match_provider;
248
249 fn classify(
252 name: String,
253 addresses: Vec<String>,
254 cname: Option<String>,
255 lookup_failed: bool,
256 wildcard: Option<&WildcardAnswer>,
257 ) -> ClassifiedSubdomain {
258 let provider = cname
259 .as_deref()
260 .and_then(match_provider)
261 .map(|p| p.provider);
262 classify_one(name, addresses, cname, provider, lookup_failed, wildcard)
263 }
264
265 #[test]
268 fn classify_flags_a_provider_deeper_in_the_chain() {
269 let c = classify_one(
270 "shop.example.com".to_string(),
271 vec![],
272 Some("edge.example.net".to_string()),
273 Some("Heroku"),
274 false,
275 None,
276 );
277 assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
278 }
279
280 #[test]
285 fn classify_flags_every_takeover_provider() {
286 let dangling = |cname: &str| {
287 classify(
288 "gone.example.com".to_string(),
289 vec![],
290 Some(cname.to_string()),
291 false,
292 None,
293 )
294 .takeover_risk
295 };
296 for p in crate::takeover::PROVIDERS {
297 let suffixes = p.cname_suffixes.iter().map(|s| format!("gone{s}"));
298 let infixes = p.cname_infixes.iter().map(|i| format!("gone{i}.example"));
299 for cname in suffixes.chain(infixes) {
300 assert_eq!(dangling(&cname).as_deref(), Some(p.provider), "{cname}");
301 }
302 }
303 assert_eq!(dangling("cdn.example.com"), None);
305 assert_eq!(dangling("github.io"), None);
306 }
307
308 #[test]
309 fn classify_dead_cname_to_provider_is_takeover_risk() {
310 let c = classify(
311 "gone.example.com".to_string(),
312 vec![], Some("gone.herokuapp.com".to_string()),
314 false,
315 None,
316 );
317 assert_eq!(c.status, SubdomainStatus::Dead);
318 assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
319 }
320
321 #[test]
322 fn classify_failed_lookup_is_unknown_not_dangling() {
323 let c = classify(
326 "slow.example.com".to_string(),
327 vec![],
328 Some("slow.herokuapp.com".to_string()),
329 true,
330 None,
331 );
332 assert_eq!(c.status, SubdomainStatus::Unknown);
333 assert!(c.takeover_risk.is_none());
334 let c = classify(
336 "v6.example.com".to_string(),
337 vec!["2001:db8::1".to_string()],
338 None,
339 true,
340 None,
341 );
342 assert_eq!(c.status, SubdomainStatus::Live);
343 }
344
345 #[test]
346 fn classify_live_host_is_not_takeover_even_with_provider_cname() {
347 let c = classify(
349 "live.example.com".to_string(),
350 vec!["203.0.113.5".to_string()],
351 Some("live.herokuapp.com".to_string()),
352 false,
353 None,
354 );
355 assert_eq!(c.status, SubdomainStatus::Live);
356 assert!(c.takeover_risk.is_none());
357 }
358
359 #[test]
360 fn classify_under_a_wildcard_compares_presence_not_addresses() {
361 let wildcard = WildcardAnswer { cname: None };
364 let c = classify(
365 "anything.example.com".to_string(),
366 vec!["203.0.113.7".to_string()],
367 None,
368 false,
369 Some(&wildcard),
370 );
371 assert_eq!(c.status, SubdomainStatus::Wildcard);
372
373 let wildcard = WildcardAnswer {
375 cname: Some("edge.cdn.test.".to_string()),
376 };
377 let c = classify(
378 "x.example.com".to_string(),
379 vec!["203.0.113.7".to_string()],
380 Some("EDGE.cdn.test".to_string()),
381 false,
382 Some(&wildcard),
383 );
384 assert_eq!(c.status, SubdomainStatus::Wildcard);
385 }
386
387 #[test]
388 fn classify_a_distinct_alias_under_a_wildcard_is_live() {
389 let wildcard = WildcardAnswer { cname: None };
391 let c = classify(
392 "shop.example.com".to_string(),
393 vec!["203.0.113.7".to_string()],
394 Some("shops.myshopify.com".to_string()),
395 false,
396 Some(&wildcard),
397 );
398 assert_eq!(c.status, SubdomainStatus::Live);
399 let c = classify(
401 "real.example.com".to_string(),
402 vec!["203.0.113.7".to_string()],
403 None,
404 false,
405 None,
406 );
407 assert_eq!(c.status, SubdomainStatus::Live);
408 }
409
410 #[tokio::test]
414 async fn wildcards_below_the_apex_are_detected() {
415 use crate::dns::test_support::{
416 a_rdata, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
417 };
418 use hickory_resolver::proto::rr::RecordType as WireType;
419
420 let mut rotation = 10u8;
422 let port = spawn_mock_dns_fn(move |qname, qtype| {
423 let in_dev = qname.ends_with(".dev.seer.test");
424 match qtype {
425 WireType::A if in_dev => {
426 rotation += 1;
427 MockReply::Answer(vec![a_rdata([192, 0, 2, rotation])])
428 }
429 WireType::A if qname == "www.seer.test" => {
430 MockReply::Answer(vec![a_rdata([192, 0, 2, 1])])
431 }
432 _ if in_dev || qname == "www.seer.test" => MockReply::NoData,
433 _ => MockReply::NxDomain,
434 }
435 })
436 .await;
437 let report = classify_subdomains(
438 &mock_dns_resolver_default(port),
439 "seer.test",
440 vec!["api.dev.seer.test".to_string(), "www.seer.test".to_string()],
441 4,
442 )
443 .await;
444
445 assert!(report.wildcard_detected);
446 let status = |name: &str| {
447 report
448 .subdomains
449 .iter()
450 .find(|s| s.name == name)
451 .map(|s| s.status)
452 };
453 assert_eq!(status("api.dev.seer.test"), Some(SubdomainStatus::Wildcard));
454 assert_eq!(status("www.seer.test"), Some(SubdomainStatus::Live));
455 }
456
457 #[test]
458 fn classify_cap_keeps_first_n_and_reports_skipped() {
459 let over = MAX_CLASSIFY_NAMES + 37;
461 let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
462 let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
463 assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
464 assert_eq!(skipped, 37);
465 assert_eq!(names[0], "h0.example.com");
467 }
468
469 #[test]
470 fn classify_cap_is_noop_under_limit() {
471 let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
472 let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
473 assert_eq!(names.len(), 10);
474 assert_eq!(skipped, 0);
475 }
476}