1use 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
56const MAX_TAKEOVER_HOSTS: usize = 2000;
62
63const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
65
66const PROBE_BODY_LIMIT: usize = 32 * 1024;
71
72struct ProviderFingerprint {
75 provider: &'static str,
76 cname_suffixes: &'static [&'static str],
82 cname_infixes: &'static [&'static str],
89 body_markers: &'static [&'static str],
93}
94
95const 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "lowercase")]
282pub enum TakeoverVerdict {
283 Vulnerable,
286 Potential,
289 Safe,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct TakeoverFinding {
296 pub host: String,
297 pub verdict: TakeoverVerdict,
298 #[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 #[serde(skip_serializing_if = "Option::is_none")]
308 pub evidence: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub http_status: Option<u16>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub probe_note: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct TakeoverReport {
320 pub domain: String,
321 pub hosts_checked: usize,
323 #[serde(default)]
325 pub hosts_skipped: usize,
326 pub vulnerable: usize,
327 pub potential: usize,
328 pub findings: Vec<TakeoverFinding>,
332 pub notes: Vec<String>,
333}
334
335impl TakeoverReport {
336 pub fn has_findings(&self) -> bool {
339 self.vulnerable > 0 || self.potential > 0
340 }
341}
342
343fn 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
354fn 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
367fn 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
376fn 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
407struct HostResolution {
411 addresses: Vec<String>,
413 cname: Option<String>,
414 lookup_error: Option<String>,
418}
419
420async 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 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
459fn 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
467fn 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 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 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
527async 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 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 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 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 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
615fn 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 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
659pub 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 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 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 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 assert!(match_body_marker(gh, "THERE ISN'T A GITHUB PAGES SITE HERE.").is_some());
763 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 assert_eq!(match_body_marker(cf, "404 Not Found"), None);
774 }
775
776 #[test]
777 fn suffix_match_is_anchored_at_the_end() {
778 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(report.findings.len(), 3);
978 assert_eq!(report.findings[0].host, "beta.example.com");
979 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 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 assert!(notes.iter().any(|n| n.contains("2 host(s) served")));
1027 assert!(notes
1029 .iter()
1030 .any(|n| n.contains("3 host(s)") && n.contains("manually")));
1031 assert!(!notes.iter().any(|n| n.contains("did not resolve")));
1034 }
1035}