Controleert SPF, DKIM en DMARC voor domeinen die veilig mailen via ZorgMail / M365.
Per domein:
- MX records
- SPF: aanwezig, syntax, all-qualifier, recursieve DNS-lookupcount (limiet 10), void lookups
- DKIM: controleert een lijst selectors (Microsoft 365 + ZorgMail)
- DMARC: aanwezig, policy, rua/ruf
.\Test-NTA7516MailDNS.ps1 -Domain voorbeeld.nl
.\Test-NTA7516MailDNS.ps1 -Domain domein1.nl,domein2.nl -HtmlPath C:\rapport.html
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Domain,
[string[]]$DkimSelector = @('selector1','selector2','zorgmail','zorgmail1','zorgmail2','enovation','default','s1','s2','k1'),
[string[]]$DnsServer = @('8.8.8.8','1.1.1.1'),
[int]$Retries = 3,
[string]$CsvPath,
[string]$HtmlPath
)
# ----------------------------------------------------------------------
# DNS-query met retries en fallback-resolver.
# Geeft een status terug: ok / nxdomain (naam bestaat niet) /
# norecord (naam bestaat, geen record van dit type) / timeout.
# Zo wordt een timeout nooit meer verward met een ontbrekend record.
# ----------------------------------------------------------------------
function Invoke-DnsQuery {
param(
[string]$Name,
[ValidateSet('A','TXT','MX','CNAME','SOA')][string]$Type
)
foreach ($srv in $DnsServer) {
for ($i = 1; $i -le $Retries; $i++) {
try {
$r = Resolve-DnsName -Name $Name -Type $Type -Server $srv -DnsOnly -ErrorAction Stop
return @{ Status = 'ok'; Records = @($r) }
} catch {
$code = $null
try { $code = [int]$_.Exception.NativeErrorCode } catch {}
if ($code -eq 9003) { return @{ Status = 'nxdomain'; Records = @() } } # naam bestaat niet
elseif ($code -eq 9501) { return @{ Status = 'norecord'; Records = @() } } # geen record van dit type
else { Start-Sleep -Milliseconds 300 } # timeout/servfail: opnieuw
}
}
}
return @{ Status = 'timeout'; Records = @() }
}
function Get-Txt {
param([string]$Name)
$q = Invoke-DnsQuery -Name $Name -Type TXT
@($q.Records | Where-Object { $_.Type -eq 'TXT' } | ForEach-Object { ($_.Strings -join '') })
}
function Get-SpfRecord {
param([string]$Name)
(Get-Txt -Name $Name | Where-Object { $_ -match '^v=spf1(\s|$)' }) | Select-Object -First 1
}
# Recursieve teller conform RFC 7208 sectie 4.6.4: a, mx, ptr, exists, include en redirect tellen mee.
function Measure-SpfLookup {
param(
[string]$Name,
[System.Collections.Generic.HashSet[string]]$Seen,
[int]$Depth = 0,
[ref]$Void,
[ref]$Trace
)
if ($Depth -gt 10) { return 0 }
$rec = Get-SpfRecord -Name $Name
if (-not $rec) {
$Void.Value++
$Trace.Value += ('{0}[void] {1}: geen SPF-record' -f (' ' * $Depth), $Name)
return 0
}
$Trace.Value += ('{0}{1}' -f (' ' * $Depth), $Name)
$count = 0
foreach ($term in ($rec -split '\s+')) {
$t = $term.ToLower().TrimStart('+','-','~','?')
switch -Regex ($t) {
'^(a|mx|ptr)$' { $count++ }
'^(a|mx|ptr|exists):' { $count++ }
'^include:' {
$count++
$sub = $term.Substring($term.IndexOf(':') + 1)
if ($Seen.Add($sub)) {
$count += Measure-SpfLookup -Name $sub -Seen $Seen -Depth ($Depth + 1) -Void $Void -Trace $Trace
}
}
'^redirect=' {
$count++
$sub = $term.Substring($term.IndexOf('=') + 1)
if ($Seen.Add($sub)) {
$count += Measure-SpfLookup -Name $sub -Seen $Seen -Depth ($Depth + 1) -Void $Void -Trace $Trace
}
}
}
}
return $count
}
# ----------------------------------------------------------------------
# DKIM-selector nakijken. Volgt automatisch een CNAME (Microsoft 365)
# naar de onderliggende sleutel en bepaalt de sleutellengte.
# ----------------------------------------------------------------------
function Get-DkimStatus {
param([string]$Selector,[string]$Domain)
$name = "$Selector._domainkey.$Domain"
$q = Invoke-DnsQuery -Name $name -Type TXT
$cname = @($q.Records | Where-Object { $_.Type -eq 'CNAME' } | ForEach-Object { $_.NameHost }) | Select-Object -First 1
$txt = @($q.Records | Where-Object { $_.Type -eq 'TXT' } | ForEach-Object { ($_.Strings -join '') } |
Where-Object { $_ -match 'DKIM1|p=' }) | Select-Object -First 1
$state = 'missing'; $bits = ''
if ($txt) {
if ($txt -match 'p=\s*([A-Za-z0-9+/=]{20,})') {
$len = [System.Convert]::FromBase64String($Matches[1]).Length
$bits = if ($len -gt 250) { '2048-bit' } elseif ($len -gt 100) { '1024-bit' } else { 'korte sleutel' }
$state = 'active'
}
elseif ($txt -match 'p=\s*(;|$)') {
$state = 'revoked' # sleutel expliciet leeg
}
else {
$state = 'active'; $bits = 'sleutellengte onbekend'
}
}
elseif ($q.Status -eq 'timeout') {
# Resolver bereikbaar? Zo ja, dan bestaat de selector echt niet.
if ((Invoke-DnsQuery -Name $Domain -Type SOA).Status -eq 'ok') { $state = 'missing' }
else { $state = 'timeout' }
}
else {
$state = if ($cname) { 'cname-nokey' } else { 'missing' }
}
[pscustomobject]@{ Selector = $Selector; State = $state; Bits = $bits; Cname = $cname }
}
function Format-Check {
param([bool]$Ok,[string]$OkText,[string]$FailText)
if ($Ok) { "OK $OkText" } else { "MIST $FailText" }
}
$results = @()
foreach ($d in $Domain) {
$issues = @()
$trace = @()
$void = 0
# ---- MX ----
$mxQ = Invoke-DnsQuery -Name $d -Type MX
$mx = @($mxQ.Records | Where-Object { $_.Type -eq 'MX' } | Sort-Object Preference |
ForEach-Object { '{0} {1}' -f $_.Preference, $_.NameExchange })
$mxOk = [bool]$mx
if (-not $mxOk) { $issues += 'Geen MX-records gevonden' }
# ---- SPF ----
$spf = Get-SpfRecord -Name $d
$spfCount = 0; $spfAll = ''
$spfHasZorgmail = $false; $spfHasM365 = $false
if (-not $spf) {
$issues += 'SPF ontbreekt'
} else {
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
$vr = [ref]$void; $tr = [ref]$trace
$spfCount = Measure-SpfLookup -Name $d -Seen $seen -Void $vr -Trace $tr
$void = $vr.Value; $trace = $tr.Value
if ($spfCount -gt 10) { $issues += "SPF over de lookuplimiet ($spfCount van 10), geeft permerror" }
elseif ($spfCount -eq 10) { $issues += 'SPF exact op de lookuplimiet (10), geen ruimte meer' }
if ($void -gt 0) { $issues += "$void void lookup(s) in de SPF-keten (max 2)" }
if ($spf -match '(?<q>[-~?+])all\s*$') { $spfAll = $Matches['q'] + 'all' } else { $issues += 'SPF zonder afsluitend all-mechanisme' }
$spfHasZorgmail = $spf -match 'zorgmail|ezorg'
$spfHasM365 = $spf -match 'spf\.protection\.outlook\.com'
if (-not $spfHasZorgmail) { $issues += 'SPF mist ZorgMail/EZorg include, uitgaand via Safe Relay faalt SPF' }
if (-not $spfHasM365) { $issues += 'SPF mist Exchange Online include' }
}
$spfOk = $spf -and ($spfCount -le 10) -and $spfHasZorgmail -and $spfHasM365 -and $spfAll -and ($void -le 2)
# ---- DKIM ----
$dkim = @()
foreach ($s in ($DkimSelector | Select-Object -Unique)) {
$st = Get-DkimStatus -Selector $s -Domain $d
if ($st.State -ne 'missing' -or $s -match 'selector1|selector2|zorgmail') { $dkim += $st }
}
$ms = $dkim | Where-Object { $_.Selector -match '^selector[12]$' -and $_.State -eq 'active' }
$zm = $dkim | Where-Object { $_.Selector -match 'zorgmail' -and $_.State -eq 'active' }
$zmState = ($dkim | Where-Object { $_.Selector -eq 'zorgmail' } | Select-Object -First 1).State
$m365Ok = [bool]$ms
$zmOk = [bool]$zm
if (-not $m365Ok) { $issues += 'Microsoft 365 DKIM (selector1/2) niet actief' }
if (-not $zmOk) {
switch ($zmState) {
'timeout' { $issues += 'ZorgMail DKIM kon niet gecontroleerd worden (DNS-timeout)' }
'revoked' { $issues += 'ZorgMail DKIM-sleutel is leeg/ingetrokken' }
default { $issues += 'ZorgMail DKIM-record ontbreekt, aanvragen bij Enovation (nog niet gepubliceerd)' }
}
}
$dkimSummary = ($dkim | ForEach-Object {
$desc = switch ($_.State) {
'active' { if ($_.Bits) { $_.Bits } else { 'actief' } }
'revoked' { 'ingetrokken (lege sleutel)' }
'cname-nokey' { 'CNAME zonder sleutel' }
'timeout' { 'timeout' }
default { 'ontbreekt' }
}
'{0}={1}' -f $_.Selector, $desc
}) -join '; '
# ---- DMARC ----
$dmarc = (Get-Txt -Name "_dmarc.$d" | Where-Object { $_ -match '^v=DMARC1' }) | Select-Object -First 1
$policy = ''; $dmarcOk = $false
if (-not $dmarc) {
$issues += 'DMARC ontbreekt'
} else {
if ($dmarc -match 'p=(none|quarantine|reject)') { $policy = $Matches[1] }
if ($policy -eq 'none') { $issues += 'DMARC policy staat op p=none, geen handhaving' }
if ($dmarc -notmatch 'rua=') { $issues += 'DMARC zonder rua, geen aggregate rapportage' }
$dmarcOk = ($policy -in 'quarantine','reject') -and ($dmarc -match 'rua=')
}
# ---- NTA 7516-eindoordeel ----
$ntaReady = $mxOk -and $spfOk -and $m365Ok -and $zmOk -and $dmarcOk
$results += [pscustomobject]@{
Domein = $d
MX = ($mx -join '; ')
MXok = $mxOk
SPF = $spf
SpfLookups = $spfCount
SpfAll = $spfAll
SpfVoid = $void
SPFok = $spfOk
DKIM = $dkimSummary
DkimM365ok = $m365Ok
DkimZorgMailok = $zmOk
DMARC = $dmarc
DmarcPolicy = $policy
DMARCok = $dmarcOk
NTA7516 = if ($ntaReady) { 'VOLDOET' } else { 'ACTIE NODIG' }
Bevindingen = ($issues -join ' | ')
SpfKeten = ($trace -join "`n")
}
}
# ---- Console-rapport ----
foreach ($r in $results) {
Write-Host ''
Write-Host ('=' * 72)
Write-Host $r.Domein -ForegroundColor Cyan
Write-Host ('=' * 72)
$green = @{ ForegroundColor = 'Green' }; $yellow = @{ ForegroundColor = 'Yellow' }
Write-Host ("MX : " ) -NoNewline
Write-Host (Format-Check $r.MXok $r.MX '(geen MX)') @($(if ($r.MXok) { $green } else { $yellow }))
Write-Host ("SPF : " ) -NoNewline
$spfText = if ($r.SPF) { "$($r.SpfLookups)/10 lookups, $($r.SpfAll), void $($r.SpfVoid)" } else { '' }
Write-Host (Format-Check $r.SPFok $spfText '(SPF onvolledig, zie bevindingen)') @($(if ($r.SPFok) { $green } else { $yellow }))
Write-Host ("DKIM Microsoft: " ) -NoNewline
Write-Host (Format-Check $r.DkimM365ok 'selector1/2 actief' '(M365 DKIM niet actief)') @($(if ($r.DkimM365ok) { $green } else { $yellow }))
Write-Host ("DKIM ZorgMail : " ) -NoNewline
Write-Host (Format-Check $r.DkimZorgMailok 'zorgmail-selector actief' '(nog niet gepubliceerd)') @($(if ($r.DkimZorgMailok) { $green } else { $yellow }))
Write-Host ("DMARC : " ) -NoNewline
Write-Host (Format-Check $r.DMARCok "p=$($r.DmarcPolicy), rua aanwezig" '(DMARC onvolledig)') @($(if ($r.DMARCok) { $green } else { $yellow }))
Write-Host (" DKIM-detail : {0}" -f $r.DKIM) -ForegroundColor DarkGray
Write-Host ''
if ($r.NTA7516 -eq 'VOLDOET') {
Write-Host " => VOLDOET AAN NTA 7516" @green
} else {
Write-Host " => ACTIE NODIG:" @yellow
foreach ($i in ($r.Bevindingen -split ' \| ')) { Write-Host (" - {0}" -f $i) @yellow }
}
}
Write-Host ''
$results | Select-Object Domein, SpfLookups, DkimM365ok, DkimZorgMailok, DmarcPolicy, NTA7516 | Format-Table -AutoSize
if ($CsvPath) {
$results | Select-Object Domein,MX,SPF,SpfLookups,SpfAll,SpfVoid,SPFok,DKIM,DkimM365ok,DkimZorgMailok,DMARC,DmarcPolicy,DMARCok,NTA7516,Bevindingen |
Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host "CSV weggeschreven naar $CsvPath"
}
if ($HtmlPath) {
$style = '<style>body{font-family:Segoe UI,Arial;font-size:13px}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:6px;vertical-align:top}th{background:#f0f0f0;text-align:left}</style>'
$results | Select-Object Domein,NTA7516,SPF,SpfLookups,DKIM,DMARC,DmarcPolicy,Bevindingen |
ConvertTo-Html -Head $style -Title 'NTA 7516 DNS check' -PreContent ("<h2>NTA 7516 DNS check</h2><p>Gegenereerd: {0}</p>" -f (Get-Date)) |
Out-File -FilePath $HtmlPath -Encoding UTF8
Write-Host "HTML weggeschreven naar $HtmlPath"
}
if ($results.NTA7516 -contains 'ACTIE NODIG') { exit 1 } else { exit 0 }