Skip to main content

seer_core/
takeover.rs

1//! Subdomain-takeover detection with HTTP fingerprint confirmation.
2//!
3//! A subdomain takeover happens when a DNS record still points at a
4//! third-party service that has since been deprovisioned: the CNAME survives,
5//! the resource behind it does not, and whoever claims that resource next
6//! controls content on the victim's domain.
7//!
8//! [`crate::subdomains::classify_subdomains`] already surfaces the *DNS half*
9//! of this signal — a dangling CNAME to a known provider whose name no longer
10//! resolves. That check alone has a large blind spot, and it is the common
11//! case: most providers answer for every name pointed at their shared
12//! infrastructure, so a deprovisioned resource still resolves and still
13//! returns HTTP 200/404 from the provider's edge. To DNS it looks perfectly
14//! healthy; only the response *body* says "there is no site here".
15//!
16//! This module closes that gap by adding the HTTP half:
17//!
18//! 1. Resolve each candidate host's addresses and walk its CNAME chain (up
19//!    to 8 hops, `MAX_CNAME_HOPS`).
20//! 2. Match every hop against the provider table below — a vanity CNAME
21//!    often points at the provider through an intermediate name. Hosts with
22//!    no provider anywhere in the chain are [`TakeoverVerdict::Safe`] and are
23//!    never fetched — the HTTP fan-out is bounded to plausible candidates.
24//! 3. For a candidate that resolves, issue one SSRF-guarded GET (every
25//!    redirect hop re-validated) and match the body against that provider's
26//!    claim-page fingerprints. A match served with the status the provider's
27//!    claim page uses (where known) is [`TakeoverVerdict::Vulnerable`],
28//!    carrying the matched marker as evidence. The GET goes over HTTPS
29//!    first; if that fails (an unclaimed custom domain is served under the
30//!    *provider's* certificate, and S3 website endpoints speak plain HTTP
31//!    only), it is made once more over `http://`. Evidence only counts when
32//!    the answering URL is still on the probed host — a body reached through
33//!    a cross-host redirect describes some other resource.
34//! 4. A candidate that does not resolve at all is [`TakeoverVerdict::Potential`]:
35//!    the classic dangling CNAME, unconfirmable over HTTP because nothing
36//!    answers.
37//! 5. A candidate that could not be checked — its address lookup failed, or
38//!    it resolves but neither probe got a response — is
39//!    [`TakeoverVerdict::Inconclusive`]: no signal either way, never a
40//!    finding.
41//!
42//! The two verdicts are deliberately distinct. `Vulnerable` means seer read a
43//! provider's own "unclaimed resource" page and is asserting a finding;
44//! `Potential` means the DNS shape is suspicious but nothing confirmed it. A
45//! pen-test report that conflates the two wastes the reader's time, so this
46//! module never promotes a `Potential` to `Vulnerable` without body evidence.
47//!
48//! Non-intrusive by construction: one unauthenticated GET per candidate, no
49//! crafted payloads, no attempt to actually claim anything. Fingerprint
50//! matching is pure and unit-tested; only resolution and fetching are async.
51
52use std::time::Duration;
53
54use futures::stream::{self, StreamExt};
55use serde::{Deserialize, Serialize};
56use tracing::debug;
57
58use crate::dns::{DnsResolver, RecordData, RecordType};
59use crate::error::Result;
60use crate::http::GuardedFetcher;
61use crate::validation::{normalize_domain, normalize_host};
62
63/// Upper bound on hosts examined in one scan. Mirrors the cap in
64/// [`crate::subdomains::classify_subdomains`]: CT logs can return tens of
65/// thousands of names, and while `concurrency` bounds parallelism it does not
66/// bound total work. Names beyond the cap are reported as skipped, never
67/// silently dropped. The public docs of [`scan_takeover`] and
68/// [`TakeoverReport::hosts_skipped`] state the value; keep them in step.
69const MAX_TAKEOVER_HOSTS: usize = 2000;
70
71/// Default HTTP fetch deadline for a fingerprint probe, for callers with no
72/// configured value of their own (the Python bindings). The CLI passes
73/// `config.http_timeout()`.
74pub const DEFAULT_TAKEOVER_TIMEOUT: Duration = Duration::from_secs(10);
75
76/// CNAME hops followed from a host looking for a provider name. Real chains
77/// are two or three hops; the cap bounds a looping or hostile one.
78pub(crate) const MAX_CNAME_HOPS: usize = 8;
79
80/// Body retained per probe. Every provider's unclaimed-resource page puts its
81/// marker in the first few KB, and probes run `concurrency`-wide, so a tighter
82/// cap than the shared default keeps peak memory proportional to the fan-out
83/// rather than to what the slowest provider chooses to send.
84const PROBE_BODY_LIMIT: usize = 32 * 1024;
85
86/// A takeover-prone provider, its CNAME shapes, and the body markers its
87/// "unclaimed resource" page returns.
88pub(crate) struct ProviderFingerprint {
89    pub(crate) provider: &'static str,
90    /// CNAME suffixes, each with a leading dot so the provider's own apex
91    /// (e.g. `github.io`) does not match — only a name *under* it. Matched
92    /// with `ends_with`, which is deliberately strict: a `contains` match
93    /// would also fire on an attacker-chosen target like
94    /// `x.github.io.attacker.example`.
95    pub(crate) cname_suffixes: &'static [&'static str],
96    /// Interior fragments matched with `contains`, for providers whose
97    /// endpoint embeds a variable component the suffix cannot cover — S3
98    /// website endpoints carry the region in the middle
99    /// (`bucket.s3-website-us-east-1.amazonaws.com`). Kept separate from
100    /// `cname_suffixes` so the loose match is opt-in per provider rather than
101    /// the default for all of them.
102    pub(crate) cname_infixes: &'static [&'static str],
103    /// Literal markers that appear in the provider's unclaimed-resource page.
104    /// Matched case-insensitively. Empty means the provider has no body
105    /// fingerprint stable enough to assert on, so only the DNS signal applies.
106    body_markers: &'static [&'static str],
107    /// HTTP statuses the unclaimed-resource page is served with, where known.
108    /// A marker in a response with any other status (a live page quoting the
109    /// text) is not evidence. Empty means not known: any status is accepted.
110    claim_statuses: &'static [u16],
111}
112
113/// Curated provider fingerprints — the single source of truth for both this
114/// module and the DNS-only takeover flag in
115/// [`crate::subdomains::classify_subdomains`], so the two can never disagree
116/// about a CNAME.
117///
118/// Every marker here is a string the provider itself serves for an unclaimed
119/// or deprovisioned resource. Markers are chosen to be specific: a generic
120/// `404 Not Found` would fire on any misconfigured host and turn this feature
121/// into a false-positive generator, so providers whose error page is generic
122/// carry no marker and rely on the DNS signal alone.
123pub(crate) const PROVIDERS: &[ProviderFingerprint] = &[
124    ProviderFingerprint {
125        provider: "GitHub Pages",
126        cname_suffixes: &[".github.io"],
127        cname_infixes: &[],
128        body_markers: &[
129            "There isn't a GitHub Pages site here.",
130            "For root URLs (like http://example.com/) you must provide an index.html file",
131        ],
132        claim_statuses: &[404],
133    },
134    ProviderFingerprint {
135        provider: "Heroku",
136        cname_suffixes: &[".herokuapp.com", ".herokudns.com", ".herokussl.com"],
137        cname_infixes: &[],
138        body_markers: &["No such app", "herokucdn.com/error-pages/no-such-app.html"],
139        claim_statuses: &[404],
140    },
141    ProviderFingerprint {
142        provider: "AWS S3",
143        cname_suffixes: &[".s3.amazonaws.com"],
144        cname_infixes: &[".s3-website"],
145        body_markers: &["NoSuchBucket", "The specified bucket does not exist"],
146        claim_statuses: &[404],
147    },
148    ProviderFingerprint {
149        provider: "AWS CloudFront",
150        cname_suffixes: &[".cloudfront.net"],
151        // CloudFront's error page is generic ("Bad request"); DNS signal only.
152        cname_infixes: &[],
153        body_markers: &[],
154        claim_statuses: &[],
155    },
156    ProviderFingerprint {
157        provider: "Azure App Service",
158        cname_suffixes: &[
159            ".azurewebsites.net",
160            ".cloudapp.net",
161            ".cloudapp.azure.com",
162            ".azureedge.net",
163        ],
164        cname_infixes: &[],
165        body_markers: &["404 Web Site not found", "Error 404 - Web app not found"],
166        claim_statuses: &[404],
167    },
168    ProviderFingerprint {
169        provider: "Azure Traffic Manager",
170        cname_suffixes: &[".trafficmanager.net"],
171        cname_infixes: &[],
172        body_markers: &[],
173        claim_statuses: &[],
174    },
175    ProviderFingerprint {
176        provider: "Azure Blob Storage",
177        cname_suffixes: &[".blob.core.windows.net"],
178        cname_infixes: &[],
179        body_markers: &["The specified container does not exist"],
180        claim_statuses: &[404],
181    },
182    ProviderFingerprint {
183        provider: "Shopify",
184        cname_suffixes: &[".myshopify.com"],
185        cname_infixes: &[],
186        body_markers: &[
187            "Sorry, this shop is currently unavailable",
188            "Only one step left!",
189        ],
190        claim_statuses: &[],
191    },
192    ProviderFingerprint {
193        provider: "Fastly",
194        cname_suffixes: &[".fastly.net"],
195        cname_infixes: &[],
196        body_markers: &["Fastly error: unknown domain"],
197        claim_statuses: &[500],
198    },
199    ProviderFingerprint {
200        provider: "Ghost",
201        cname_suffixes: &[".ghost.io"],
202        cname_infixes: &[],
203        body_markers: &["The thing you were looking for is no longer here"],
204        claim_statuses: &[],
205    },
206    ProviderFingerprint {
207        provider: "Surge.sh",
208        cname_suffixes: &[".surge.sh"],
209        cname_infixes: &[],
210        body_markers: &["project not found"],
211        claim_statuses: &[404],
212    },
213    ProviderFingerprint {
214        provider: "Bitbucket",
215        cname_suffixes: &[".bitbucket.io"],
216        cname_infixes: &[],
217        body_markers: &["Repository not found"],
218        claim_statuses: &[404],
219    },
220    ProviderFingerprint {
221        provider: "Pantheon",
222        cname_suffixes: &[".pantheonsite.io"],
223        cname_infixes: &[],
224        body_markers: &["The gods are wise, but do not know of the site which you seek"],
225        claim_statuses: &[404],
226    },
227    ProviderFingerprint {
228        provider: "Read the Docs",
229        cname_suffixes: &[".readthedocs.io"],
230        cname_infixes: &[],
231        body_markers: &["unknown to Read the Docs"],
232        claim_statuses: &[404],
233    },
234    ProviderFingerprint {
235        provider: "WP Engine",
236        cname_suffixes: &[".wpengine.com"],
237        cname_infixes: &[],
238        body_markers: &["The site you were looking for couldn't be found"],
239        claim_statuses: &[],
240    },
241    ProviderFingerprint {
242        provider: "Zendesk",
243        cname_suffixes: &[".zendesk.com"],
244        cname_infixes: &[],
245        body_markers: &["Help Center Closed"],
246        claim_statuses: &[],
247    },
248    ProviderFingerprint {
249        provider: "Netlify",
250        cname_suffixes: &[".netlify.app", ".netlify.com"],
251        cname_infixes: &[],
252        body_markers: &["Not Found - Request ID"],
253        claim_statuses: &[404],
254    },
255    ProviderFingerprint {
256        provider: "Statuspage",
257        cname_suffixes: &[".statuspage.io"],
258        cname_infixes: &[],
259        body_markers: &[],
260        claim_statuses: &[],
261    },
262    ProviderFingerprint {
263        provider: "Unbounce",
264        cname_suffixes: &[".unbouncepages.com"],
265        cname_infixes: &[],
266        body_markers: &["The requested URL was not found on this server"],
267        claim_statuses: &[404],
268    },
269    ProviderFingerprint {
270        provider: "Help Scout",
271        cname_suffixes: &[".helpscoutdocs.com"],
272        cname_infixes: &[],
273        body_markers: &["No settings were found for this company"],
274        claim_statuses: &[],
275    },
276    ProviderFingerprint {
277        provider: "LaunchRock",
278        cname_suffixes: &[".launchrock.com"],
279        cname_infixes: &[],
280        body_markers: &["It looks like you may have taken a wrong turn somewhere"],
281        claim_statuses: &[],
282    },
283    ProviderFingerprint {
284        provider: "Tumblr",
285        cname_suffixes: &[".domains.tumblr.com"],
286        cname_infixes: &[],
287        body_markers: &["Whatever you were looking for doesn't currently exist at this address"],
288        claim_statuses: &[404],
289    },
290    ProviderFingerprint {
291        provider: "Webflow",
292        cname_suffixes: &[".proxy-ssl.webflow.com", ".proxy.webflow.com"],
293        cname_infixes: &[],
294        body_markers: &["The page you are looking for doesn't exist or has been moved"],
295        claim_statuses: &[404],
296    },
297    ProviderFingerprint {
298        provider: "Intercom",
299        cname_suffixes: &[".custom.intercom.help"],
300        cname_infixes: &[],
301        body_markers: &["This page is reserved for artistic dogs"],
302        claim_statuses: &[],
303    },
304    ProviderFingerprint {
305        provider: "UserVoice",
306        cname_suffixes: &[".uservoice.com"],
307        cname_infixes: &[],
308        body_markers: &["This UserVoice subdomain is currently available"],
309        claim_statuses: &[],
310    },
311    ProviderFingerprint {
312        provider: "Wufoo",
313        cname_suffixes: &[".wufoo.com"],
314        cname_infixes: &[],
315        body_markers: &["Profile not found"],
316        claim_statuses: &[],
317    },
318    ProviderFingerprint {
319        provider: "FeedPress",
320        cname_suffixes: &[".redirect.feedpress.me"],
321        cname_infixes: &[],
322        body_markers: &["The feed has not been found"],
323        claim_statuses: &[],
324    },
325];
326
327/// Confidence in a takeover finding.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum TakeoverVerdict {
331    /// The provider's own unclaimed-resource page was served — confirmed by
332    /// matching the response body against a provider fingerprint.
333    Vulnerable,
334    /// A dangling CNAME to a takeover-prone provider that does not resolve.
335    /// Suspicious, but nothing answered to confirm it.
336    Potential,
337    /// A CNAME to a takeover-prone provider that could not be checked: its
338    /// address lookup failed, or it resolves but the HTTP probe got no
339    /// response. No signal either way — reported, but not a finding.
340    Inconclusive,
341    /// No takeover signal.
342    Safe,
343}
344
345/// One host's takeover assessment.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct TakeoverFinding {
348    pub host: String,
349    pub verdict: TakeoverVerdict,
350    /// The third-party service the CNAME points at, when recognized.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub provider: Option<String>,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub cname: Option<String>,
355    #[serde(skip_serializing_if = "Vec::is_empty")]
356    pub addresses: Vec<String>,
357    /// The provider fingerprint that matched the response body. Present only
358    /// on `Vulnerable` findings — this is the evidence for the claim.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub evidence: Option<String>,
361    /// HTTP status returned by the probe, when one was made.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub http_status: Option<u16>,
364    /// Why no HTTP confirmation was attempted or why it failed.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub probe_note: Option<String>,
367}
368
369/// The result of a takeover scan over a set of hosts.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct TakeoverReport {
372    pub domain: String,
373    /// How many hosts were examined (after the cap).
374    pub hosts_checked: usize,
375    /// Hosts dropped because the input exceeded the 2000-host cap.
376    #[serde(default)]
377    pub hosts_skipped: usize,
378    pub vulnerable: usize,
379    pub potential: usize,
380    /// Provider-CNAME hosts that could not be checked (see
381    /// [`TakeoverVerdict::Inconclusive`]).
382    #[serde(default)]
383    pub inconclusive: usize,
384    /// Hosts worth reporting (vulnerable, then potential, then inconclusive).
385    /// Hosts with no takeover signal are counted but not listed — a clean
386    /// host is noise in a findings report.
387    pub findings: Vec<TakeoverFinding>,
388    pub notes: Vec<String>,
389}
390
391impl TakeoverReport {
392    /// True when the scan found anything actionable (a vulnerable or
393    /// potential host; an inconclusive one is not a finding). Drives the
394    /// CLI's check-style exit code.
395    pub fn has_findings(&self) -> bool {
396        self.vulnerable > 0 || self.potential > 0
397    }
398}
399
400// --- Pure fingerprinting ------------------------------------------------
401
402/// Returns the provider whose CNAME suffix matches `cname`, or `None`.
403pub(crate) fn match_provider(cname: &str) -> Option<&'static ProviderFingerprint> {
404    let c = cname.trim_end_matches('.').to_ascii_lowercase();
405    PROVIDERS.iter().find(|p| {
406        p.cname_suffixes.iter().any(|suffix| c.ends_with(suffix))
407            || p.cname_infixes.iter().any(|infix| c.contains(infix))
408    })
409}
410
411/// Returns the provider fingerprint marker found in `body`, or `None`.
412///
413/// Matching is case-insensitive because providers vary the casing of their
414/// error copy across edges and over time.
415fn match_body_marker(provider: &ProviderFingerprint, body: &str) -> Option<&'static str> {
416    let haystack = body.to_ascii_lowercase();
417    provider
418        .body_markers
419        .iter()
420        .find(|marker| haystack.contains(&marker.to_ascii_lowercase()))
421        .copied()
422}
423
424/// Whether `status` is one the provider's claim page is served with (any
425/// status when that is not known).
426fn claim_status_matches(provider: &ProviderFingerprint, status: u16) -> bool {
427    provider.claim_statuses.is_empty() || provider.claim_statuses.contains(&status)
428}
429
430/// Truncates `hosts` to `cap` in place, returning how many were dropped. Pure
431/// so a scan cap is unit-testable without a resolver; shared with the
432/// subdomain classifier's cap.
433pub(crate) fn truncate_to_cap(hosts: &mut Vec<String>, cap: usize) -> usize {
434    let skipped = hosts.len().saturating_sub(cap);
435    hosts.truncate(cap);
436    skipped
437}
438
439/// Builds the advisory notes for a completed scan.
440fn build_notes(
441    vulnerable: usize,
442    potential: usize,
443    inconclusive: usize,
444    hosts_skipped: usize,
445) -> Vec<String> {
446    let mut notes = Vec::new();
447
448    if vulnerable > 0 {
449        notes.push(format!(
450            "{vulnerable} host(s) served a provider's unclaimed-resource page — an attacker \
451             can likely claim these and host content on your domain. Remove the DNS record or \
452             re-claim the resource."
453        ));
454    }
455    if potential > 0 {
456        notes.push(format!(
457            "{potential} host(s) have a CNAME to a takeover-prone provider that does not \
458             resolve, so nothing answered to confirm it either way. Verify each manually \
459             before acting."
460        ));
461    }
462    if inconclusive > 0 {
463        notes.push(format!(
464            "{inconclusive} host(s) with a takeover-prone provider CNAME could not be checked \
465             (a DNS lookup or the HTTP probe failed — see each host's note); re-run to confirm."
466        ));
467    }
468    if vulnerable == 0 && potential == 0 && inconclusive == 0 {
469        notes.push("No takeover signals found.".to_string());
470    }
471    if hosts_skipped > 0 {
472        notes.push(format!(
473            "{hosts_skipped} host(s) exceeded the {MAX_TAKEOVER_HOSTS}-host scan cap and were \
474             not examined."
475        ));
476    }
477
478    notes
479}
480
481// --- Async scanning -----------------------------------------------------
482
483/// DNS facts about one host.
484pub(crate) struct HostResolution {
485    /// A and AAAA addresses (IPv6-only hosts are alive too).
486    pub(crate) addresses: Vec<String>,
487    /// The host's own CNAME target (the first hop), when it has one.
488    pub(crate) cname: Option<String>,
489    /// The first hop of the CNAME chain that names a takeover-prone
490    /// provider, with that provider.
491    pub(crate) provider: Option<(&'static ProviderFingerprint, String)>,
492    /// Set when no address was found *and* an address lookup failed outright
493    /// (timeout, SERVFAIL) rather than answering NXDOMAIN/NODATA — "we could
494    /// not tell", which must never be reported as "does not resolve".
495    pub(crate) lookup_error: Option<String>,
496}
497
498/// The CNAME target of `name`, or `None` when it has none (or the lookup
499/// failed — a chain walk simply stops there).
500async fn cname_of(resolver: &DnsResolver, name: &str) -> Option<String> {
501    let records = resolver.resolve(name, RecordType::CNAME, None).await.ok()?;
502    records.into_iter().find_map(|r| match r.data {
503        RecordData::CNAME { target } => Some(target),
504        _ => None,
505    })
506}
507
508/// Resolves the addresses of `host` and walks its CNAME chain for a provider.
509pub(crate) async fn resolve_host(resolver: &DnsResolver, host: &str) -> HostResolution {
510    let (a, aaaa, cname) = tokio::join!(
511        Box::pin(resolver.resolve(host, RecordType::A, None)),
512        Box::pin(resolver.resolve(host, RecordType::AAAA, None)),
513        Box::pin(cname_of(resolver, host)),
514    );
515
516    let mut addresses = Vec::new();
517    let mut lookup_error = None;
518    for result in [a, aaaa] {
519        match result {
520            Ok(records) => addresses.extend(
521                records
522                    .iter()
523                    .filter_map(|r| r.data.address().map(str::to_string)),
524            ),
525            // `resolve` maps NXDOMAIN/NODATA to an empty Ok, so an Err here is
526            // a genuine failure to get an answer.
527            Err(e) => lookup_error = Some(e.sanitized_message()),
528        }
529    }
530    if !addresses.is_empty() {
531        lookup_error = None;
532    }
533
534    // A resolver answers a CNAME query with one hop and does not chase it, so
535    // the chain is walked here, one query per hop, until a hop names a
536    // provider, the chain ends, it loops, or the hop cap is reached.
537    let mut provider = None;
538    let mut seen = std::collections::HashSet::new();
539    let mut hop = cname.clone();
540    for _ in 0..MAX_CNAME_HOPS {
541        let Some(target) = hop.take() else { break };
542        if let Some(p) = match_provider(&target) {
543            provider = Some((p, target));
544            break;
545        }
546        if !seen.insert(target.clone()) {
547            break;
548        }
549        hop = cname_of(resolver, &target).await;
550    }
551
552    HostResolution {
553        addresses,
554        cname,
555        provider,
556        lookup_error,
557    }
558}
559
560/// Turns a completed probe into a finding.
561///
562/// This is the step that decides `Vulnerable` vs `Safe`, so it is split out as
563/// a pure function: the rule that a confirmed takeover requires a matched body
564/// marker (never a mere status code, and never a provider match on its own),
565/// served with the claim page's own status where that is known, is the
566/// load-bearing claim of this module and is unit-tested directly.
567fn finding_from_probe(
568    host: String,
569    provider: &ProviderFingerprint,
570    cname: Option<String>,
571    addresses: Vec<String>,
572    response: &crate::http::FetchedResponse,
573) -> TakeoverFinding {
574    // The body only speaks for this host if this host served it. After a
575    // cross-host redirect (a live app bouncing visitors to some *other*,
576    // unclaimed resource on the same provider) a claim-page marker describes
577    // that other resource, so it is not evidence here.
578    let head = &response.head;
579    let final_host = reqwest::Url::parse(&head.final_url)
580        .ok()
581        .as_ref()
582        .and_then(crate::net::url_host)
583        .map(|h| h.trim_end_matches('.').to_ascii_lowercase());
584    let same_host = final_host.as_deref() == Some(host.trim_end_matches('.'));
585    if !same_host {
586        return TakeoverFinding {
587            host,
588            verdict: TakeoverVerdict::Safe,
589            provider: Some(provider.provider.to_string()),
590            cname,
591            addresses,
592            evidence: None,
593            http_status: Some(head.status),
594            probe_note: Some(format!(
595                "redirected to {}; that response is not attributed to this host",
596                final_host.as_deref().unwrap_or("another host")
597            )),
598        };
599    }
600
601    let marker = match_body_marker(provider, &response.body);
602    // The claim-page text under another status is a page quoting it (a live
603    // site, a help article), not the provider's unclaimed-resource response.
604    let evidence = marker.filter(|_| claim_status_matches(provider, head.status));
605    let verdict = if evidence.is_some() {
606        TakeoverVerdict::Vulnerable
607    } else {
608        TakeoverVerdict::Safe
609    };
610    let probe_note = if marker.is_some() && evidence.is_none() {
611        Some(format!(
612            "claim-page text was served with HTTP {}, not the status {}'s unclaimed-resource \
613             page uses; not treated as evidence",
614            head.status, provider.provider
615        ))
616    } else if evidence.is_none() && provider.body_markers.is_empty() {
617        // A provider we have no fingerprint for can never be confirmed here,
618        // so say so rather than letting `Safe` imply it was checked.
619        Some("provider has no reliable body fingerprint; verify this host manually".to_string())
620    } else {
621        None
622    };
623
624    TakeoverFinding {
625        host,
626        verdict,
627        provider: Some(provider.provider.to_string()),
628        cname,
629        addresses,
630        evidence: evidence.map(str::to_string),
631        http_status: Some(head.status),
632        probe_note,
633    }
634}
635
636/// Assesses a single host: resolve, match the provider, and confirm over HTTP
637/// when the host answers.
638async fn check_host(
639    resolver: &DnsResolver,
640    fetcher: &GuardedFetcher,
641    host: String,
642) -> TakeoverFinding {
643    let HostResolution {
644        addresses,
645        cname,
646        provider,
647        lookup_error,
648    } = resolve_host(resolver, &host).await;
649
650    // No CNAME, or a chain that names no provider we recognize: nothing to
651    // claim. These hosts are never fetched, which is what keeps the HTTP
652    // fan-out proportional to the number of plausible candidates rather than
653    // to the size of the zone.
654    let Some((provider, provider_cname)) = provider else {
655        return TakeoverFinding {
656            host,
657            verdict: TakeoverVerdict::Safe,
658            provider: None,
659            cname,
660            addresses,
661            evidence: None,
662            http_status: None,
663            probe_note: None,
664        };
665    };
666
667    // The finding names the hop that points at the provider.
668    let cname = Some(provider_cname);
669
670    // A provider CNAME that resolves to nothing is the classic dangling
671    // record. Nothing answers, so HTTP cannot confirm it either way. A lookup
672    // that *failed* is not evidence that the name is dangling: we could not
673    // tell, so it is inconclusive.
674    if addresses.is_empty() {
675        let (verdict, probe_note) = match lookup_error {
676            Some(e) => (
677                TakeoverVerdict::Inconclusive,
678                format!(
679                    "CNAME points at a takeover-prone provider, but its address lookup failed \
680                     ({e}); re-run to confirm"
681                ),
682            ),
683            None => (
684                TakeoverVerdict::Potential,
685                "CNAME points at a takeover-prone provider but does not resolve".to_string(),
686            ),
687        };
688        return TakeoverFinding {
689            host,
690            verdict,
691            provider: Some(provider.provider.to_string()),
692            cname,
693            addresses,
694            evidence: None,
695            http_status: None,
696            probe_note: Some(probe_note),
697        };
698    }
699
700    // The host resolves — ask the provider's edge what it serves. HTTPS
701    // first; an unclaimed custom domain is typically served under the
702    // provider's own certificate (so validating TLS fails) and S3 website
703    // endpoints have no HTTPS at all, so fall back to plain HTTP, where the
704    // claim page is readable. Body markers do not depend on TLS trust.
705    let https_err = match fetcher.get(&format!("https://{host}/")).await {
706        Ok(response) => return finding_from_probe(host, provider, cname, addresses, &response),
707        Err(e) => e,
708    };
709    match fetcher.get(&format!("http://{host}/")).await {
710        Ok(response) => finding_from_probe(host, provider, cname, addresses, &response),
711        Err(http_err) => {
712            debug!(host = %host, https = %https_err, http = %http_err, "takeover probe failed");
713            // A failed probe is not evidence of anything, and the host
714            // resolves, so it is not dangling either: report it as unchecked
715            // and say why — never upgrade a fetch failure into a finding.
716            TakeoverFinding {
717                host,
718                verdict: TakeoverVerdict::Inconclusive,
719                provider: Some(provider.provider.to_string()),
720                cname,
721                addresses,
722                evidence: None,
723                http_status: None,
724                probe_note: Some(format!(
725                    "HTTP probe failed over HTTPS ({}) and HTTP ({})",
726                    https_err.sanitized_message(),
727                    http_err.sanitized_message()
728                )),
729            }
730        }
731    }
732}
733
734/// Assembles a report from completed per-host findings. Pure, so ordering,
735/// counting, and filtering are testable without any network.
736fn build_report(
737    domain: String,
738    findings: Vec<TakeoverFinding>,
739    hosts_skipped: usize,
740) -> TakeoverReport {
741    let hosts_checked = findings.len();
742    let vulnerable = findings
743        .iter()
744        .filter(|f| f.verdict == TakeoverVerdict::Vulnerable)
745        .count();
746    let potential = findings
747        .iter()
748        .filter(|f| f.verdict == TakeoverVerdict::Potential)
749        .count();
750    let inconclusive = findings
751        .iter()
752        .filter(|f| f.verdict == TakeoverVerdict::Inconclusive)
753        .count();
754
755    // Report only actionable hosts, worst first, alphabetical within a band.
756    let mut reported: Vec<TakeoverFinding> = findings
757        .into_iter()
758        .filter(|f| f.verdict != TakeoverVerdict::Safe)
759        .collect();
760    reported.sort_by(|a, b| {
761        let rank = |v: TakeoverVerdict| match v {
762            TakeoverVerdict::Vulnerable => 0,
763            TakeoverVerdict::Potential => 1,
764            TakeoverVerdict::Inconclusive => 2,
765            TakeoverVerdict::Safe => 3,
766        };
767        rank(a.verdict)
768            .cmp(&rank(b.verdict))
769            .then_with(|| a.host.cmp(&b.host))
770    });
771
772    TakeoverReport {
773        domain,
774        hosts_checked,
775        hosts_skipped,
776        vulnerable,
777        potential,
778        inconclusive,
779        notes: build_notes(vulnerable, potential, inconclusive, hosts_skipped),
780        findings: reported,
781    }
782}
783
784/// Scans `hosts` for subdomain-takeover exposure on `domain`.
785///
786/// Each host is resolved and, when its CNAME chain names a recognized
787/// provider and the host answers, fetched once — over HTTPS, or over plain
788/// HTTP when HTTPS fails — to confirm against that provider's
789/// unclaimed-resource fingerprint. Hosts with no provider CNAME are never
790/// fetched. At most 2000 hosts are examined.
791///
792/// # Arguments
793/// * `resolver` - DNS resolver for the CNAME/address lookups
794/// * `domain` - The apex the hosts belong to (reported, and normalized)
795/// * `hosts` - Candidate hostnames (typically from CT-log enumeration)
796/// * `concurrency` - Maximum simultaneous host checks
797/// * `timeout` - HTTP fetch deadline for each probe (the CLI passes
798///   `config.http_timeout()`; [`DEFAULT_TAKEOVER_TIMEOUT`] otherwise)
799///
800/// # Returns
801/// * `Ok(TakeoverReport)` - the scan result, including a clean one
802/// * `Err(SeerError::InvalidDomain)` - `domain` is not a valid domain
803pub async fn scan_takeover(
804    resolver: &DnsResolver,
805    domain: &str,
806    mut hosts: Vec<String>,
807    concurrency: usize,
808    timeout: Duration,
809) -> Result<TakeoverReport> {
810    let domain = normalize_domain(domain)?;
811    // Hosts are probed exactly as named — `www` is the most commonly
812    // CNAME'd host of all, so it must not collapse into the apex. Input that
813    // doesn't normalize is kept verbatim and simply won't resolve.
814    for host in &mut hosts {
815        if let Ok(normalized) = normalize_host(host) {
816            *host = normalized;
817        }
818    }
819    let mut seen = std::collections::HashSet::new();
820    hosts.retain(|h| seen.insert(h.clone()));
821    let hosts_skipped = truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS);
822    let concurrency = concurrency.max(1);
823
824    let fetcher = GuardedFetcher::new()
825        .with_timeout(timeout)
826        .with_max_body(PROBE_BODY_LIMIT);
827
828    let findings: Vec<TakeoverFinding> = stream::iter(hosts)
829        .map(|host| {
830            let fetcher = &fetcher;
831            async move { check_host(resolver, fetcher, host).await }
832        })
833        .buffer_unordered(concurrency)
834        .collect()
835        .await;
836
837    Ok(build_report(domain, findings, hosts_skipped))
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn finding(host: &str, verdict: TakeoverVerdict) -> TakeoverFinding {
845        TakeoverFinding {
846            host: host.to_string(),
847            verdict,
848            provider: Some("GitHub Pages".to_string()),
849            cname: Some("x.github.io".to_string()),
850            addresses: vec![],
851            evidence: None,
852            http_status: None,
853            probe_note: None,
854        }
855    }
856
857    #[test]
858    fn provider_matching_by_cname_suffix() {
859        assert_eq!(
860            match_provider("myapp.herokuapp.com").map(|p| p.provider),
861            Some("Heroku")
862        );
863        assert_eq!(
864            match_provider("user.github.io.").map(|p| p.provider),
865            Some("GitHub Pages")
866        );
867        assert_eq!(
868            match_provider("BUCKET.S3.AMAZONAWS.COM").map(|p| p.provider),
869            Some("AWS S3")
870        );
871        // Unrelated targets must not match.
872        assert_eq!(match_provider("cdn.example.com").map(|p| p.provider), None);
873    }
874
875    #[test]
876    fn provider_apex_alone_does_not_match() {
877        // The suffixes carry a leading dot, so the provider's own apex is not
878        // a takeover candidate — only a name under it.
879        assert!(match_provider("github.io").is_none());
880        assert!(match_provider("herokuapp.com").is_none());
881    }
882
883    #[test]
884    fn body_markers_match_case_insensitively() {
885        let gh = match_provider("x.github.io").expect("github provider");
886        assert_eq!(
887            match_body_marker(gh, "<h1>There isn't a GitHub Pages site here.</h1>"),
888            Some("There isn't a GitHub Pages site here.")
889        );
890        // Providers vary casing across their edges.
891        assert!(match_body_marker(gh, "THERE ISN'T A GITHUB PAGES SITE HERE.").is_some());
892        // A real site's body must not match.
893        assert_eq!(match_body_marker(gh, "<h1>Welcome to my blog</h1>"), None);
894    }
895
896    #[test]
897    fn providers_without_markers_never_match_a_body() {
898        let cf = match_provider("d123.cloudfront.net").expect("cloudfront provider");
899        assert!(cf.body_markers.is_empty());
900        // Even a body that looks like an error must not produce evidence for a
901        // provider we deliberately have no fingerprint for.
902        assert_eq!(match_body_marker(cf, "404 Not Found"), None);
903    }
904
905    #[test]
906    fn suffix_match_is_anchored_at_the_end() {
907        // A `contains` match would fire here and let an attacker-chosen CNAME
908        // target impersonate a provider, then serve that provider's claim-page
909        // text to manufacture a "confirmed" finding.
910        assert!(match_provider("x.github.io.attacker.example").is_none());
911        assert!(match_provider("app.herokuapp.com.evil.test").is_none());
912    }
913
914    #[test]
915    fn s3_website_region_endpoints_match_via_infix() {
916        // S3 website endpoints carry the region mid-name, so the suffix alone
917        // cannot cover them — this is why the infix list exists.
918        assert_eq!(
919            match_provider("bucket.s3-website-us-east-1.amazonaws.com").map(|p| p.provider),
920            Some("AWS S3")
921        );
922        assert_eq!(
923            match_provider("bucket.s3-website.eu-west-2.amazonaws.com").map(|p| p.provider),
924            Some("AWS S3")
925        );
926    }
927
928    #[test]
929    fn only_s3_opts_into_loose_infix_matching() {
930        // The loose match must stay opt-in; if it spreads to other providers
931        // the anchoring guarantee above quietly stops holding for them.
932        for p in PROVIDERS {
933            if p.provider != "AWS S3" {
934                assert!(
935                    p.cname_infixes.is_empty(),
936                    "{} must not use infix matching without a documented reason",
937                    p.provider
938                );
939            }
940        }
941    }
942
943    #[test]
944    fn every_fingerprint_suffix_is_dot_prefixed() {
945        // A suffix without a leading dot would match the provider apex and any
946        // domain merely ending in those characters (e.g. "evilgithub.io").
947        for p in PROVIDERS {
948            assert!(
949                !p.cname_suffixes.is_empty(),
950                "{} has no CNAME suffixes",
951                p.provider
952            );
953            for suffix in p.cname_suffixes {
954                assert!(
955                    suffix.starts_with('.'),
956                    "{} suffix {suffix:?} must start with a dot",
957                    p.provider
958                );
959            }
960        }
961    }
962
963    /// Synthetic probe response, so the confirmation rule is testable without
964    /// DNS or HTTP.
965    fn probe(host: &str, status: u16, body: &str) -> crate::http::FetchedResponse {
966        crate::http::FetchedResponse {
967            head: crate::http::ResponseHead {
968                final_url: format!("https://{host}/"),
969                status,
970                headers: vec![],
971                redirects: 0,
972            },
973            body: body.to_string(),
974        }
975    }
976
977    #[test]
978    fn matching_body_marker_confirms_vulnerable_with_evidence() {
979        let gh = match_provider("gone.github.io").expect("github provider");
980        let finding = finding_from_probe(
981            "gone.example.com".to_string(),
982            gh,
983            Some("gone.github.io".to_string()),
984            vec!["185.199.108.153".to_string()],
985            &probe(
986                "gone.example.com",
987                404,
988                "<h1>There isn't a GitHub Pages site here.</h1>",
989            ),
990        );
991        assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
992        // The evidence is what makes the claim auditable.
993        assert_eq!(
994            finding.evidence.as_deref(),
995            Some("There isn't a GitHub Pages site here.")
996        );
997        assert_eq!(finding.http_status, Some(404));
998    }
999
1000    #[test]
1001    fn a_live_site_on_a_provider_cname_is_safe() {
1002        // The common false positive to avoid: a perfectly healthy site hosted
1003        // on a takeover-prone provider must NOT be reported.
1004        let gh = match_provider("user.github.io").expect("github provider");
1005        let finding = finding_from_probe(
1006            "blog.example.com".to_string(),
1007            gh,
1008            Some("user.github.io".to_string()),
1009            vec!["185.199.108.153".to_string()],
1010            &probe(
1011                "blog.example.com",
1012                200,
1013                "<html><body><h1>My Blog</h1></body></html>",
1014            ),
1015        );
1016        assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1017        assert!(finding.evidence.is_none());
1018    }
1019
1020    #[test]
1021    fn a_404_alone_never_confirms_a_takeover() {
1022        // Status codes are not evidence: plenty of live sites 404 their root.
1023        // Only a provider's own claim-page text confirms.
1024        let gh = match_provider("user.github.io").expect("github provider");
1025        let finding = finding_from_probe(
1026            "blog.example.com".to_string(),
1027            gh,
1028            Some("user.github.io".to_string()),
1029            vec!["185.199.108.153".to_string()],
1030            &probe("blog.example.com", 404, "<h1>Page not found</h1>"),
1031        );
1032        assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1033    }
1034
1035    #[test]
1036    fn marker_after_a_cross_host_redirect_is_not_evidence() {
1037        // A live, claimed app that redirects to a *different* unclaimed app on
1038        // the same provider must not make the probed host look claimable.
1039        let heroku = match_provider("app.herokuapp.com").expect("heroku provider");
1040        let mut response = probe("retired-app.herokuapp.com", 404, "No such app");
1041        response.head.redirects = 1;
1042        let finding = finding_from_probe(
1043            "app.example.com".to_string(),
1044            heroku,
1045            Some("app.herokuapp.com".to_string()),
1046            vec!["203.0.113.7".to_string()],
1047            &response,
1048        );
1049        assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1050        assert!(finding.evidence.is_none());
1051        assert!(finding
1052            .probe_note
1053            .as_deref()
1054            .unwrap_or_default()
1055            .contains("retired-app.herokuapp.com"));
1056
1057        // Same host over plain HTTP (the fallback path) still confirms.
1058        let mut plain = probe("app.example.com", 404, "No such app");
1059        plain.head.final_url = "http://App.Example.com./".to_string();
1060        let finding = finding_from_probe(
1061            "app.example.com".to_string(),
1062            heroku,
1063            Some("app.herokuapp.com".to_string()),
1064            vec!["203.0.113.7".to_string()],
1065            &plain,
1066        );
1067        assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
1068    }
1069
1070    #[test]
1071    fn fingerprintless_provider_says_it_could_not_confirm() {
1072        // CloudFront has no reliable body marker, so a Safe verdict there must
1073        // carry a note rather than implying the host was actually cleared.
1074        let cf = match_provider("d123.cloudfront.net").expect("cloudfront provider");
1075        let finding = finding_from_probe(
1076            "cdn.example.com".to_string(),
1077            cf,
1078            Some("d123.cloudfront.net".to_string()),
1079            vec!["203.0.113.9".to_string()],
1080            &probe("cdn.example.com", 403, "Bad request"),
1081        );
1082        assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1083        assert!(
1084            finding
1085                .probe_note
1086                .as_deref()
1087                .unwrap_or_default()
1088                .contains("verify this host manually"),
1089            "got {:?}",
1090            finding.probe_note
1091        );
1092    }
1093
1094    #[test]
1095    fn report_counts_and_orders_findings() {
1096        let findings = vec![
1097            finding("safe.example.com", TakeoverVerdict::Safe),
1098            finding("zeta.example.com", TakeoverVerdict::Potential),
1099            finding("beta.example.com", TakeoverVerdict::Vulnerable),
1100            finding("alpha.example.com", TakeoverVerdict::Potential),
1101        ];
1102        let report = build_report("example.com".to_string(), findings, 0);
1103
1104        assert_eq!(report.hosts_checked, 4);
1105        assert_eq!(report.vulnerable, 1);
1106        assert_eq!(report.potential, 2);
1107        // Safe hosts are counted but not listed.
1108        assert_eq!(report.findings.len(), 3);
1109        assert_eq!(report.findings[0].host, "beta.example.com");
1110        // Potentials follow, alphabetically.
1111        assert_eq!(report.findings[1].host, "alpha.example.com");
1112        assert_eq!(report.findings[2].host, "zeta.example.com");
1113        assert!(report.has_findings());
1114    }
1115
1116    #[test]
1117    fn clean_scan_reports_no_findings() {
1118        let findings = vec![
1119            finding("a.example.com", TakeoverVerdict::Safe),
1120            finding("b.example.com", TakeoverVerdict::Safe),
1121        ];
1122        let report = build_report("example.com".to_string(), findings, 0);
1123        assert_eq!(report.hosts_checked, 2);
1124        assert!(report.findings.is_empty());
1125        assert!(!report.has_findings());
1126        assert!(report
1127            .notes
1128            .iter()
1129            .any(|n| n.contains("No takeover signals")));
1130    }
1131
1132    #[test]
1133    fn host_cap_truncates_and_reports_the_remainder() {
1134        let over = MAX_TAKEOVER_HOSTS + 15;
1135        let mut hosts: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
1136        let skipped = truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS);
1137        assert_eq!(hosts.len(), MAX_TAKEOVER_HOSTS);
1138        assert_eq!(skipped, 15);
1139
1140        // The truncation must surface in the report's notes, never silently.
1141        let report = build_report("example.com".to_string(), vec![], skipped);
1142        assert!(report.notes.iter().any(|n| n.contains("scan cap")));
1143        assert_eq!(report.hosts_skipped, 15);
1144    }
1145
1146    #[test]
1147    fn host_cap_is_a_noop_under_the_limit() {
1148        let mut hosts: Vec<String> = (0..5).map(|i| format!("h{i}.example.com")).collect();
1149        assert_eq!(truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS), 0);
1150        assert_eq!(hosts.len(), 5);
1151    }
1152
1153    #[test]
1154    fn notes_distinguish_confirmed_from_unconfirmed() {
1155        let notes = build_notes(2, 3, 4, 0);
1156        // The confirmed finding must read as actionable...
1157        assert!(notes.iter().any(|n| n.contains("2 host(s) served")));
1158        // ...the unconfirmed one must say it needs manual verification...
1159        assert!(notes
1160            .iter()
1161            .any(|n| n.contains("3 host(s)") && n.contains("manually")));
1162        // ...and the unchecked ones must say they could not be checked.
1163        assert!(notes
1164            .iter()
1165            .any(|n| n.contains("4 host(s)") && n.contains("could not be checked")));
1166    }
1167
1168    /// Regression: the claim-page text under a status the provider's claim
1169    /// page never uses (a live page quoting it) confirmed a takeover.
1170    #[test]
1171    fn a_marker_under_the_wrong_status_is_not_evidence() {
1172        let gh = match_provider("user.github.io").expect("github provider");
1173        let finding = finding_from_probe(
1174            "blog.example.com".to_string(),
1175            gh,
1176            Some("user.github.io".to_string()),
1177            vec!["185.199.108.153".to_string()],
1178            &probe(
1179                "blog.example.com",
1180                200,
1181                "<p>If you see \"There isn't a GitHub Pages site here.\" ...</p>",
1182            ),
1183        );
1184        assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1185        assert!(finding.evidence.is_none());
1186        assert!(finding
1187            .probe_note
1188            .as_deref()
1189            .unwrap_or_default()
1190            .contains("HTTP 200"));
1191
1192        // A provider with no known claim status accepts any status.
1193        let shopify = match_provider("shop.myshopify.com").expect("shopify provider");
1194        assert!(shopify.claim_statuses.is_empty());
1195        let finding = finding_from_probe(
1196            "shop.example.com".to_string(),
1197            shopify,
1198            Some("shop.myshopify.com".to_string()),
1199            vec!["203.0.113.7".to_string()],
1200            &probe(
1201                "shop.example.com",
1202                200,
1203                "Sorry, this shop is currently unavailable",
1204            ),
1205        );
1206        assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
1207    }
1208
1209    #[test]
1210    fn claim_statuses_are_error_statuses() {
1211        // A claim page is an error page; a 2xx/3xx entry would let a live
1212        // page's quoted text count as evidence again.
1213        for p in PROVIDERS {
1214            for status in p.claim_statuses {
1215                assert!(*status >= 400, "{}: {status}", p.provider);
1216            }
1217        }
1218    }
1219
1220    #[test]
1221    fn inconclusive_hosts_are_listed_but_not_findings() {
1222        let findings = vec![
1223            finding("a.example.com", TakeoverVerdict::Inconclusive),
1224            finding("b.example.com", TakeoverVerdict::Safe),
1225        ];
1226        let report = build_report("example.com".to_string(), findings, 0);
1227        assert_eq!(report.inconclusive, 1);
1228        assert_eq!(report.potential, 0);
1229        assert_eq!(report.findings.len(), 1);
1230        assert!(!report.has_findings());
1231    }
1232
1233    /// A scripted zone: `name`'s CNAME target, or no record.
1234    async fn chain_resolver(chain: &'static [(&'static str, &'static str)]) -> DnsResolver {
1235        use crate::dns::test_support::{
1236            cname_rdata, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1237        };
1238        use hickory_resolver::proto::rr::RecordType as WireType;
1239
1240        let port = spawn_mock_dns_fn(move |name, rtype| {
1241            match chain.iter().find(|(from, _)| *from == name) {
1242                Some((_, to)) if rtype == WireType::CNAME => {
1243                    MockReply::Answer(vec![cname_rdata(to)])
1244                }
1245                _ => MockReply::NxDomain,
1246            }
1247        })
1248        .await;
1249        mock_dns_resolver_default(port)
1250    }
1251
1252    /// Regression: only the first CNAME hop was matched, so a vanity CNAME
1253    /// pointing at the provider through an intermediate name was never
1254    /// checked.
1255    #[tokio::test]
1256    async fn a_provider_later_in_the_cname_chain_is_found() {
1257        let resolver = chain_resolver(&[
1258            ("docs.example.com", "docs.cdn.example.net"),
1259            ("docs.cdn.example.net", "gone.github.io"),
1260        ])
1261        .await;
1262        let r = resolve_host(&resolver, "docs.example.com").await;
1263        assert_eq!(r.cname.as_deref(), Some("docs.cdn.example.net."));
1264        let (provider, hop) = r.provider.expect("provider in the chain");
1265        assert_eq!(provider.provider, "GitHub Pages");
1266        assert_eq!(hop, "gone.github.io.");
1267    }
1268
1269    #[tokio::test]
1270    async fn a_looping_cname_chain_ends() {
1271        let resolver = chain_resolver(&[
1272            ("a.example.com", "b.example.net"),
1273            ("b.example.net", "a.example.com"),
1274        ])
1275        .await;
1276        let r = resolve_host(&resolver, "a.example.com").await;
1277        assert!(r.provider.is_none());
1278    }
1279}