use serde::{Deserialize, Serialize};
use crate::dns::{DnsResolver, RecordData, RecordType};
const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
const MAX_CLASSIFY_NAMES: usize = 2000;
const TAKEOVER_FINGERPRINTS: &[(&str, &str)] = &[
(".github.io", "GitHub Pages"),
(".herokuapp.com", "Heroku"),
(".herokudns.com", "Heroku"),
(".s3.amazonaws.com", "AWS S3"),
(".cloudfront.net", "AWS CloudFront"),
(".azurewebsites.net", "Azure App Service"),
(".cloudapp.net", "Azure Cloud Service"),
(".trafficmanager.net", "Azure Traffic Manager"),
(".blob.core.windows.net", "Azure Blob Storage"),
(".ghost.io", "Ghost"),
(".surge.sh", "Surge.sh"),
(".bitbucket.io", "Bitbucket"),
(".pantheonsite.io", "Pantheon"),
(".readthedocs.io", "Read the Docs"),
(".wpengine.com", "WP Engine"),
(".zendesk.com", "Zendesk"),
(".fastly.net", "Fastly"),
(".netlify.app", "Netlify"),
(".netlify.com", "Netlify"),
(".myshopify.com", "Shopify"),
(".statuspage.io", "Statuspage"),
(".unbouncepages.com", "Unbounce"),
(".helpscoutdocs.com", "Help Scout"),
(".launchrock.com", "LaunchRock"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SubdomainStatus {
Live,
Dead,
Wildcard,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassifiedSubdomain {
pub name: String,
pub status: SubdomainStatus,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub addresses: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_risk: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubdomainClassification {
pub domain: String,
pub wildcard_detected: bool,
pub subdomains: Vec<ClassifiedSubdomain>,
#[serde(default)]
pub names_skipped: usize,
}
fn takeover_provider(cname: &str) -> Option<&'static str> {
let c = cname.trim_end_matches('.').to_ascii_lowercase();
TAKEOVER_FINGERPRINTS
.iter()
.find(|(suffix, _)| c.ends_with(suffix))
.map(|(_, provider)| *provider)
}
fn extract_addresses(records: &[crate::dns::DnsRecord]) -> Vec<String> {
records
.iter()
.filter_map(|r| match &r.data {
RecordData::A { address } => Some(address.clone()),
RecordData::AAAA { address } => Some(address.clone()),
_ => None,
})
.collect()
}
fn extract_cname(records: &[crate::dns::DnsRecord]) -> Option<String> {
records.iter().find_map(|r| match &r.data {
RecordData::CNAME { target } => Some(target.clone()),
_ => None,
})
}
fn classify_one(
name: String,
addresses: Vec<String>,
cname: Option<String>,
wildcard_addrs: &[String],
) -> ClassifiedSubdomain {
let status = if addresses.is_empty() {
SubdomainStatus::Dead
} else if !wildcard_addrs.is_empty() && addresses.iter().all(|a| wildcard_addrs.contains(a)) {
SubdomainStatus::Wildcard
} else {
SubdomainStatus::Live
};
let takeover_risk = match (&cname, status) {
(Some(target), SubdomainStatus::Dead) => takeover_provider(target).map(str::to_string),
_ => None,
};
ClassifiedSubdomain {
name,
status,
addresses,
cname,
takeover_risk,
}
}
async fn resolve_name(resolver: &DnsResolver, name: &str) -> (Vec<String>, Option<String>) {
let (a, cname) = tokio::join!(
Box::pin(resolver.resolve(name, RecordType::A, None)),
Box::pin(resolver.resolve(name, RecordType::CNAME, None)),
);
let addresses = a.map(|r| extract_addresses(&r)).unwrap_or_default();
let cname = cname.ok().and_then(|r| extract_cname(&r));
(addresses, cname)
}
fn apply_classify_cap(names: &mut Vec<String>) -> usize {
let skipped = names.len().saturating_sub(MAX_CLASSIFY_NAMES);
if skipped > 0 {
names.truncate(MAX_CLASSIFY_NAMES);
}
skipped
}
pub async fn classify_subdomains(
resolver: &DnsResolver,
domain: &str,
mut names: Vec<String>,
concurrency: usize,
) -> SubdomainClassification {
use futures::stream::{self, StreamExt};
let names_skipped = apply_classify_cap(&mut names);
let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
let (wildcard_addrs, _) = resolve_name(resolver, &probe).await;
let wildcard_detected = !wildcard_addrs.is_empty();
let concurrency = concurrency.max(1);
let wildcard_addrs = std::sync::Arc::new(wildcard_addrs);
let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
.map(|name| {
let wildcard_addrs = wildcard_addrs.clone();
async move {
let (addresses, cname) = resolve_name(resolver, &name).await;
classify_one(name, addresses, cname, &wildcard_addrs)
}
})
.buffer_unordered(concurrency)
.collect()
.await;
subdomains.sort_by(|a, b| {
let rank = |s: &ClassifiedSubdomain| {
if s.takeover_risk.is_some() {
0
} else if s.status == SubdomainStatus::Live {
1
} else {
2
}
};
rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
});
SubdomainClassification {
domain: domain.to_string(),
wildcard_detected,
subdomains,
names_skipped,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn takeover_provider_matches_known_suffixes() {
assert_eq!(takeover_provider("myapp.herokuapp.com"), Some("Heroku"));
assert_eq!(takeover_provider("foo.github.io"), Some("GitHub Pages"));
assert_eq!(
takeover_provider("bucket.s3.amazonaws.com."),
Some("AWS S3")
);
assert_eq!(takeover_provider("cdn.example.com"), None);
assert_eq!(takeover_provider("github.io"), None);
}
#[test]
fn classify_dead_cname_to_provider_is_takeover_risk() {
let c = classify_one(
"gone.example.com".to_string(),
vec![], Some("gone.herokuapp.com".to_string()),
&[],
);
assert_eq!(c.status, SubdomainStatus::Dead);
assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
}
#[test]
fn classify_live_host_is_not_takeover_even_with_provider_cname() {
let c = classify_one(
"live.example.com".to_string(),
vec!["203.0.113.5".to_string()],
Some("live.herokuapp.com".to_string()),
&[],
);
assert_eq!(c.status, SubdomainStatus::Live);
assert!(c.takeover_risk.is_none());
}
#[test]
fn classify_wildcard_addresses_are_marked_wildcard() {
let wildcard = vec!["198.51.100.9".to_string()];
let c = classify_one(
"anything.example.com".to_string(),
vec!["198.51.100.9".to_string()],
None,
&wildcard,
);
assert_eq!(c.status, SubdomainStatus::Wildcard);
}
#[test]
fn classify_distinct_address_under_wildcard_is_live() {
let wildcard = vec!["198.51.100.9".to_string()];
let c = classify_one(
"real.example.com".to_string(),
vec!["203.0.113.7".to_string()],
None,
&wildcard,
);
assert_eq!(c.status, SubdomainStatus::Live);
}
#[test]
fn classify_cap_keeps_first_n_and_reports_skipped() {
let over = MAX_CLASSIFY_NAMES + 37;
let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
let skipped = apply_classify_cap(&mut names);
assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
assert_eq!(skipped, 37);
assert_eq!(names[0], "h0.example.com");
}
#[test]
fn classify_cap_is_noop_under_limit() {
let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
let skipped = apply_classify_cap(&mut names);
assert_eq!(names.len(), 10);
assert_eq!(skipped, 0);
}
}