1use serde::{Deserialize, Serialize};
11
12use crate::dns::{DnsResolver, RecordData, RecordType};
13
14const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
18
19const MAX_CLASSIFY_NAMES: usize = 2000;
27
28const TAKEOVER_FINGERPRINTS: &[(&str, &str)] = &[
32 (".github.io", "GitHub Pages"),
33 (".herokuapp.com", "Heroku"),
34 (".herokudns.com", "Heroku"),
35 (".s3.amazonaws.com", "AWS S3"),
36 (".cloudfront.net", "AWS CloudFront"),
37 (".azurewebsites.net", "Azure App Service"),
38 (".cloudapp.net", "Azure Cloud Service"),
39 (".trafficmanager.net", "Azure Traffic Manager"),
40 (".blob.core.windows.net", "Azure Blob Storage"),
41 (".ghost.io", "Ghost"),
42 (".surge.sh", "Surge.sh"),
43 (".bitbucket.io", "Bitbucket"),
44 (".pantheonsite.io", "Pantheon"),
45 (".readthedocs.io", "Read the Docs"),
46 (".wpengine.com", "WP Engine"),
47 (".zendesk.com", "Zendesk"),
48 (".fastly.net", "Fastly"),
49 (".netlify.app", "Netlify"),
50 (".netlify.com", "Netlify"),
51 (".myshopify.com", "Shopify"),
52 (".statuspage.io", "Statuspage"),
53 (".unbouncepages.com", "Unbounce"),
54 (".helpscoutdocs.com", "Help Scout"),
55 (".launchrock.com", "LaunchRock"),
56];
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum SubdomainStatus {
62 Live,
64 Dead,
66 Wildcard,
69 Unknown,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ClassifiedSubdomain {
77 pub name: String,
78 pub status: SubdomainStatus,
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub addresses: Vec<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub cname: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub takeover_risk: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SubdomainClassification {
92 pub domain: String,
93 pub wildcard_detected: bool,
95 pub subdomains: Vec<ClassifiedSubdomain>,
96 #[serde(default)]
101 pub names_skipped: usize,
102}
103
104fn takeover_provider(cname: &str) -> Option<&'static str> {
109 let c = cname.trim_end_matches('.').to_ascii_lowercase();
110 TAKEOVER_FINGERPRINTS
111 .iter()
112 .find(|(suffix, _)| c.ends_with(suffix))
113 .map(|(_, provider)| *provider)
114}
115
116fn extract_addresses(records: &[crate::dns::DnsRecord]) -> Vec<String> {
117 records
118 .iter()
119 .filter_map(|r| match &r.data {
120 RecordData::A { address } => Some(address.clone()),
121 RecordData::AAAA { address } => Some(address.clone()),
122 _ => None,
123 })
124 .collect()
125}
126
127fn extract_cname(records: &[crate::dns::DnsRecord]) -> Option<String> {
128 records.iter().find_map(|r| match &r.data {
129 RecordData::CNAME { target } => Some(target.clone()),
130 _ => None,
131 })
132}
133
134fn classify_one(
136 name: String,
137 addresses: Vec<String>,
138 cname: Option<String>,
139 lookup_failed: bool,
140 wildcard_addrs: &[String],
141) -> ClassifiedSubdomain {
142 let status = if addresses.is_empty() && lookup_failed {
143 SubdomainStatus::Unknown
146 } else if addresses.is_empty() {
147 SubdomainStatus::Dead
148 } else if !wildcard_addrs.is_empty() && addresses.iter().all(|a| wildcard_addrs.contains(a)) {
149 SubdomainStatus::Wildcard
150 } else {
151 SubdomainStatus::Live
152 };
153
154 let takeover_risk = match (&cname, status) {
157 (Some(target), SubdomainStatus::Dead) => takeover_provider(target).map(str::to_string),
158 _ => None,
159 };
160
161 ClassifiedSubdomain {
162 name,
163 status,
164 addresses,
165 cname,
166 takeover_risk,
167 }
168}
169
170async fn resolve_name(resolver: &DnsResolver, name: &str) -> (Vec<String>, Option<String>, bool) {
177 let (a, aaaa, cname) = tokio::join!(
178 Box::pin(resolver.resolve(name, RecordType::A, None)),
179 Box::pin(resolver.resolve(name, RecordType::AAAA, None)),
180 Box::pin(resolver.resolve(name, RecordType::CNAME, None)),
181 );
182 let lookup_failed = a.is_err() || aaaa.is_err();
183 let mut addresses = a.map(|r| extract_addresses(&r)).unwrap_or_default();
184 addresses.extend(aaaa.map(|r| extract_addresses(&r)).unwrap_or_default());
185 let cname = cname.ok().and_then(|r| extract_cname(&r));
186 (addresses, cname, lookup_failed)
187}
188
189fn apply_classify_cap(names: &mut Vec<String>) -> usize {
192 let skipped = names.len().saturating_sub(MAX_CLASSIFY_NAMES);
193 if skipped > 0 {
194 names.truncate(MAX_CLASSIFY_NAMES);
195 }
196 skipped
197}
198
199pub async fn classify_subdomains(
206 resolver: &DnsResolver,
207 domain: &str,
208 mut names: Vec<String>,
209 concurrency: usize,
210) -> SubdomainClassification {
211 use futures::stream::{self, StreamExt};
212
213 let names_skipped = apply_classify_cap(&mut names);
217
218 let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
220 let (wildcard_addrs, _, _) = resolve_name(resolver, &probe).await;
221 let wildcard_detected = !wildcard_addrs.is_empty();
222
223 let concurrency = concurrency.max(1);
224 let wildcard_addrs = std::sync::Arc::new(wildcard_addrs);
225
226 let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
227 .map(|name| {
228 let wildcard_addrs = wildcard_addrs.clone();
229 async move {
230 let (addresses, cname, lookup_failed) = resolve_name(resolver, &name).await;
231 classify_one(name, addresses, cname, lookup_failed, &wildcard_addrs)
232 }
233 })
234 .buffer_unordered(concurrency)
235 .collect()
236 .await;
237
238 subdomains.sort_by(|a, b| {
241 let rank = |s: &ClassifiedSubdomain| {
242 if s.takeover_risk.is_some() {
243 0
244 } else if s.status == SubdomainStatus::Live {
245 1
246 } else {
247 2
248 }
249 };
250 rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
251 });
252
253 SubdomainClassification {
254 domain: domain.to_string(),
255 wildcard_detected,
256 subdomains,
257 names_skipped,
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn takeover_provider_matches_known_suffixes() {
267 assert_eq!(takeover_provider("myapp.herokuapp.com"), Some("Heroku"));
268 assert_eq!(takeover_provider("foo.github.io"), Some("GitHub Pages"));
269 assert_eq!(
270 takeover_provider("bucket.s3.amazonaws.com."),
271 Some("AWS S3")
272 );
273 assert_eq!(takeover_provider("cdn.example.com"), None);
275 assert_eq!(takeover_provider("github.io"), None);
277 }
278
279 #[test]
280 fn classify_dead_cname_to_provider_is_takeover_risk() {
281 let c = classify_one(
282 "gone.example.com".to_string(),
283 vec![], Some("gone.herokuapp.com".to_string()),
285 false,
286 &[],
287 );
288 assert_eq!(c.status, SubdomainStatus::Dead);
289 assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
290 }
291
292 #[test]
293 fn classify_failed_lookup_is_unknown_not_dangling() {
294 let c = classify_one(
297 "slow.example.com".to_string(),
298 vec![],
299 Some("slow.herokuapp.com".to_string()),
300 true,
301 &[],
302 );
303 assert_eq!(c.status, SubdomainStatus::Unknown);
304 assert!(c.takeover_risk.is_none());
305 let c = classify_one(
307 "v6.example.com".to_string(),
308 vec!["2001:db8::1".to_string()],
309 None,
310 true,
311 &[],
312 );
313 assert_eq!(c.status, SubdomainStatus::Live);
314 }
315
316 #[test]
317 fn classify_live_host_is_not_takeover_even_with_provider_cname() {
318 let c = classify_one(
320 "live.example.com".to_string(),
321 vec!["203.0.113.5".to_string()],
322 Some("live.herokuapp.com".to_string()),
323 false,
324 &[],
325 );
326 assert_eq!(c.status, SubdomainStatus::Live);
327 assert!(c.takeover_risk.is_none());
328 }
329
330 #[test]
331 fn classify_wildcard_addresses_are_marked_wildcard() {
332 let wildcard = vec!["198.51.100.9".to_string()];
333 let c = classify_one(
334 "anything.example.com".to_string(),
335 vec!["198.51.100.9".to_string()],
336 None,
337 false,
338 &wildcard,
339 );
340 assert_eq!(c.status, SubdomainStatus::Wildcard);
341 }
342
343 #[test]
344 fn classify_distinct_address_under_wildcard_is_live() {
345 let wildcard = vec!["198.51.100.9".to_string()];
346 let c = classify_one(
347 "real.example.com".to_string(),
348 vec!["203.0.113.7".to_string()],
349 None,
350 false,
351 &wildcard,
352 );
353 assert_eq!(c.status, SubdomainStatus::Live);
354 }
355
356 #[test]
357 fn classify_cap_keeps_first_n_and_reports_skipped() {
358 let over = MAX_CLASSIFY_NAMES + 37;
360 let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
361 let skipped = apply_classify_cap(&mut names);
362 assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
363 assert_eq!(skipped, 37);
364 assert_eq!(names[0], "h0.example.com");
366 }
367
368 #[test]
369 fn classify_cap_is_noop_under_limit() {
370 let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
371 let skipped = apply_classify_cap(&mut names);
372 assert_eq!(names.len(), 10);
373 assert_eq!(skipped, 0);
374 }
375}