use gossan_core::Target;
use secfinding::{Finding, FindingKind, Severity};
use crate::correlation::utils::normalize_host;
const TAKEOVER_SIGNALS: &[&str] = &["takeover", "dangling", "unclaimed"];
const WILDCARD_TAG: &str = "dns-wildcard";
fn is_dns_wildcard(f: &Finding) -> bool {
if f.tags().iter().any(|t| t.as_ref() == WILDCARD_TAG) {
return true;
}
normalize_host(f.target()).starts_with("*.")
}
fn wildcard_parent_of(f: &Finding) -> String {
normalize_host(f.target())
.trim_start_matches("*.")
.to_string()
}
pub struct WildcardTakeoverRule;
impl super::super::CorrelationRule for WildcardTakeoverRule {
fn name(&self) -> &'static str {
"wildcard_takeover"
}
fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
let mut wildcard_parents: Vec<String> = findings
.iter()
.filter(|f| is_dns_wildcard(f))
.map(wildcard_parent_of)
.filter(|p| !p.is_empty())
.collect();
wildcard_parents.sort();
wildcard_parents.dedup();
if wildcard_parents.is_empty() {
return vec![];
}
let takeover_findings: Vec<&Finding> = findings
.iter()
.filter(|f| {
let lower = f.title().to_lowercase();
if !TAKEOVER_SIGNALS.iter().any(|sig| lower.contains(sig)) {
return false;
}
let host = normalize_host(f.target());
wildcard_parents
.iter()
.any(|p| host.ends_with(&format!(".{p}")))
})
.collect();
if takeover_findings.is_empty() {
return vec![];
}
let chain = Finding::builder(
"correlation",
takeover_findings[0].target(),
Severity::Critical,
)
.title("Wildcard DNS + Subdomain Takeover = Mass Domain Hijack")
.detail(format!(
"A wildcard DNS record exists alongside {} takeover-vulnerable subdomain(s) under \
the same parent domain. Because the wildcard resolves ALL subdomains, an attacker \
who completes the takeover captures not just the dangling subdomain but EVERY \
possible subdomain - including future ones that don't exist yet. This enables mass \
phishing, cookie theft across the entire domain, and bypass of same-site cookie \
protections.",
takeover_findings.len()
))
.kind(FindingKind::Vulnerability)
.tag("chain")
.tag("wildcard")
.tag("takeover")
.tag("critical")
.build_or_log();
chain.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::correlation::CorrelationRule;
fn finding(scanner: &str, target: &str, title: &str) -> Finding {
Finding::builder(scanner, target, Severity::High)
.title(title)
.build()
.expect("test finding")
}
fn wildcard_finding(apex: &str) -> Finding {
Finding::builder("subdomain", apex, Severity::Info)
.title(format!("Wildcard DNS detected on {apex}"))
.tag("subdomain")
.tag(WILDCARD_TAG)
.build()
.expect("test wildcard finding")
}
#[test]
fn wildcard_takeover_does_not_fire_across_unrelated_domains() {
let rule = WildcardTakeoverRule;
let findings = vec![
wildcard_finding("example.com"),
finding(
"subdomain",
"leftover.unrelated-target.com",
"Subdomain takeover on dangling CNAME",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"cross-domain wildcard+takeover chain emitted as a false positive"
);
}
#[test]
fn wildcard_takeover_fires_when_takeover_lives_under_wildcard_parent() {
let rule = WildcardTakeoverRule;
let findings = vec![
wildcard_finding("example.com"),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert!(chains[0].title().contains("Mass Domain Hijack"));
}
#[test]
fn wildcard_takeover_ignores_cors_wildcard_origin_finding() {
let rule = WildcardTakeoverRule;
let findings = vec![
finding(
"hidden",
"example.com",
"CORS: wildcard origin (Access-Control-Allow-Origin: *)",
),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"CORS wildcard origin finding wrongly triggered DNS wildcard+takeover chain"
);
}
#[test]
fn wildcard_takeover_ignores_csp_wildcard_script_src_finding() {
let rule = WildcardTakeoverRule;
let findings = vec![
finding("hidden", "example.com", "CSP: wildcard * in script-src"),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"CSP wildcard finding wrongly triggered DNS wildcard+takeover chain"
);
}
#[test]
fn wildcard_takeover_ignores_cors_wildcard_with_credentials_finding() {
let rule = WildcardTakeoverRule;
let findings = vec![
finding(
"hidden",
"example.com",
"CORS: wildcard origin with credentials",
),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"CORS wildcard+credentials finding wrongly triggered DNS wildcard+takeover chain"
);
}
#[test]
fn wildcard_takeover_fires_via_starred_target_fallback() {
let rule = WildcardTakeoverRule;
let findings = vec![
Finding::builder("dns", "*.example.com", Severity::Info)
.title("zone observed")
.build()
.expect("starred target finding"),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
}
#[test]
fn wildcard_alone_does_not_chain() {
let rule = WildcardTakeoverRule;
let findings = vec![wildcard_finding("example.com")];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn takeover_alone_does_not_chain() {
let rule = WildcardTakeoverRule;
let findings = vec![finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
)];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn duplicate_wildcard_findings_emit_one_chain() {
let rule = WildcardTakeoverRule;
let findings = vec![
wildcard_finding("example.com"),
wildcard_finding("example.com"),
finding(
"subdomain",
"leftover.example.com",
"Subdomain takeover on dangling CNAME",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
}
#[test]
fn wildcard_takeover_ignores_apex_equality() {
let rule = WildcardTakeoverRule;
let findings = vec![
wildcard_finding("example.com"),
finding(
"subdomain",
"example.com",
"Subdomain takeover on dangling CNAME",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"apex equality must not trigger wildcard+takeover chain"
);
}
#[test]
fn wildcard_takeover_matches_one_subdomain_level_below_the_wildcard() {
let rule = WildcardTakeoverRule;
let findings = vec![
wildcard_finding("api.example.com"),
finding(
"subdomain",
"dev.api.example.com",
"Dangling CNAME under api.example.com",
),
finding(
"subdomain",
"apex.example.com",
"Dangling CNAME on sibling apex",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert!(
chains[0].detail().contains("1 takeover-vulnerable"),
"chain must include only the under-wildcard takeover, got {}",
chains[0].detail()
);
}
}