Skip to main content

seer_core/subdomains/
classify.rs

1//! Optional resolve-and-classify pass over enumerated subdomains.
2//!
3//! CT-log enumeration returns a flat name list with no signal about which
4//! hosts are alive or hijackable. This stage resolves each name, marks it
5//! live / dead / wildcard, and flags dangling CNAMEs that point at
6//! takeover-prone providers — turning a name dump into an attack-surface
7//! report. It reuses the existing [`DnsResolver`] and anti-SSRF path; only the
8//! resolution is async, while the takeover fingerprinting is pure and tested.
9
10use serde::{Deserialize, Serialize};
11
12use crate::dns::{DnsResolver, RecordData, RecordType};
13
14/// Label prepended to the domain to probe for wildcard DNS. If this
15/// almost-certainly-nonexistent name resolves, the zone has a wildcard record
16/// and per-name "live" verdicts based on an A record alone are unreliable.
17const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
18
19/// Upper bound on the number of enumerated names that get resolved and
20/// classified in a single pass. CT logs can return tens of thousands of names
21/// for a large target, and each name issues two live DNS queries (A + CNAME);
22/// the `concurrency` limit caps parallelism but not total work. 2000 names
23/// (up to ~4000 queries) is a defensible ceiling that covers essentially every
24/// real zone while bounding the DNS fan-out. Names beyond the cap are reported
25/// as skipped rather than silently dropped.
26const MAX_CLASSIFY_NAMES: usize = 2000;
27
28/// Curated CNAME-suffix → provider table for subdomain-takeover detection.
29/// A dangling CNAME to one of these (whose target no longer resolves) is a
30/// resource an attacker can often re-claim.
31const 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/// Liveness classification of a single subdomain.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum SubdomainStatus {
62    /// Resolves to one or more addresses distinct from the wildcard set.
63    Live,
64    /// Does not resolve to any address.
65    Dead,
66    /// Only resolves via a zone wildcard (its addresses match the wildcard
67    /// probe), so it is probably not a distinct real host.
68    Wildcard,
69}
70
71/// A subdomain annotated with resolution and takeover signal.
72#[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    /// The provider name when this is a dangling CNAME to a takeover-prone
81    /// service (CNAME matches a fingerprint and the name does not resolve).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub takeover_risk: Option<String>,
84}
85
86/// The result of a classification pass over an enumerated subdomain list.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SubdomainClassification {
89    pub domain: String,
90    /// Whether the zone answers for a random nonexistent name (wildcard DNS).
91    pub wildcard_detected: bool,
92    pub subdomains: Vec<ClassifiedSubdomain>,
93    /// Number of enumerated names dropped before classification because the
94    /// input exceeded [`MAX_CLASSIFY_NAMES`]. Zero when nothing was capped.
95    /// `#[serde(default)]` keeps older history files (without this field)
96    /// deserializable.
97    #[serde(default)]
98    pub names_skipped: usize,
99}
100
101/// Returns the takeover-prone provider for a CNAME target, or `None`.
102///
103/// The apex of a provider domain (e.g. `github.io` itself) does not match —
104/// only a name *under* it — because the fingerprints carry a leading dot.
105fn 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
131/// Classifies a single name given the wildcard address set (pure given inputs).
132fn 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    // A dangling CNAME: points at a takeover-prone provider yet does not
147    // resolve to any address.
148    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
162/// Resolves A/AAAA and CNAME for `name`.
163async 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
173/// Truncates `names` to at most [`MAX_CLASSIFY_NAMES`] in place and returns how
174/// many were dropped. Pure so the cap can be unit-tested without a resolver.
175fn 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
183/// Resolves and classifies each name in `names` for `domain`, detecting
184/// wildcard DNS to suppress false-positive "live" verdicts and flagging
185/// dangling CNAMEs to takeover-prone providers. At most [`MAX_CLASSIFY_NAMES`]
186/// names are resolved (see `apply_classify_cap`); any beyond that are reported
187/// in [`SubdomainClassification::names_skipped`]. Runs up to `concurrency`
188/// resolutions at a time.
189pub 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    // Cap total work: classify at most MAX_CLASSIFY_NAMES names (the caller has
198    // already deduped/sorted them via `build_result`), keeping the first N and
199    // reporting the remainder as skipped so the truncation is never silent.
200    let names_skipped = apply_classify_cap(&mut names);
201
202    // Probe for wildcard DNS once.
203    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    // Stable, useful order: takeover risks first, then live, then the rest,
223    // alphabetical within each group.
224    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        // Unrelated CNAME target → no match.
258        assert_eq!(takeover_provider("cdn.example.com"), None);
259        // The provider apex itself is not a takeover (needs a label under it).
260        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![], // does not resolve
268            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        // Resolves to an address → not dangling, so no takeover flag.
278        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        // Over the cap: keep exactly MAX_CLASSIFY_NAMES, report the overflow.
315        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        // The first N (in the caller's already-sorted order) are the ones kept.
321        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}