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