use serde::{Deserialize, Serialize};
use crate::dns::DnsResolver;
use crate::takeover::{match_provider, resolve_host, truncate_to_cap};
const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
const MAX_CLASSIFY_NAMES: usize = 2000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SubdomainStatus {
Live,
Dead,
Wildcard,
Unknown,
}
#[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 classify_one(
name: String,
addresses: Vec<String>,
cname: Option<String>,
lookup_failed: bool,
wildcard_addrs: &[String],
) -> ClassifiedSubdomain {
let status = if addresses.is_empty() && lookup_failed {
SubdomainStatus::Unknown
} else 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) => {
match_provider(target).map(|p| p.provider.to_string())
}
_ => None,
};
ClassifiedSubdomain {
name,
status,
addresses,
cname,
takeover_risk,
}
}
pub async fn classify_subdomains(
resolver: &DnsResolver,
domain: &str,
mut names: Vec<String>,
concurrency: usize,
) -> SubdomainClassification {
use futures::stream::{self, StreamExt};
let names_skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
let wildcard_addrs = resolve_host(resolver, &probe).await.addresses;
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 r = resolve_host(resolver, &name).await;
let failed = r.lookup_error.is_some();
classify_one(name, r.addresses, r.cname, failed, &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 classify_flags_every_takeover_provider() {
let dangling = |cname: &str| {
classify_one(
"gone.example.com".to_string(),
vec![],
Some(cname.to_string()),
false,
&[],
)
.takeover_risk
};
for p in crate::takeover::PROVIDERS {
let suffixes = p.cname_suffixes.iter().map(|s| format!("gone{s}"));
let infixes = p.cname_infixes.iter().map(|i| format!("gone{i}.example"));
for cname in suffixes.chain(infixes) {
assert_eq!(dangling(&cname).as_deref(), Some(p.provider), "{cname}");
}
}
assert_eq!(dangling("cdn.example.com"), None);
assert_eq!(dangling("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()),
false,
&[],
);
assert_eq!(c.status, SubdomainStatus::Dead);
assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
}
#[test]
fn classify_failed_lookup_is_unknown_not_dangling() {
let c = classify_one(
"slow.example.com".to_string(),
vec![],
Some("slow.herokuapp.com".to_string()),
true,
&[],
);
assert_eq!(c.status, SubdomainStatus::Unknown);
assert!(c.takeover_risk.is_none());
let c = classify_one(
"v6.example.com".to_string(),
vec!["2001:db8::1".to_string()],
None,
true,
&[],
);
assert_eq!(c.status, SubdomainStatus::Live);
}
#[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()),
false,
&[],
);
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,
false,
&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,
false,
&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 = truncate_to_cap(&mut names, MAX_CLASSIFY_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 = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
assert_eq!(names.len(), 10);
assert_eq!(skipped, 0);
}
}