use crate::util::is_routable_ip;
use crate::OriginCandidate;
use futures::future::join_all;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::TokioResolver;
use std::net::IpAddr;
use std::str::FromStr;
pub async fn scan(domain: String) -> anyhow::Result<Vec<OriginCandidate>> {
let mut candidates = Vec::new();
let resolver = TokioResolver::builder_with_config(
ResolverConfig::default(),
TokioConnectionProvider::default(),
)
.with_options(ResolverOpts::default())
.build();
match resolver.mx_lookup(domain.clone()).await {
Ok(mx_lookup) => {
for mx in mx_lookup {
let exchange = mx.exchange().to_string();
match resolver.ipv4_lookup(&exchange).await {
Ok(a_lookup) => {
for ip in a_lookup {
let addr = IpAddr::V4(ip.0);
if is_routable_ip(addr) {
candidates.push(OriginCandidate::new(
addr,
format!("dns_misconfig_mx_a ({exchange})"),
60,
));
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(
exchange = %exchange,
error = %e,
"dns_misconfig MX A lookup failed; skipping exchange"
);
}
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(domain = %domain, error = %e, "dns_misconfig MX lookup failed");
}
}
match resolver.txt_lookup(domain.clone()).await {
Ok(txt_lookup) => {
for txt in txt_lookup {
let string_data = txt.to_string();
if string_data.contains("ip4:") {
let parts = string_data.split("ip4:");
for p in parts.skip(1) {
let ip_str = p.split([' ', '/']).next().unwrap_or("");
if let Ok(ip) = std::net::Ipv4Addr::from_str(ip_str) {
let addr = IpAddr::V4(ip);
if is_routable_ip(addr) {
candidates.push(OriginCandidate::new(
addr,
"dns_misconfig_spf_ip4",
85,
));
}
}
}
}
if string_data.contains("ip6:") {
let parts = string_data.split("ip6:");
for p in parts.skip(1) {
let ip_str = p.split([' ', '/']).next().unwrap_or("");
if let Ok(ip) = std::net::Ipv6Addr::from_str(ip_str) {
let addr = IpAddr::V6(ip);
if is_routable_ip(addr) {
candidates.push(OriginCandidate::new(
addr,
"dns_misconfig_spf_ip6",
85,
));
}
}
}
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(domain = %domain, error = %e, "dns_misconfig SPF/TXT lookup failed");
}
}
let dmarc_domain = format!("_dmarc.{}", domain);
match resolver.txt_lookup(&dmarc_domain).await {
Ok(txt_lookup) => {
for txt in txt_lookup {
let string_data = txt.to_string();
if let Some(rua_domain) = parse_dmarc_rua(&string_data) {
match resolver.ipv4_lookup(&rua_domain).await {
Ok(a_lookup) => {
for ip in a_lookup {
let addr = IpAddr::V4(ip.0);
if is_routable_ip(addr) {
candidates.push(OriginCandidate::new(
addr,
format!("dns_misconfig_dmarc_rua ({rua_domain})"),
70,
));
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(
rua_domain = %rua_domain,
error = %e,
"dns_misconfig DMARC RUA A lookup failed; skipping"
);
}
}
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(domain = %dmarc_domain, error = %e, "dns_misconfig DMARC TXT lookup failed");
}
}
let bypass_subs = [
"direct", "origin", "mail", "ftp", "cpanel", "staging", "dev", "test", "api", "admin",
"portal", "app", "beta", "prod", "www",
];
let fqdns: Vec<String> = bypass_subs
.iter()
.map(|sub| format!("{}.{}", sub, domain))
.collect();
let lookups: Vec<_> = fqdns
.iter()
.map(|fqdn| {
let resolver = resolver.clone();
let fqdn = fqdn.clone();
async move {
match resolver.ipv4_lookup(&fqdn).await {
Ok(lookup) => Some(lookup),
Err(e) => {
if !(e.is_nx_domain() || e.is_no_records_found()) {
tracing::warn!(
fqdn = %fqdn,
error = %e,
"dns_misconfig bypass-sub lookup failed; skipping label"
);
}
None
}
}
}
})
.collect();
for (fqdn, lookup) in fqdns.into_iter().zip(join_all(lookups).await) {
if let Some(lookup) = lookup {
for ip in lookup {
let addr = IpAddr::V4(ip.0);
if is_routable_ip(addr) {
candidates.push(OriginCandidate::new(
addr,
format!("dns_misconfig_bypass_sub ({fqdn})"),
75,
));
}
}
}
}
Ok(candidates)
}
fn parse_dmarc_rua(string_data: &str) -> Option<String> {
let string_data_lower = string_data.to_lowercase();
if !string_data_lower.contains("v=dmarc1") {
return None;
}
let rua_start = string_data_lower.find("rua=")?;
let after_rua = &string_data_lower[rua_start + 4..];
let rua_val = after_rua
.split(|c| c == ';' || c == '!')
.next()
.unwrap_or(after_rua)
.trim();
let email_part = rua_val.strip_prefix("mailto:").unwrap_or(rua_val);
let at_pos = email_part.find('@')?;
Some(email_part[at_pos + 1..].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dmarc_unicode_does_not_panic() {
let txt = "v=DMARC1; rua=mailto:reports@İexample.com";
let rua = parse_dmarc_rua(txt);
assert_eq!(rua, Some("i\u{0307}example.com".to_string()));
}
#[test]
fn dmarc_basic_parsing() {
let txt = "v=DMARC1; p=reject; rua=mailto:dmarc@example.com";
assert_eq!(parse_dmarc_rua(txt), Some("example.com".to_string()));
}
#[test]
fn dmarc_no_rua() {
let txt = "v=DMARC1; p=reject";
assert_eq!(parse_dmarc_rua(txt), None);
}
#[test]
fn dmarc_no_at_sign() {
let txt = "v=DMARC1; rua=mailto:nodomain";
assert_eq!(parse_dmarc_rua(txt), None);
}
#[test]
fn dmarc_rua_with_size_suffix() {
let txt = "v=DMARC1; rua=mailto:reports@example.com!10m";
assert_eq!(parse_dmarc_rua(txt), Some("example.com".to_string()));
}
}