gossan-dns 0.3.3

DNS security scanner: SPF/DMARC/DKIM email auth, zone transfer (AXFR), subdomain takeover, CAA/NS posture, part of the security research ecosystem
//! Subdomain takeover detection via dangling DNS records.
//!
//! Detects three classes of takeover:
//!
//! 1. **CNAME takeover**: Domain has a CNAME pointing to a service (GitHub Pages,
//!    Heroku, Azure, etc.) that no longer claims the hostname. An attacker
//!    registers on the service and captures the subdomain.
//!
//! 2. **NS takeover**: Nameserver delegation points to a domain that doesn't
//!    resolve or is available for registration.
//!
//! 3. **MX takeover**: MX records point to unclaimed mail servers, an attacker
//!    can receive email for the domain.
//!
//! # Fingerprint database
//!
//! The CNAME fingerprint database is embedded at compile time from `takeovers.txt`.
//! To add a service: append `service-cname-suffix:Service Name` to the file.

use gossan_core::Target;
use hickory_resolver::{proto::rr::RecordType, TokioResolver};
use secfinding::{Evidence, Finding, FindingKind, Severity};

/// CNAME takeover fingerprints loaded at compile time.
static FINGERPRINTS: &str = include_str!("takeovers.txt");

/// Run all takeover checks: CNAME, NS, and MX.
pub async fn check(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
    let mut findings = Vec::new();

    findings.extend(check_cname(resolver, domain, target).await);
    findings.extend(check_ns(resolver, domain, target).await);
    findings.extend(check_mx(resolver, domain, target).await);
    findings
}

/// Parse the fingerprint database into (suffix, service_name) pairs.
pub fn fingerprints() -> Vec<(&'static str, &'static str)> {
    FINGERPRINTS
        .lines()
        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
        .filter_map(|l| {
            let (suffix, name) = l.split_once(':')?;
            Some((suffix.trim(), name.trim()))
        })
        .collect()
}

// ── CNAME takeover ──────────────────────────────────────────────────────────

/// Detect dangling CNAMEs pointing at 60+ service fingerprints.

/// True when the name has no A records (NXDOMAIN / empty), not on transient DNS errors.
async fn lookup_name_absent(resolver: &TokioResolver, name: &str) -> bool {
    match resolver.lookup(name, RecordType::A).await {
        Ok(lookup) => lookup.iter().next().is_none(),
        Err(e) => {
            if e.is_nx_domain() || e.is_no_records_found() {
                true
            } else {
                tracing::warn!(
                    name,
                    error = %e,
                    "dns takeover: A lookup failed (not treating as dangling)"
                );
                false
            }
        }
    }
}

async fn check_cname(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
    let mut findings = Vec::new();

    let cname = match resolver.lookup(domain, RecordType::CNAME).await {
        Ok(response) => {
            let records: Vec<String> = response
                .iter()
                .filter_map(|r| {
                    if let hickory_resolver::proto::rr::RData::CNAME(c) = r {
                        Some(c.to_string().trim_end_matches('.').to_string())
                    } else {
                        None
                    }
                })
                .collect();
            if records.is_empty() {
                return findings;
            }
            records[0].clone()
        }
        Err(_) => return findings,
    };

    let fps = fingerprints();
    let matched_service = fps.iter().find(|(suffix, _)| cname.ends_with(suffix));

    let (suffix, service) = match matched_service {
        Some(s) => *s,
        None => return findings,
    };

    // 1. Check if the CNAME target actually resolves (NXDOMAIN/empty only).
    let dns_dangling = lookup_name_absent(resolver, domain).await;

    if dns_dangling {
        gossan_core::try_push_finding(Finding::builder("dns", target.domain().unwrap_or("?"), Severity::High)
                .title(format!("Dangling CNAME → {service}"))
                .detail(format!(
                    "{domain} has CNAME → {cname} (matches {suffix}{service}) but it does not resolve. \
                     An attacker might be able to register this hostname on {service}."
                ))
                .kind(FindingKind::Exposure)
                .evidence(Evidence::DnsRecord {
                    record_type: "CNAME".into(),
                    value: cname.clone().into(),
                })
                .tag("takeover")
                .tag("cname")
                .tag("dangling"), &mut findings);
    }

    findings
}

// ── NS takeover ─────────────────────────────────────────────────────────────

