pub mod module;
pub use module::NetworkDnsModule;
use hickory_resolver::proto::rr::{RData, RecordType};
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DnsCheckResult {
pub caa_present: bool,
pub dnssec_detected: bool,
pub mx_present: bool,
pub spf_present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsFinding {
pub rule_id: String,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkDnsAnalysis {
pub host: String,
pub caa_present: bool,
pub dnssec_detected: bool,
pub mx_present: bool,
pub spf_present: Option<bool>,
pub findings: Vec<DnsFinding>,
}
pub fn finding_message_text(rule_id: &str, en: bool) -> String {
match rule_id {
"dns.caa_missing" => if en {
"No CAA record was found for this domain. Without one, any certificate authority \
is allowed to issue TLS certificates for it."
} else {
"Für diese Domain wurde kein CAA-Eintrag gefunden. Ohne CAA-Eintrag darf jede \
Zertifizierungsstelle TLS-Zertifikate für sie ausstellen."
}
.to_string(),
"dns.dnssec_not_detected" => if en {
"No DNSKEY record was found at this domain's apex — DNSSEC signing does not \
appear to be active."
} else {
"Am Apex dieser Domain wurde kein DNSKEY-Eintrag gefunden — DNSSEC-Signierung \
scheint nicht aktiv zu sein."
}
.to_string(),
"dns.spf_missing" => if en {
"This domain has an MX record (mail is routed here) but no SPF TXT record, \
making it easier for others to spoof its sender addresses."
} else {
"Diese Domain hat einen MX-Eintrag (Mail wird hierhin geroutet), aber keinen \
SPF-TXT-Eintrag, wodurch sich ihre Absenderadressen leichter fälschen lassen."
}
.to_string(),
_ => String::new(),
}
}
pub fn build_analysis(host: &str, result: &DnsCheckResult) -> NetworkDnsAnalysis {
let mut findings = Vec::new();
if !result.caa_present {
findings.push(DnsFinding {
rule_id: "dns.caa_missing".to_string(),
message: finding_message_text("dns.caa_missing", true),
});
}
if !result.dnssec_detected {
findings.push(DnsFinding {
rule_id: "dns.dnssec_not_detected".to_string(),
message: finding_message_text("dns.dnssec_not_detected", true),
});
}
let spf_present = if result.mx_present {
if !result.spf_present {
findings.push(DnsFinding {
rule_id: "dns.spf_missing".to_string(),
message: finding_message_text("dns.spf_missing", true),
});
}
Some(result.spf_present)
} else {
None
};
NetworkDnsAnalysis {
host: host.to_string(),
caa_present: result.caa_present,
dnssec_detected: result.dnssec_detected,
mx_present: result.mx_present,
spf_present,
findings,
}
}
pub async fn query_dns(host: &str) -> Option<DnsCheckResult> {
let resolver: TokioResolver = match TokioResolver::builder_tokio() {
Ok(builder) => match builder.build() {
Ok(resolver) => resolver,
Err(e) => {
warn!("Failed to build DNS resolver for {host}: {e}");
return None;
}
},
Err(e) => {
warn!("Failed to configure DNS resolver for {host}: {e}");
return None;
}
};
let fqdn = if host.ends_with('.') {
host.to_string()
} else {
format!("{host}.")
};
let caa_present = resolver
.lookup(fqdn.as_str(), RecordType::CAA)
.await
.map(|l| !l.answers().is_empty())
.unwrap_or(false);
let dnssec_detected = resolver
.lookup(fqdn.as_str(), RecordType::DNSKEY)
.await
.map(|l| !l.answers().is_empty())
.unwrap_or(false);
let mx_present = resolver
.mx_lookup(fqdn.as_str())
.await
.map(|l| !l.answers().is_empty())
.unwrap_or(false);
let spf_present = if mx_present {
resolver
.txt_lookup(fqdn.as_str())
.await
.map(|l| {
l.answers().iter().any(|record| {
matches!(&record.data, RData::TXT(txt) if txt.to_string().starts_with("v=spf1"))
})
})
.unwrap_or(false)
} else {
false
};
Some(DnsCheckResult {
caa_present,
dnssec_detected,
mx_present,
spf_present,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_present_produces_no_findings() {
let result = DnsCheckResult {
caa_present: true,
dnssec_detected: true,
mx_present: true,
spf_present: true,
};
let analysis = build_analysis("example.com", &result);
assert!(analysis.findings.is_empty());
assert_eq!(analysis.spf_present, Some(true));
}
#[test]
fn caa_missing_produces_finding() {
let result = DnsCheckResult {
caa_present: false,
dnssec_detected: true,
mx_present: false,
spf_present: false,
};
let analysis = build_analysis("example.com", &result);
assert_eq!(analysis.findings.len(), 1);
assert_eq!(analysis.findings[0].rule_id, "dns.caa_missing");
}
#[test]
fn dnssec_not_detected_produces_finding() {
let result = DnsCheckResult {
caa_present: true,
dnssec_detected: false,
mx_present: false,
spf_present: false,
};
let analysis = build_analysis("example.com", &result);
assert_eq!(analysis.findings.len(), 1);
assert_eq!(analysis.findings[0].rule_id, "dns.dnssec_not_detected");
}
#[test]
fn spf_missing_only_fires_when_mx_present() {
let no_mx = DnsCheckResult {
caa_present: true,
dnssec_detected: true,
mx_present: false,
spf_present: false,
};
let analysis = build_analysis("example.com", &no_mx);
assert!(analysis.findings.is_empty());
assert_eq!(analysis.spf_present, None);
let mx_without_spf = DnsCheckResult {
caa_present: true,
dnssec_detected: true,
mx_present: true,
spf_present: false,
};
let analysis = build_analysis("example.com", &mx_without_spf);
assert_eq!(analysis.findings.len(), 1);
assert_eq!(analysis.findings[0].rule_id, "dns.spf_missing");
assert_eq!(analysis.spf_present, Some(false));
}
#[test]
fn mx_with_spf_produces_no_spf_finding() {
let result = DnsCheckResult {
caa_present: true,
dnssec_detected: true,
mx_present: true,
spf_present: true,
};
let analysis = build_analysis("example.com", &result);
assert!(analysis.findings.is_empty());
assert_eq!(analysis.spf_present, Some(true));
}
#[test]
fn all_missing_produces_three_findings() {
let result = DnsCheckResult {
caa_present: false,
dnssec_detected: false,
mx_present: true,
spf_present: false,
};
let analysis = build_analysis("example.com", &result);
assert_eq!(analysis.findings.len(), 3);
}
#[test]
fn english_message_text_has_no_known_german_leaks() {
let has_umlaut = |s: &str| s.chars().any(|c| "äöüÄÖÜß".contains(c));
for rule_id in [
"dns.caa_missing",
"dns.dnssec_not_detected",
"dns.spf_missing",
] {
let msg = finding_message_text(rule_id, true);
assert!(!msg.is_empty(), "{rule_id} must produce a message");
assert!(
!has_umlaut(&msg),
"{rule_id} EN message leaks German: {msg}"
);
}
}
#[test]
fn german_message_text_differs_from_english() {
for rule_id in [
"dns.caa_missing",
"dns.dnssec_not_detected",
"dns.spf_missing",
] {
let en = finding_message_text(rule_id, true);
let de = finding_message_text(rule_id, false);
assert_ne!(en, de, "{rule_id} must have a distinct German translation");
}
}
}