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
11use serde::{Deserialize, Serialize};
12
13use crate::dns::DnsResolver;
14use crate::takeover::{match_provider, resolve_host, truncate_to_cap};
15
16/// Label prepended to the domain to probe for wildcard DNS. If this
17/// almost-certainly-nonexistent name resolves, the zone has a wildcard record
18/// and per-name "live" verdicts based on an A record alone are unreliable.
19const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
20
21/// Upper bound on the number of enumerated names that get resolved and
22/// classified in a single pass. CT logs can return tens of thousands of names
23/// for a large target, and each name issues two live DNS queries (A + CNAME);
24/// the `concurrency` limit caps parallelism but not total work. 2000 names
25/// (up to ~4000 queries) is a defensible ceiling that covers essentially every
26/// real zone while bounding the DNS fan-out. Names beyond the cap are reported
27/// as skipped rather than silently dropped. The public docs of
28/// [`classify_subdomains`] and [`SubdomainClassification::names_skipped`]
29/// state the value; keep them in step.
30const MAX_CLASSIFY_NAMES: usize = 2000;
31
32/// Liveness classification of a single subdomain.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum SubdomainStatus {
36    /// Resolves to one or more addresses distinct from the wildcard set.
37    Live,
38    /// Does not resolve to any address.
39    Dead,
40    /// Only resolves via a zone wildcard (its addresses match the wildcard
41    /// probe), so it is probably not a distinct real host.
42    Wildcard,
43    /// The address lookup failed (timeout, SERVFAIL) rather than answering,
44    /// so whether the name is live is unknown. Never treated as dangling.
45    Unknown,
46}
47
48/// A subdomain annotated with resolution and takeover signal.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClassifiedSubdomain {
51    pub name: String,
52    pub status: SubdomainStatus,
53    #[serde(skip_serializing_if = "Vec::is_empty")]
54    pub addresses: Vec<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub cname: Option<String>,
57    /// The provider name when this is a dangling CNAME to a takeover-prone
58    /// service (CNAME matches a fingerprint and the name does not resolve).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub takeover_risk: Option<String>,
61}
62
63/// The result of a classification pass over an enumerated subdomain list.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SubdomainClassification {
66    pub domain: String,
67    /// Whether the zone answers for a random nonexistent name (wildcard DNS).
68    pub wildcard_detected: bool,
69    pub subdomains: Vec<ClassifiedSubdomain>,
70    /// Number of enumerated names dropped before classification because the
71    /// input exceeded the 2000-name cap. Zero when nothing was capped.
72    /// `#[serde(default)]` keeps older history files (without this field)
73    /// deserializable.
74    #[serde(default)]
75    pub names_skipped: usize,
76}
77
78/// Classifies a single name given the wildcard address set (pure given inputs).
79fn classify_one(
80    name: String,
81    addresses: Vec<String>,
82    cname: Option<String>,
83    lookup_failed: bool,
84    wildcard_addrs: &[String],
85) -> ClassifiedSubdomain {
86    let status = if addresses.is_empty() && lookup_failed {
87        // No answer is not the same as "no records": a timed-out lookup must
88        // not be reported dead, nor flagged as a dangling takeover risk.
89        SubdomainStatus::Unknown
90    } else if addresses.is_empty() {
91        SubdomainStatus::Dead
92    } else if !wildcard_addrs.is_empty() && addresses.iter().all(|a| wildcard_addrs.contains(a)) {
93        SubdomainStatus::Wildcard
94    } else {
95        SubdomainStatus::Live
96    };
97
98    // A dangling CNAME: points at a takeover-prone provider yet does not
99    // resolve to any address.
100    let takeover_risk = match (&cname, status) {
101        (Some(target), SubdomainStatus::Dead) => {
102            match_provider(target).map(|p| p.provider.to_string())
103        }
104        _ => None,
105    };
106
107    ClassifiedSubdomain {
108        name,
109        status,
110        addresses,
111        cname,
112        takeover_risk,
113    }
114}
115
116/// Resolves and classifies each name in `names` for `domain`, detecting
117/// wildcard DNS to suppress false-positive "live" verdicts and flagging
118/// dangling CNAMEs to takeover-prone providers. At most 2000 names are
119/// resolved; any beyond that are reported in
120/// [`SubdomainClassification::names_skipped`]. Runs up to `concurrency`
121/// resolutions at a time.
122pub async fn classify_subdomains(
123    resolver: &DnsResolver,
124    domain: &str,
125    mut names: Vec<String>,
126    concurrency: usize,
127) -> SubdomainClassification {
128    use futures::stream::{self, StreamExt};
129
130    // Cap total work: classify at most MAX_CLASSIFY_NAMES names (the caller has
131    // already deduped/sorted them via `build_result`), keeping the first N and
132    // reporting the remainder as skipped so the truncation is never silent.
133    let names_skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
134
135    // Probe for wildcard DNS once.
136    let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
137    let wildcard_addrs = resolve_host(resolver, &probe).await.addresses;
138    let wildcard_detected = !wildcard_addrs.is_empty();
139
140    let concurrency = concurrency.max(1);
141    let wildcard_addrs = std::sync::Arc::new(wildcard_addrs);
142
143    let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
144        .map(|name| {
145            let wildcard_addrs = wildcard_addrs.clone();
146            async move {
147                // `lookup_error` is set only when no address answered and a
148                // lookup failed outright — exactly the "unknown" signal.
149                let r = resolve_host(resolver, &name).await;
150                let failed = r.lookup_error.is_some();
151                classify_one(name, r.addresses, r.cname, failed, &wildcard_addrs)
152            }
153        })
154        .buffer_unordered(concurrency)
155        .collect()
156        .await;
157
158    // Stable, useful order: takeover risks first, then live, then the rest,
159    // alphabetical within each group.
160    subdomains.sort_by(|a, b| {
161        let rank = |s: &ClassifiedSubdomain| {
162            if s.takeover_risk.is_some() {
163                0
164            } else if s.status == SubdomainStatus::Live {
165                1
166            } else {
167                2
168            }
169        };
170        rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
171    });
172
173    SubdomainClassification {
174        domain: domain.to_string(),
175        wildcard_detected,
176        subdomains,
177        names_skipped,
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    /// Regression: classify used to keep its own provider table, which drifted
186    /// from `seer takeover`'s (no Tumblr, Webflow, S3 website endpoints, ...,
187    /// and `.cloudapp.net` mislabeled "Azure Cloud Service"). Every CNAME shape
188    /// the takeover table knows must flag here, under the same provider name.
189    #[test]
190    fn classify_flags_every_takeover_provider() {
191        let dangling = |cname: &str| {
192            classify_one(
193                "gone.example.com".to_string(),
194                vec![],
195                Some(cname.to_string()),
196                false,
197                &[],
198            )
199            .takeover_risk
200        };
201        for p in crate::takeover::PROVIDERS {
202            let suffixes = p.cname_suffixes.iter().map(|s| format!("gone{s}"));
203            let infixes = p.cname_infixes.iter().map(|i| format!("gone{i}.example"));
204            for cname in suffixes.chain(infixes) {
205                assert_eq!(dangling(&cname).as_deref(), Some(p.provider), "{cname}");
206            }
207        }
208        // An unrelated target and a bare provider apex are not takeover-prone.
209        assert_eq!(dangling("cdn.example.com"), None);
210        assert_eq!(dangling("github.io"), None);
211    }
212
213    #[test]
214    fn classify_dead_cname_to_provider_is_takeover_risk() {
215        let c = classify_one(
216            "gone.example.com".to_string(),
217            vec![], // does not resolve
218            Some("gone.herokuapp.com".to_string()),
219            false,
220            &[],
221        );
222        assert_eq!(c.status, SubdomainStatus::Dead);
223        assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
224    }
225
226    #[test]
227    fn classify_failed_lookup_is_unknown_not_dangling() {
228        // A timed-out lookup with a provider CNAME used to read as Dead + a
229        // takeover risk; an unanswered query proves nothing.
230        let c = classify_one(
231            "slow.example.com".to_string(),
232            vec![],
233            Some("slow.herokuapp.com".to_string()),
234            true,
235            &[],
236        );
237        assert_eq!(c.status, SubdomainStatus::Unknown);
238        assert!(c.takeover_risk.is_none());
239        // If the other family answered, the host is simply live.
240        let c = classify_one(
241            "v6.example.com".to_string(),
242            vec!["2001:db8::1".to_string()],
243            None,
244            true,
245            &[],
246        );
247        assert_eq!(c.status, SubdomainStatus::Live);
248    }
249
250    #[test]
251    fn classify_live_host_is_not_takeover_even_with_provider_cname() {
252        // Resolves to an address → not dangling, so no takeover flag.
253        let c = classify_one(
254            "live.example.com".to_string(),
255            vec!["203.0.113.5".to_string()],
256            Some("live.herokuapp.com".to_string()),
257            false,
258            &[],
259        );
260        assert_eq!(c.status, SubdomainStatus::Live);
261        assert!(c.takeover_risk.is_none());
262    }
263
264    #[test]
265    fn classify_wildcard_addresses_are_marked_wildcard() {
266        let wildcard = vec!["198.51.100.9".to_string()];
267        let c = classify_one(
268            "anything.example.com".to_string(),
269            vec!["198.51.100.9".to_string()],
270            None,
271            false,
272            &wildcard,
273        );
274        assert_eq!(c.status, SubdomainStatus::Wildcard);
275    }
276
277    #[test]
278    fn classify_distinct_address_under_wildcard_is_live() {
279        let wildcard = vec!["198.51.100.9".to_string()];
280        let c = classify_one(
281            "real.example.com".to_string(),
282            vec!["203.0.113.7".to_string()],
283            None,
284            false,
285            &wildcard,
286        );
287        assert_eq!(c.status, SubdomainStatus::Live);
288    }
289
290    #[test]
291    fn classify_cap_keeps_first_n_and_reports_skipped() {
292        // Over the cap: keep exactly MAX_CLASSIFY_NAMES, report the overflow.
293        let over = MAX_CLASSIFY_NAMES + 37;
294        let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
295        let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
296        assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
297        assert_eq!(skipped, 37);
298        // The first N (in the caller's already-sorted order) are the ones kept.
299        assert_eq!(names[0], "h0.example.com");
300    }
301
302    #[test]
303    fn classify_cap_is_noop_under_limit() {
304        let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
305        let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
306        assert_eq!(names.len(), 10);
307        assert_eq!(skipped, 0);
308    }
309}