/// Detect dangling nameserver delegations.
///
/// If a domain's NS record points to a nameserver that doesn't resolve,
/// an attacker who registers that nameserver domain controls all DNS for
/// the victim domain (the most severe form of takeover).
async fn check_ns(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
    let mut findings = Vec::new();

    let ns_records = match resolver.lookup(domain, RecordType::NS).await {
        Ok(r) => r,
        Err(_) => return findings,
    };

    let nameservers: Vec<String> = ns_records
        .iter()
        .filter_map(|r| {
            if let hickory_resolver::proto::rr::RData::NS(ns) = r {
                Some(ns.to_string().trim_end_matches('.').to_string())
            } else {
                None
            }
        })
        .collect();

    for ns in &nameservers {
        if lookup_name_absent(resolver, ns.as_str()).await {
            gossan_core::try_push_finding(
                Finding::builder("dns", target.domain().unwrap_or("?"), Severity::Critical)
                    .title(format!("NS takeover: dangling nameserver {ns}"))
                    .detail(format!(
                        "{domain} delegates DNS to {ns} which does not resolve. \
                         If an attacker registers {ns}, they control ALL DNS records \
                         for {domain} (full domain hijack)."
                    ))
                    .kind(FindingKind::Vulnerability)
                    .evidence(Evidence::DnsRecord {
                        record_type: "NS".into(),
                        value: ns.clone().into(),
                    })
                    .tag("takeover")
                    .tag("ns")
                    .tag("critical"),
                &mut findings,
            );
        }
    }

    // NS count resilience
    if nameservers.len() < 2 {
        gossan_core::try_push_finding(
            Finding::builder("dns", target.domain().unwrap_or("?"), Severity::Low)
                .title(format!(
                    "Only {} nameserver(s), no redundancy",
                    nameservers.len()
                ))
                .detail(format!(
                    "{domain} has {count} NS record(s). RFC 2182 recommends ≥2. \
                     Single nameserver failure = total domain outage.",
                    count = nameservers.len()
                ))
                .kind(FindingKind::Vulnerability)
                .tag("dns")
                .tag("resilience"),
            &mut findings,
        );
    }

    findings
}

// ── MX takeover ─────────────────────────────────────────────────────────────

/// Detect dangling MX records pointing to unresolvable mail servers.
///
/// An attacker who controls the MX target can receive all email sent to
/// the domain (password resets, MFA codes, confidential communications).
async fn check_mx(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
    let mut findings = Vec::new();

    let mx_records = match resolver.mx_lookup(domain).await {
        Ok(r) => r,
        Err(_) => return findings,
    };

    let exchanges: Vec<String> = mx_records
        .iter()
        .map(|mx| mx.exchange().to_string().trim_end_matches('.').to_string())
        .collect();

    for mx in &exchanges {
        if mx == "." {
            continue; // null MX (RFC 7505), intentional
        }
        if lookup_name_absent(resolver, mx.as_str()).await {
            gossan_core::try_push_finding(
                Finding::builder("dns", target.domain().unwrap_or("?"), Severity::High)
                    .title(format!("MX takeover risk: dangling mail server {mx}"))
                    .detail(format!(
                        "{domain} MX record points to {mx} which does not resolve. \
                         If an attacker claims this hostname, they receive all email \
                         for {domain} (password resets, MFA codes, confidential data)."
                    ))
                    .kind(FindingKind::Vulnerability)
                    .evidence(Evidence::DnsRecord {
                        record_type: "MX".into(),
                        value: mx.clone().into(),
                    })
                    .tag("takeover")
                    .tag("mx"),
                &mut findings,
            );
        }
    }

    findings
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fingerprint_database_loads() {
        let fps = fingerprints();
        assert!(
            fps.len() >= 50,
            "should have 50+ service fingerprints, got {}",
            fps.len()
        );
    }

    #[test]
    fn fingerprints_have_valid_format() {
        for (suffix, name) in fingerprints() {
            assert!(!suffix.is_empty(), "empty suffix found");
            assert!(!name.is_empty(), "empty name for suffix: {suffix}");
            assert!(suffix.contains('.'), "suffix should be a domain: {suffix}");
        }
    }

    #[test]
    fn known_services_present() {
        let fps = fingerprints();
        let suffixes: Vec<&str> = fps.iter().map(|(s, _)| *s).collect();
        for expected in [
            "github.io",
            "herokuapp.com",
            "amazonaws.com",
            "azurewebsites.net",
            "netlify.app",
            "vercel.app",
        ] {
            assert!(
                suffixes.contains(&expected),
                "missing fingerprint: {expected}"
            );
        }
    }

    #[test]
    fn no_duplicate_suffixes() {
        let fps = fingerprints();
        let mut seen = std::collections::HashSet::new();
        for (suffix, name) in &fps {
            assert!(seen.insert(*suffix), "duplicate suffix: {suffix} ({name})");
        }
    }
}