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    /// The address lookup failed (timeout, SERVFAIL) rather than answering,
70    /// so whether the name is live is unknown. Never treated as dangling.
71    Unknown,
72}
73
74/// A subdomain annotated with resolution and takeover signal.
75#[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    /// The provider name when this is a dangling CNAME to a takeover-prone
84    /// service (CNAME matches a fingerprint and the name does not resolve).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub takeover_risk: Option<String>,
87}
88
89/// The result of a classification pass over an enumerated subdomain list.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SubdomainClassification {
92    pub domain: String,
93    /// Whether the zone answers for a random nonexistent name (wildcard DNS).
94    pub wildcard_detected: bool,
95    pub subdomains: Vec<ClassifiedSubdomain>,
96    /// Number of enumerated names dropped before classification because the
97    /// input exceeded [`MAX_CLASSIFY_NAMES`]. Zero when nothing was capped.
98    /// `#[serde(default)]` keeps older history files (without this field)
99    /// deserializable.
100    #[serde(default)]
101    pub names_skipped: usize,
102}
103
104/// Returns the takeover-prone provider for a CNAME target, or `None`.
105///
106/// The apex of a provider domain (e.g. `github.io` itself) does not match —
107/// only a name *under* it — because the fingerprints carry a leading dot.
108fn 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
134/// Classifies a single name given the wildcard address set (pure given inputs).
135fn 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        // No answer is not the same as "no records": a timed-out lookup must
144        // not be reported dead, nor flagged as a dangling takeover risk.
145        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    // A dangling CNAME: points at a takeover-prone provider yet does not
155    // resolve to any address.
156    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
170/// Resolves A/AAAA and CNAME for `name`.
171///
172/// Returns the addresses, the CNAME target, and whether an address lookup
173/// failed outright. `resolve` maps NXDOMAIN/NODATA to an empty `Ok`, so an
174/// `Err` is a genuine failure to get an answer, which callers must not read
175/// as "does not resolve".
176async 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
189/// Truncates `names` to at most [`MAX_CLASSIFY_NAMES`] in place and returns how
190/// many were dropped. Pure so the cap can be unit-tested without a resolver.
191fn 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
199/// Resolves and classifies each name in `names` for `domain`, detecting
200/// wildcard DNS to suppress false-positive "live" verdicts and flagging
201/// dangling CNAMEs to takeover-prone providers. At most [`MAX_CLASSIFY_NAMES`]
202/// names are resolved (see `apply_classify_cap`); any beyond that are reported
203/// in [`SubdomainClassification::names_skipped`]. Runs up to `concurrency`
204/// resolutions at a time.
205pub 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    // Cap total work: classify at most MAX_CLASSIFY_NAMES names (the caller has
214    // already deduped/sorted them via `build_result`), keeping the first N and
215    // reporting the remainder as skipped so the truncation is never silent.
216    let names_skipped = apply_classify_cap(&mut names);
217
218    // Probe for wildcard DNS once.
219    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    // Stable, useful order: takeover risks first, then live, then the rest,
239    // alphabetical within each group.
240    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        // Unrelated CNAME target → no match.
274        assert_eq!(takeover_provider("cdn.example.com"), None);
275        // The provider apex itself is not a takeover (needs a label under it).
276        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![], // does not resolve
284            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        // A timed-out lookup with a provider CNAME used to read as Dead + a
295        // takeover risk; an unanswered query proves nothing.
296        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        // If the other family answered, the host is simply live.
306        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        // Resolves to an address → not dangling, so no takeover flag.
319        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        // Over the cap: keep exactly MAX_CLASSIFY_NAMES, report the overflow.
359        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        // The first N (in the caller's already-sorted order) are the ones kept.
365        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}