51 views +0 -0

ZorgMail NTA 7516 DNS check

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 }