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}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ClassifiedSubdomain {
74 pub name: String,
75 pub status: SubdomainStatus,
76 #[serde(skip_serializing_if = "Vec::is_empty")]
77 pub addresses: Vec<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub cname: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 pub takeover_risk: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SubdomainClassification {
89 pub domain: String,
90 pub wildcard_detected: bool,
92 pub subdomains: Vec<ClassifiedSubdomain>,
93 #[serde(default)]
98 pub names_skipped: usize,
99}
100
101fn takeover_provider(cname: &str) -> Option<&'static str> {
106 let c = cname.trim_end_matches('.').to_ascii_lowercase();
107 TAKEOVER_FINGERPRINTS
108 .iter()
109 .find(|(suffix, _)| c.ends_with(suffix))
110 .map(|(_, provider)| *provider)
111}
112
113fn extract_addresses(records: &[crate::dns::DnsRecord]) -> Vec<String> {
114 records
115 .iter()
116 .filter_map(|r| match &r.data {
117 RecordData::A { address } => Some(address.clone()),
118 RecordData::AAAA { address } => Some(address.clone()),
119 _ => None,
120 })
121 .collect()
122}
123
124fn extract_cname(records: &[crate::dns::DnsRecord]) -> Option<String> {
125 records.iter().find_map(|r| match &r.data {
126 RecordData::CNAME { target } => Some(target.clone()),
127 _ => None,
128 })
129}
130
131fn classify_one(
133 name: String,
134 addresses: Vec<String>,
135 cname: Option<String>,
136 wildcard_addrs: &[String],
137) -> ClassifiedSubdomain {
138 let status = if addresses.is_empty() {
139 SubdomainStatus::Dead
140 } else if !wildcard_addrs.is_empty() && addresses.iter().all(|a| wildcard_addrs.contains(a)) {
141 SubdomainStatus::Wildcard
142 } else {
143 SubdomainStatus::Live
144 };
145
146 let takeover_risk = match (&cname, status) {
149 (Some(target), SubdomainStatus::Dead) => takeover_provider(target).map(str::to_string),
150 _ => None,
151 };
152
153 ClassifiedSubdomain {
154 name,
155 status,
156 addresses,
157 cname,
158 takeover_risk,
159 }
160}
161
162async fn resolve_name(resolver: &DnsResolver, name: &str) -> (Vec<String>, Option<String>) {
164 let (a, cname) = tokio::join!(
165 Box::pin(resolver.resolve(name, RecordType::A, None)),
166 Box::pin(resolver.resolve(name, RecordType::CNAME, None)),
167 );
168 let addresses = a.map(|r| extract_addresses(&r)).unwrap_or_default();
169 let cname = cname.ok().and_then(|r| extract_cname(&r));
170 (addresses, cname)
171}
172
173fn apply_classify_cap(names: &mut Vec<String>) -> usize {
176 let skipped = names.len().saturating_sub(MAX_CLASSIFY_NAMES);
177 if skipped > 0 {
178 names.truncate(MAX_CLASSIFY_NAMES);
179 }
180 skipped
181}
182
183pub async fn classify_subdomains(
190 resolver: &DnsResolver,
191 domain: &str,
192 mut names: Vec<String>,
193 concurrency: usize,
194) -> SubdomainClassification {
195 use futures::stream::{self, StreamExt};
196
197 let names_skipped = apply_classify_cap(&mut names);
201
202 let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
204 let (wildcard_addrs, _) = resolve_name(resolver, &probe).await;
205 let wildcard_detected = !wildcard_addrs.is_empty();
206
207 let concurrency = concurrency.max(1);
208 let wildcard_addrs = std::sync::Arc::new(wildcard_addrs);
209
210 let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
211 .map(|name| {
212 let wildcard_addrs = wildcard_addrs.clone();
213 async move {
214 let (addresses, cname) = resolve_name(resolver, &name).await;
215 classify_one(name, addresses, cname, &wildcard_addrs)
216 }
217 })
218 .buffer_unordered(concurrency)
219 .collect()
220 .await;
221
222 subdomains.sort_by(|a, b| {
225 let rank = |s: &ClassifiedSubdomain| {
226 if s.takeover_risk.is_some() {
227 0
228 } else if s.status == SubdomainStatus::Live {
229 1
230 } else {
231 2
232 }
233 };
234 rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
235 });
236
237 SubdomainClassification {
238 domain: domain.to_string(),
239 wildcard_detected,
240 subdomains,
241 names_skipped,
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn takeover_provider_matches_known_suffixes() {
251 assert_eq!(takeover_provider("myapp.herokuapp.com"), Some("Heroku"));
252 assert_eq!(takeover_provider("foo.github.io"), Some("GitHub Pages"));
253 assert_eq!(
254 takeover_provider("bucket.s3.amazonaws.com."),
255 Some("AWS S3")
256 );
257 assert_eq!(takeover_provider("cdn.example.com"), None);
259 assert_eq!(takeover_provider("github.io"), None);
261 }
262
263 #[test]
264 fn classify_dead_cname_to_provider_is_takeover_risk() {
265 let c = classify_one(
266 "gone.example.com".to_string(),
267 vec![], Some("gone.herokuapp.com".to_string()),
269 &[],
270 );
271 assert_eq!(c.status, SubdomainStatus::Dead);
272 assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
273 }
274
275 #[test]
276 fn classify_live_host_is_not_takeover_even_with_provider_cname() {
277 let c = classify_one(
279 "live.example.com".to_string(),
280 vec!["203.0.113.5".to_string()],
281 Some("live.herokuapp.com".to_string()),
282 &[],
283 );
284 assert_eq!(c.status, SubdomainStatus::Live);
285 assert!(c.takeover_risk.is_none());
286 }
287
288 #[test]
289 fn classify_wildcard_addresses_are_marked_wildcard() {
290 let wildcard = vec!["198.51.100.9".to_string()];
291 let c = classify_one(
292 "anything.example.com".to_string(),
293 vec!["198.51.100.9".to_string()],
294 None,
295 &wildcard,
296 );
297 assert_eq!(c.status, SubdomainStatus::Wildcard);
298 }
299
300 #[test]
301 fn classify_distinct_address_under_wildcard_is_live() {
302 let wildcard = vec!["198.51.100.9".to_string()];
303 let c = classify_one(
304 "real.example.com".to_string(),
305 vec!["203.0.113.7".to_string()],
306 None,
307 &wildcard,
308 );
309 assert_eq!(c.status, SubdomainStatus::Live);
310 }
311
312 #[test]
313 fn classify_cap_keeps_first_n_and_reports_skipped() {
314 let over = MAX_CLASSIFY_NAMES + 37;
316 let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
317 let skipped = apply_classify_cap(&mut names);
318 assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
319 assert_eq!(skipped, 37);
320 assert_eq!(names[0], "h0.example.com");
322 }
323
324 #[test]
325 fn classify_cap_is_noop_under_limit() {
326 let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
327 let skipped = apply_classify_cap(&mut names);
328 assert_eq!(names.len(), 10);
329 assert_eq!(skipped, 0);
330 }
331}