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, plus the
8//! provider table and host resolution of [`crate::takeover`] (the HTTP half of
9//! takeover detection), so both surfaces agree on what is takeover-prone.
10//!
11//! Wildcard DNS is detected per level: each distinct parent of a classified
12//! name (`dev.example.com` for `api.dev.example.com`) is probed once with a
13//! fresh random sibling (`seer-probe-<hex>`, the label `seer dig` uses), so a
14//! `*.dev.example.com` wildcard is caught as well as one at the apex, and a
15//! zone cannot special-case a fixed probe name.
16
17use std::collections::{BTreeSet, HashMap};
18
19use serde::{Deserialize, Serialize};
20
21use crate::dns::DnsResolver;
22use crate::takeover::{resolve_host, truncate_to_cap};
23
24/// What a random name directly under one parent resolved to, when it did:
25/// the level has a wildcard. Only its CNAME target matters — a CDN-backed
26/// wildcard rotates its addresses, so addresses are not compared.
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct WildcardAnswer {
29    cname: Option<String>,
30}
31
32/// Upper bound on the number of enumerated names that get resolved and
33/// classified in a single pass. CT logs can return tens of thousands of names
34/// for a large target, and each name issues two live DNS queries (A + CNAME);
35/// the `concurrency` limit caps parallelism but not total work. 2000 names
36/// (up to ~4000 queries) is a defensible ceiling that covers essentially every
37/// real zone while bounding the DNS fan-out. Names beyond the cap are reported
38/// as skipped rather than silently dropped. The public docs of
39/// [`classify_subdomains`] and [`SubdomainClassification::names_skipped`]
40/// state the value; keep them in step.
41const MAX_CLASSIFY_NAMES: usize = 2000;
42
43/// Liveness classification of a single subdomain.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SubdomainStatus {
47    /// Resolves, and is not explained by a wildcard at its level.
48    Live,
49    /// Does not resolve to any address.
50    Dead,
51    /// Resolves, but so does a random name at the same level (a zone
52    /// wildcard) with the same CNAME target (or none), so it cannot be told
53    /// apart from wildcard synthesis — probably not a distinct real host.
54    Wildcard,
55    /// The address lookup failed (timeout, SERVFAIL) rather than answering,
56    /// so whether the name is live is unknown. Never treated as dangling.
57    Unknown,
58}
59
60/// A subdomain annotated with resolution and takeover signal.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ClassifiedSubdomain {
63    pub name: String,
64    pub status: SubdomainStatus,
65    #[serde(skip_serializing_if = "Vec::is_empty")]
66    pub addresses: Vec<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub cname: Option<String>,
69    /// The provider name when this is a dangling CNAME to a takeover-prone
70    /// service (CNAME matches a fingerprint and the name does not resolve).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub takeover_risk: Option<String>,
73}
74
75/// The result of a classification pass over an enumerated subdomain list.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SubdomainClassification {
78    pub domain: String,
79    /// Whether a random nonexistent name answered at any probed level
80    /// (wildcard DNS at the apex or below it).
81    pub wildcard_detected: bool,
82    pub subdomains: Vec<ClassifiedSubdomain>,
83    /// Number of enumerated names dropped before classification because the
84    /// input exceeded the 2000-name cap. Zero when nothing was capped.
85    /// `#[serde(default)]` keeps older history files (without this field)
86    /// deserializable.
87    #[serde(default)]
88    pub names_skipped: usize,
89}
90
91/// A CNAME target in comparable form.
92fn cname_key(cname: Option<&str>) -> Option<String> {
93    cname.map(|c| c.trim_end_matches('.').to_ascii_lowercase())
94}
95
96/// Classifies a single name given what a random sibling at its level
97/// resolved to (`None`: no wildcard there). Pure given inputs.
98///
99/// Under a wildcard, a resolving name is `Wildcard` unless its CNAME target
100/// differs from the wildcard's — an explicitly configured alias — in which
101/// case it is `Live`. Addresses are deliberately not compared: a CDN-backed
102/// wildcard answers from a rotating pool, so an exact-IP match read every
103/// wildcard-synthesized name as live.
104///
105/// `provider` is the takeover-prone provider any hop of the name's CNAME
106/// chain points at (`resolve_host` walks the chain), not just the first.
107fn classify_one(
108    name: String,
109    addresses: Vec<String>,
110    cname: Option<String>,
111    provider: Option<&'static str>,
112    lookup_failed: bool,
113    wildcard: Option<&WildcardAnswer>,
114) -> ClassifiedSubdomain {
115    let status = if addresses.is_empty() && lookup_failed {
116        // No answer is not the same as "no records": a timed-out lookup must
117        // not be reported dead, nor flagged as a dangling takeover risk.
118        SubdomainStatus::Unknown
119    } else if addresses.is_empty() {
120        SubdomainStatus::Dead
121    } else {
122        match wildcard {
123            Some(w) if cname_key(cname.as_deref()) == cname_key(w.cname.as_deref()) => {
124                SubdomainStatus::Wildcard
125            }
126            _ => SubdomainStatus::Live,
127        }
128    };
129
130    // A dangling CNAME: points at a takeover-prone provider yet does not
131    // resolve to any address.
132    let takeover_risk = match status {
133        SubdomainStatus::Dead => provider.map(str::to_string),
134        _ => None,
135    };
136
137    ClassifiedSubdomain {
138        name,
139        status,
140        addresses,
141        cname,
142        takeover_risk,
143    }
144}
145
146/// The level a name's wildcard would sit at: its parent (`dev.example.com`
147/// for `api.dev.example.com`).
148fn parent_of(name: &str) -> Option<&str> {
149    name.split_once('.').map(|(_, parent)| parent)
150}
151
152/// Probes each parent once with a fresh random sibling, up to `concurrency`
153/// at a time. A parent maps to `Some` when its probe resolved (a wildcard at
154/// that level); a probe that failed or found nothing is `None`.
155async fn probe_parents(
156    resolver: &DnsResolver,
157    parents: BTreeSet<String>,
158    concurrency: usize,
159) -> HashMap<String, Option<WildcardAnswer>> {
160    use futures::stream::{self, StreamExt};
161
162    stream::iter(parents)
163        .map(|parent| async move {
164            let probe = format!("{}.{parent}", crate::dns::random_probe_label());
165            let r = resolve_host(resolver, &probe).await;
166            let answer = (!r.addresses.is_empty()).then_some(WildcardAnswer { cname: r.cname });
167            (parent, answer)
168        })
169        .buffer_unordered(concurrency)
170        .collect()
171        .await
172}
173
174/// Resolves and classifies each name in `names` for `domain`, detecting
175/// wildcard DNS at each name's level to suppress false-positive "live"
176/// verdicts and flagging dangling CNAMEs to takeover-prone providers. At most
177/// 2000 names are resolved; any beyond that are reported in
178/// [`SubdomainClassification::names_skipped`]. Runs up to `concurrency`
179/// resolutions at a time.
180pub async fn classify_subdomains(
181    resolver: &DnsResolver,
182    domain: &str,
183    mut names: Vec<String>,
184    concurrency: usize,
185) -> SubdomainClassification {
186    use futures::stream::{self, StreamExt};
187
188    // Cap total work: classify at most MAX_CLASSIFY_NAMES names (the caller has
189    // already deduped/sorted them via `build_result`), keeping the first N and
190    // reporting the remainder as skipped so the truncation is never silent.
191    let names_skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
192    let concurrency = concurrency.max(1);
193
194    // Probe every distinct level once (the apex for `api.example.com`,
195    // `dev.example.com` for `api.dev.example.com`).
196    let parents: BTreeSet<String> = names
197        .iter()
198        .filter_map(|n| parent_of(n))
199        .map(str::to_string)
200        .collect();
201    let wildcards = probe_parents(resolver, parents, concurrency).await;
202    let wildcard_detected = wildcards.values().any(Option::is_some);
203    let wildcards = &wildcards;
204
205    let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
206        .map(|name| async move {
207            // `lookup_error` is set only when no address answered and a
208            // lookup failed outright — exactly the "unknown" signal.
209            let r = resolve_host(resolver, &name).await;
210            let failed = r.lookup_error.is_some();
211            let wildcard = parent_of(&name)
212                .and_then(|p| wildcards.get(p))
213                .and_then(Option::as_ref);
214            let provider = r.provider.map(|(p, _)| p.provider);
215            classify_one(name, r.addresses, r.cname, provider, failed, wildcard)
216        })
217        .buffer_unordered(concurrency)
218        .collect()
219        .await;
220
221    // Stable, useful order: takeover risks first, then live, then the rest,
222    // alphabetical within each group.
223    subdomains.sort_by(|a, b| {
224        let rank = |s: &ClassifiedSubdomain| {
225            if s.takeover_risk.is_some() {
226                0
227            } else if s.status == SubdomainStatus::Live {
228                1
229            } else {
230                2
231            }
232        };
233        rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
234    });
235
236    SubdomainClassification {
237        domain: domain.to_string(),
238        wildcard_detected,
239        subdomains,
240        names_skipped,
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::takeover::match_provider;
248
249    /// `classify_one` with the provider of a one-hop chain, as
250    /// `resolve_host` would report it.
251    fn classify(
252        name: String,
253        addresses: Vec<String>,
254        cname: Option<String>,
255        lookup_failed: bool,
256        wildcard: Option<&WildcardAnswer>,
257    ) -> ClassifiedSubdomain {
258        let provider = cname
259            .as_deref()
260            .and_then(match_provider)
261            .map(|p| p.provider);
262        classify_one(name, addresses, cname, provider, lookup_failed, wildcard)
263    }
264
265    /// Regression: only the first CNAME hop was matched, so
266    /// `shop → edge.example.net → foo.herokudns.com` was never flagged.
267    #[test]
268    fn classify_flags_a_provider_deeper_in_the_chain() {
269        let c = classify_one(
270            "shop.example.com".to_string(),
271            vec![],
272            Some("edge.example.net".to_string()),
273            Some("Heroku"),
274            false,
275            None,
276        );
277        assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
278    }
279
280    /// Regression: classify used to keep its own provider table, which drifted
281    /// from `seer takeover`'s (no Tumblr, Webflow, S3 website endpoints, ...,
282    /// and `.cloudapp.net` mislabeled "Azure Cloud Service"). Every CNAME shape
283    /// the takeover table knows must flag here, under the same provider name.
284    #[test]
285    fn classify_flags_every_takeover_provider() {
286        let dangling = |cname: &str| {
287            classify(
288                "gone.example.com".to_string(),
289                vec![],
290                Some(cname.to_string()),
291                false,
292                None,
293            )
294            .takeover_risk
295        };
296        for p in crate::takeover::PROVIDERS {
297            let suffixes = p.cname_suffixes.iter().map(|s| format!("gone{s}"));
298            let infixes = p.cname_infixes.iter().map(|i| format!("gone{i}.example"));
299            for cname in suffixes.chain(infixes) {
300                assert_eq!(dangling(&cname).as_deref(), Some(p.provider), "{cname}");
301            }
302        }
303        // An unrelated target and a bare provider apex are not takeover-prone.
304        assert_eq!(dangling("cdn.example.com"), None);
305        assert_eq!(dangling("github.io"), None);
306    }
307
308    #[test]
309    fn classify_dead_cname_to_provider_is_takeover_risk() {
310        let c = classify(
311            "gone.example.com".to_string(),
312            vec![], // does not resolve
313            Some("gone.herokuapp.com".to_string()),
314            false,
315            None,
316        );
317        assert_eq!(c.status, SubdomainStatus::Dead);
318        assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
319    }
320
321    #[test]
322    fn classify_failed_lookup_is_unknown_not_dangling() {
323        // A timed-out lookup with a provider CNAME used to read as Dead + a
324        // takeover risk; an unanswered query proves nothing.
325        let c = classify(
326            "slow.example.com".to_string(),
327            vec![],
328            Some("slow.herokuapp.com".to_string()),
329            true,
330            None,
331        );
332        assert_eq!(c.status, SubdomainStatus::Unknown);
333        assert!(c.takeover_risk.is_none());
334        // If the other family answered, the host is simply live.
335        let c = classify(
336            "v6.example.com".to_string(),
337            vec!["2001:db8::1".to_string()],
338            None,
339            true,
340            None,
341        );
342        assert_eq!(c.status, SubdomainStatus::Live);
343    }
344
345    #[test]
346    fn classify_live_host_is_not_takeover_even_with_provider_cname() {
347        // Resolves to an address → not dangling, so no takeover flag.
348        let c = classify(
349            "live.example.com".to_string(),
350            vec!["203.0.113.5".to_string()],
351            Some("live.herokuapp.com".to_string()),
352            false,
353            None,
354        );
355        assert_eq!(c.status, SubdomainStatus::Live);
356        assert!(c.takeover_risk.is_none());
357    }
358
359    #[test]
360    fn classify_under_a_wildcard_compares_presence_not_addresses() {
361        // Regression: exact-IP equality read a CDN wildcard's rotating
362        // addresses as distinct live hosts.
363        let wildcard = WildcardAnswer { cname: None };
364        let c = classify(
365            "anything.example.com".to_string(),
366            vec!["203.0.113.7".to_string()],
367            None,
368            false,
369            Some(&wildcard),
370        );
371        assert_eq!(c.status, SubdomainStatus::Wildcard);
372
373        // Same wildcard CNAME target (case/trailing dot aside): synthesized.
374        let wildcard = WildcardAnswer {
375            cname: Some("edge.cdn.test.".to_string()),
376        };
377        let c = classify(
378            "x.example.com".to_string(),
379            vec!["203.0.113.7".to_string()],
380            Some("EDGE.cdn.test".to_string()),
381            false,
382            Some(&wildcard),
383        );
384        assert_eq!(c.status, SubdomainStatus::Wildcard);
385    }
386
387    #[test]
388    fn classify_a_distinct_alias_under_a_wildcard_is_live() {
389        // An explicitly configured CNAME the wildcard does not synthesize.
390        let wildcard = WildcardAnswer { cname: None };
391        let c = classify(
392            "shop.example.com".to_string(),
393            vec!["203.0.113.7".to_string()],
394            Some("shops.myshopify.com".to_string()),
395            false,
396            Some(&wildcard),
397        );
398        assert_eq!(c.status, SubdomainStatus::Live);
399        // No wildcard at the level: resolving is live.
400        let c = classify(
401            "real.example.com".to_string(),
402            vec!["203.0.113.7".to_string()],
403            None,
404            false,
405            None,
406        );
407        assert_eq!(c.status, SubdomainStatus::Live);
408    }
409
410    /// Regression: only the apex was probed, with a fixed label, so a
411    /// `*.dev.example.com` wildcard went unnoticed and every name under it
412    /// read Live. Each level is probed with its own random label.
413    #[tokio::test]
414    async fn wildcards_below_the_apex_are_detected() {
415        use crate::dns::test_support::{
416            a_rdata, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
417        };
418        use hickory_resolver::proto::rr::RecordType as WireType;
419
420        // The wildcard rotates its address on every answer.
421        let mut rotation = 10u8;
422        let port = spawn_mock_dns_fn(move |qname, qtype| {
423            let in_dev = qname.ends_with(".dev.seer.test");
424            match qtype {
425                WireType::A if in_dev => {
426                    rotation += 1;
427                    MockReply::Answer(vec![a_rdata([192, 0, 2, rotation])])
428                }
429                WireType::A if qname == "www.seer.test" => {
430                    MockReply::Answer(vec![a_rdata([192, 0, 2, 1])])
431                }
432                _ if in_dev || qname == "www.seer.test" => MockReply::NoData,
433                _ => MockReply::NxDomain,
434            }
435        })
436        .await;
437        let report = classify_subdomains(
438            &mock_dns_resolver_default(port),
439            "seer.test",
440            vec!["api.dev.seer.test".to_string(), "www.seer.test".to_string()],
441            4,
442        )
443        .await;
444
445        assert!(report.wildcard_detected);
446        let status = |name: &str| {
447            report
448                .subdomains
449                .iter()
450                .find(|s| s.name == name)
451                .map(|s| s.status)
452        };
453        assert_eq!(status("api.dev.seer.test"), Some(SubdomainStatus::Wildcard));
454        assert_eq!(status("www.seer.test"), Some(SubdomainStatus::Live));
455    }
456
457    #[test]
458    fn classify_cap_keeps_first_n_and_reports_skipped() {
459        // Over the cap: keep exactly MAX_CLASSIFY_NAMES, report the overflow.
460        let over = MAX_CLASSIFY_NAMES + 37;
461        let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
462        let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
463        assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
464        assert_eq!(skipped, 37);
465        // The first N (in the caller's already-sorted order) are the ones kept.
466        assert_eq!(names[0], "h0.example.com");
467    }
468
469    #[test]
470    fn classify_cap_is_noop_under_limit() {
471        let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
472        let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
473        assert_eq!(names.len(), 10);
474        assert_eq!(skipped, 0);
475    }
476}