EmailDnsAudit.ps1
|
<#
.SYNOPSIS EmailDnsAudit scans domains for email DNS records (MX, SPF, DMARC, and DKIM) and generates audit reports. .DESCRIPTION EmailDnsAudit can load domains from direct input, a file, or Exchange Online accepted domains. It checks core email DNS controls and produces console output plus optional HTML, CSV, and JSON reports. .NOTES Developer: Muataz Awad #> param( [Parameter(Mandatory = $false)] [string]$InputFile, [Parameter(Mandatory = $false)] [string[]]$DomainInput, [Parameter(Mandatory = $false)] [string]$OutputCsv, [Parameter(Mandatory = $false)] [string]$OutputHtml, [Parameter(Mandatory = $false)] [string]$OutputJson, [Parameter(Mandatory = $false)] [switch]$ShowAll, [Parameter(Mandatory = $false)] [switch]$PromptForFile, [Parameter(Mandatory = $false)] [switch]$UseExchangeAcceptedDomains, [Parameter(Mandatory = $false)] [string]$ExchangeOrganization, [Parameter(Mandatory = $false)] [bool]$ExcludeOnMicrosoftDomains = $true, [Parameter(Mandatory = $false)] [string[]]$DkimSelectors, [Parameter(Mandatory = $false)] [string]$DnsServer, [Parameter(Mandatory = $false)] [ValidateRange(1, 30)] [int]$TimeoutSeconds = 5, [Parameter(Mandatory = $false)] [bool]$ShowProgress = $true, [Parameter(Mandatory = $false)] [string]$DeveloperName = 'Muataz Awad', [Parameter(Mandatory = $false)] [switch]$FilterMxNotMicrosoft, [Parameter(Mandatory = $false)] [switch]$FilterMissingAnyCore, [Parameter(Mandatory = $false)] [switch]$FilterMissingAllCore, [Parameter(Mandatory = $false)] [ValidateSet('MX', 'SPF', 'DMARC', 'DKIM')] [string[]]$FilterMissingAnyOf, [Parameter(Mandatory = $false)] [ValidateSet('MX', 'SPF', 'DMARC', 'DKIM')] [string[]]$FilterMissingAllOf, [Parameter(Mandatory = $false)] [switch]$NoOpenHtml ) function Get-InputFilePathFromConsole { param( [Parameter(Mandatory = $true)] [string]$StartDirectory ) Write-Host 'Choose an input option:' -ForegroundColor Yellow Write-Host '[1] Type domain(s) directly' Write-Host '[2] Sign in to Exchange Online and import accepted domains' Write-Host '[3] Browse and select a domains text file (.txt)' $selection = Read-Host 'Enter 1, 2, or 3' if ($selection -eq '1') { return '__DIRECT_DOMAIN__' } if ($selection -eq '2') { return '__EXO_DOMAINS__' } if ($selection -eq '3') { return '__POPUP_FILE__' } Write-Warning 'Invalid selection. Please choose 1, 2, or 3.' return '' } function Get-InputFilePathFromPopup { param( [Parameter(Mandatory = $true)] [string]$StartDirectory ) $windowsPowerShell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' if (Test-Path $windowsPowerShell) { $helperScriptPath = Join-Path $env:TEMP ('dns-popup-' + [guid]::NewGuid().ToString() + '.ps1') $helperResultPath = Join-Path $env:TEMP ('dns-popup-' + [guid]::NewGuid().ToString() + '.txt') $helperScriptTemplate = @' Add-Type -AssemblyName System.Windows.Forms $owner = New-Object System.Windows.Forms.Form $owner.TopMost = $true $owner.StartPosition = 'CenterScreen' $owner.ShowInTaskbar = $false $owner.FormBorderStyle = 'FixedToolWindow' $owner.Opacity = 0 $owner.Width = 1 $owner.Height = 1 $owner.Show() $owner.Activate() $dialog = New-Object System.Windows.Forms.OpenFileDialog $dialog.Title = 'Select domains text file' $dialog.Filter = 'Text files (*.txt)|*.txt|All files (*.*)|*.*' $dialog.Multiselect = $false if (Test-Path '__STARTDIR__') { $dialog.InitialDirectory = '__STARTDIR__' } $selection = $dialog.ShowDialog($owner) $owner.Close() $owner.Dispose() if ($selection -eq [System.Windows.Forms.DialogResult]::OK) { Set-Content -Path '__RESULTPATH__' -Value $dialog.FileName -Encoding UTF8 } '@ $helperScript = $helperScriptTemplate.Replace('__STARTDIR__', $StartDirectory.Replace("'", "''")).Replace('__RESULTPATH__', $helperResultPath.Replace("'", "''")) Set-Content -Path $helperScriptPath -Value $helperScript -Encoding UTF8 Write-Host 'Opening file picker popup...' -ForegroundColor Yellow $pickerProcess = Start-Process -FilePath $windowsPowerShell -ArgumentList @('-NoProfile', '-STA', '-File', $helperScriptPath) -PassThru -WindowStyle Hidden $pickerProcess.WaitForExit() $selectedPath = '' if (Test-Path $helperResultPath) { $selectedPath = (Get-Content -Path $helperResultPath -Raw).Trim() } Remove-Item -Path $helperScriptPath -ErrorAction SilentlyContinue Remove-Item -Path $helperResultPath -ErrorAction SilentlyContinue if ($selectedPath) { return $selectedPath } } Write-Warning 'Popup file picker did not return a file. Please enter the path manually.' $manualPath = Read-Host 'Enter the full path to your domains text file' return $manualPath.Trim() } function Get-DomainInputFromConsole { $rawDomainInput = Read-Host 'Enter one or more domains (comma-separated)' $domains = $rawDomainInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } return @($domains) } function Get-ExchangeAcceptedDomains { param( [Parameter(Mandatory = $false)] [string]$Organization ) if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { throw "ExchangeOnlineManagement module not found. Install it with: Install-Module ExchangeOnlineManagement -Scope CurrentUser" } Import-Module ExchangeOnlineManagement -ErrorAction Stop $connectParams = @{ ShowBanner = $false } if ($Organization) { $connectParams.Organization = $Organization } Connect-ExchangeOnline @connectParams | Out-Null try { $acceptedDomains = Get-AcceptedDomain -ErrorAction Stop return @( $acceptedDomains | ForEach-Object { $domainName = $null if ($_.DomainName) { $domainName = [string]$_.DomainName } elseif ($_.Name) { $domainName = [string]$_.Name } if ($domainName) { $isDefault = $false if ($null -ne $_.Default) { $isDefault = [bool]$_.Default } elseif ($null -ne $_.IsDefault) { $isDefault = [bool]$_.IsDefault } [PSCustomObject]@{ Domain = $domainName IsDefault = $isDefault } } } | Where-Object { $_ -and $_.Domain } ) } finally { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } } function Confirm-SelectedFile { param( [Parameter(Mandatory = $true)] [string]$SelectedPath ) Write-Host "Selected file: $SelectedPath" -ForegroundColor Cyan $confirm = Read-Host 'Use this file? [Y/n]' return ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match '^(y|yes)$') } function Get-InputFilePath { $startDirectory = (Get-Location).Path return (Get-InputFilePathFromConsole -StartDirectory $startDirectory) } if ((-not $DomainInput -or $DomainInput.Count -eq 0) -and ($PromptForFile -or -not $InputFile)) { $InputFile = Get-InputFilePath } if ($InputFile -eq '__POPUP_FILE__') { $InputFile = Get-InputFilePathFromPopup -StartDirectory (Get-Location).Path } if ($InputFile -eq '__DIRECT_DOMAIN__') { $DomainInput = Get-DomainInputFromConsole $InputFile = $null } if ($InputFile -eq '__EXO_DOMAINS__') { $UseExchangeAcceptedDomains = $true $InputFile = $null } $defaultDomainDisplay = $null if ($UseExchangeAcceptedDomains) { Write-Host 'Signing in to Exchange Online and importing accepted domains...' -ForegroundColor Yellow try { $exchangeDomains = @(Get-ExchangeAcceptedDomains -Organization $ExchangeOrganization) if ($ExcludeOnMicrosoftDomains) { $exchangeDomains = @($exchangeDomains | Where-Object { $_.Domain -notmatch '\.onmicrosoft\.com$' }) } $defaultDomainDisplay = @( $exchangeDomains | Where-Object { $_.IsDefault } | Select-Object -First 1 -ExpandProperty Domain ) | Select-Object -First 1 $domains = @( $exchangeDomains | ForEach-Object { $_.Domain.Trim() } | Where-Object { $_ } ) } catch { Write-Error "Exchange Online domain import failed: $($_.Exception.Message)" exit 1 } $inputSourceDisplay = if ($ExchangeOrganization) { "Exchange Online org: $ExchangeOrganization" } else { 'Exchange Online' } if ($ExcludeOnMicrosoftDomains) { $inputSourceDisplay += ' | excluding *.onmicrosoft.com' } } elseif ($DomainInput -and $DomainInput.Count -gt 0) { $domains = @( $DomainInput | ForEach-Object { $_.Trim() } | Where-Object { $_ } ) $inputSourceDisplay = 'Direct domain input' } else { if (-not $InputFile -or -not (Test-Path -Path $InputFile)) { Write-Error "Input file not found or not provided: $InputFile" exit 1 } $domains = @( Get-Content -Path $InputFile | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') } ) $inputSourceDisplay = $InputFile } $domains = @($domains) if (-not $domains) { Write-Warning "No domains found for source: $inputSourceDisplay" exit 0 } function Get-DnsRecordsByType { param( [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $true)] [string]$Type ) $queryParams = @{ Name = $Name Type = $Type ErrorAction = 'Stop' QuickTimeout = $true } if ($DnsServer) { $queryParams.Server = $DnsServer } $maxAttempts = 2 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { try { return @(Resolve-DnsName @queryParams) } catch { if ($attempt -lt $maxAttempts) { Start-Sleep -Milliseconds 120 continue } if ($_.Exception.Message -match 'timed out|timeout') { Write-Warning "DNS query timeout: $Name [$Type]" } return @() } } return @() } function Get-TxtValues { param( [Parameter(Mandatory = $true)] [string]$Name ) $records = Get-DnsRecordsByType -Name $Name -Type 'TXT' $values = foreach ($record in $records) { if ($record.Strings) { (($record.Strings -join '')).Trim() } } return @($values | Where-Object { $_ }) } function New-StatusBadge { param( [Parameter(Mandatory = $true)] [bool]$State ) if ($State) { return "<span class='badge ok'>Present</span>" } return "<span class='badge warn'>Missing</span>" } function Convert-ToHtmlRecordList { param( [Parameter(Mandatory = $false)] [string]$Text ) if (-not $Text) { return "<span class='muted'>-</span>" } $items = $Text -split '\s\|\s' $encodedItems = foreach ($item in $items) { "<div class='record-line'>" + [System.Net.WebUtility]::HtmlEncode($item) + "</div>" } return ($encodedItems -join '') } function Test-IsRecordMissing { param( [Parameter(Mandatory = $true)] [pscustomobject]$Result, [Parameter(Mandatory = $true)] [string]$RecordName ) switch ($RecordName.ToUpperInvariant()) { 'MX' { return -not $Result.HasMX } 'SPF' { return -not $Result.HasSPF } 'DMARC' { return -not $Result.HasDMARC } 'DKIM' { return -not $Result.HasDKIM } default { return $false } } } if (-not $DkimSelectors -or $DkimSelectors.Count -eq 0) { $DkimSelectors = @('selector1', 'selector2', 'default', 'google', 'k1', 'k2', 'mail', 'dkim') } $domainResults = @() $mxDetails = @() $scanTotalDomains = $domains.Count for ($domainIndex = 0; $domainIndex -lt $scanTotalDomains; $domainIndex++) { $domain = $domains[$domainIndex] if ($ShowProgress) { $domainPercent = [math]::Round((($domainIndex) / [math]::Max($scanTotalDomains, 1)) * 100, 0) Write-Progress -Id 1 -Activity 'Scanning Email DNS Records' -Status "Domain $($domainIndex + 1)/${scanTotalDomains}: $domain" -PercentComplete $domainPercent } if ($ShowProgress) { Write-Host "Processing domain $($domainIndex + 1)/${scanTotalDomains}: $domain" } if ($ShowProgress) { Write-Progress -Id 2 -ParentId 1 -Activity "Querying $domain" -Status 'MX lookup' -PercentComplete 0 } $mxRecords = Get-DnsRecordsByType -Name $domain -Type 'MX' | Where-Object { $_.Type -eq 'MX' } | Sort-Object -Property Preference if ($ShowProgress) { Write-Progress -Id 2 -ParentId 1 -Activity "Querying $domain" -Status 'SPF lookup' -PercentComplete 20 } $spfValues = Get-TxtValues -Name $domain | Where-Object { $_ -match '^v=spf1\b' } if ($ShowProgress) { Write-Progress -Id 2 -ParentId 1 -Activity "Querying $domain" -Status 'DMARC lookup' -PercentComplete 35 } $dmarcValues = Get-TxtValues -Name "_dmarc.$domain" | Where-Object { $_ -match '^v=DMARC1\b' } if ($ShowProgress) { Write-Progress -Id 2 -ParentId 1 -Activity "Querying $domain" -Status 'DKIM selector checks' -PercentComplete 60 } $dkimMatches = foreach ($selector in $DkimSelectors) { $name = "$selector._domainkey.$domain" $values = Get-TxtValues -Name $name | Where-Object { $_ -match '^v=DKIM1\b' } foreach ($value in $values) { [PSCustomObject]@{ Selector = $selector Value = $value } } } if ($ShowProgress) { Write-Progress -Id 2 -ParentId 1 -Activity "Querying $domain" -Status 'Finalizing domain results' -PercentComplete 90 } $hasMx = $mxRecords.Count -gt 0 $hasSpf = $spfValues.Count -gt 0 $hasDmarc = $dmarcValues.Count -gt 0 $hasDkim = $dkimMatches.Count -gt 0 $mxPointsToEop = $false if ($hasMx) { $mxPointsToEop = @($mxRecords | Where-Object { $_.NameExchange -match '\.mail\.protection\.outlook\.com\.?$' }).Count -gt 0 } if ($hasMx) { foreach ($mx in $mxRecords) { $mxDetails += [PSCustomObject]@{ Domain = $domain Preference = $mx.Preference Exchange = $mx.NameExchange } } } else { $mxDetails += [PSCustomObject]@{ Domain = $domain Preference = $null Exchange = '' } } $mxSummary = if ($hasMx) { (($mxRecords | ForEach-Object { "pref=$($_.Preference) $($_.NameExchange)" }) -join ' | ') } else { '' } $dkimRecordText = if ($hasDkim) { (($dkimMatches | ForEach-Object { "$($_.Selector): $($_.Value)" }) -join ' | ') } else { '' } $score = @($hasMx, $hasSpf, $hasDmarc, $hasDkim | Where-Object { $_ }).Count $overallStatus = if ($score -ge 4) { 'Strong' } elseif ($score -ge 2) { 'Moderate' } else { 'Needs Attention' } $missingControls = @() if (-not $hasMx) { $missingControls += 'MX' } if (-not $hasSpf) { $missingControls += 'SPF' } if (-not $hasDmarc) { $missingControls += 'DMARC' } if (-not $hasDkim) { $missingControls += 'DKIM' } $domainResults += [PSCustomObject]@{ Domain = $domain HasMX = $hasMx HasSPF = $hasSpf HasDMARC = $hasDmarc HasDKIM = $hasDkim MxPointsToEOP = $mxPointsToEop MX = $mxSummary SPF = $spfValues -join ' | ' DMARC = $dmarcValues -join ' | ' DKIMSelectors = ($dkimMatches | Select-Object -ExpandProperty Selector -Unique) -join ', ' DKIMRecords = $dkimRecordText Missing = $missingControls -join ', ' OverallStatus = $overallStatus } if ($ShowProgress) { Write-Progress -Id 2 -Activity "Querying $domain" -Completed } } if ($ShowProgress) { Write-Progress -Id 1 -Activity 'Scanning Email DNS Records' -Completed } $allResults = $domainResults | Sort-Object Domain $consoleResults = $allResults $activeFilters = @() if ($FilterMxNotMicrosoft) { $consoleResults = @($consoleResults | Where-Object { -not $_.MxPointsToEOP }) $activeFilters += 'MX not pointing to Microsoft' } if ($FilterMissingAnyCore) { $consoleResults = @($consoleResults | Where-Object { -not $_.HasMX -or -not $_.HasSPF -or -not $_.HasDMARC -or -not $_.HasDKIM }) $activeFilters += 'Missing any core record (MX/SPF/DMARC/DKIM)' } if ($FilterMissingAllCore) { $consoleResults = @($consoleResults | Where-Object { -not $_.HasMX -and -not $_.HasSPF -and -not $_.HasDMARC -and -not $_.HasDKIM }) $activeFilters += 'Missing all core records (MX/SPF/DMARC/DKIM)' } if ($FilterMissingAnyOf -and $FilterMissingAnyOf.Count -gt 0) { $consoleResults = @($consoleResults | Where-Object { $result = $_ @($FilterMissingAnyOf | Where-Object { Test-IsRecordMissing -Result $result -RecordName $_ }).Count -gt 0 }) $activeFilters += "Missing any of: $($FilterMissingAnyOf -join ', ')" } if ($FilterMissingAllOf -and $FilterMissingAllOf.Count -gt 0) { $consoleResults = @($consoleResults | Where-Object { $result = $_ @($FilterMissingAllOf | Where-Object { Test-IsRecordMissing -Result $result -RecordName $_ }).Count -eq $FilterMissingAllOf.Count }) $activeFilters += "Missing all of: $($FilterMissingAllOf -join ', ')" } if ($consoleResults.Count -eq 0) { Write-Warning 'No domains matched the selected filter(s).' } if ($ShowAll) { $consoleResults | Select-Object Domain, MxPointsToEOP, MX, SPF, DMARC, DKIMRecords, OverallStatus | Format-List } else { $consoleResults | Where-Object { $_.HasMX } | Select-Object Domain, HasMX, HasSPF, HasDMARC, HasDKIM, MxPointsToEOP, OverallStatus | Format-Table -AutoSize } if (-not $OutputHtml) { $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $OutputHtml = Join-Path -Path (Get-Location) -ChildPath "email-dns-report-$timestamp.html" } $totalScannedDomains = $allResults.Count $totalDomains = $consoleResults.Count $domainsWithMx = ($consoleResults | Where-Object { $_.HasMX }).Count $domainsWithSpf = ($consoleResults | Where-Object { $_.HasSPF }).Count $domainsWithDmarc = ($consoleResults | Where-Object { $_.HasDMARC }).Count $domainsWithDkim = ($consoleResults | Where-Object { $_.HasDKIM }).Count $domainsMxNotMicrosoft = ($consoleResults | Where-Object { -not $_.MxPointsToEOP }).Count $issuesOnly = $consoleResults | Where-Object { $_.Missing } $domainSectionsHtml = @() for ($i = 0; $i -lt $consoleResults.Count; $i++) { $record = $consoleResults[$i] $safeDomain = [System.Net.WebUtility]::HtmlEncode([string]$record.Domain) $safeSelectors = [System.Net.WebUtility]::HtmlEncode([string]$record.DKIMSelectors) $statusClass = if ($record.OverallStatus -eq 'Strong') { 'ok' } elseif ($record.OverallStatus -eq 'Moderate') { 'warn' } else { 'err' } $sectionId = "domain-section-$i" $domainSectionsHtml += "<details class='domain-card domain-details' data-mx-eop='$($record.MxPointsToEOP.ToString().ToLower())' data-has-mx='$($record.HasMX.ToString().ToLower())' data-has-spf='$($record.HasSPF.ToString().ToLower())' data-has-dmarc='$($record.HasDMARC.ToString().ToLower())' data-has-dkim='$($record.HasDKIM.ToString().ToLower())'>" + "<summary class='domain-head'>" + "<h3>$safeDomain</h3>" + "<div class='domain-controls'><span class='badge $statusClass'>$($record.OverallStatus)</span><span class='collapse-hint'>Click to collapse/expand</span></div>" + "</summary>" + "<div class='record-grid' id='$sectionId'>" + "<div class='record-item'><div class='record-title'>MX</div><div class='record-content'>$(Convert-ToHtmlRecordList -Text $record.MX)</div></div>" + "<div class='record-item'><div class='record-title'>SPF</div><div class='record-content'>$(Convert-ToHtmlRecordList -Text $record.SPF)</div></div>" + "<div class='record-item'><div class='record-title'>DMARC</div><div class='record-content'>$(Convert-ToHtmlRecordList -Text $record.DMARC)</div></div>" + "<div class='record-item'><div class='record-title'>DKIM</div><div class='record-content'>$(Convert-ToHtmlRecordList -Text $record.DKIMRecords)</div></div>" + "<div class='record-item'><div class='record-title'>DKIM Selectors</div><div class='record-content'>$(if ($safeSelectors) { $safeSelectors } else { "<span class='muted'>-</span>" })</div></div>" + "</div>" + "</details>" } $issuesRowsHtml = foreach ($issue in $issuesOnly) { $safeDomain = [System.Net.WebUtility]::HtmlEncode([string]$issue.Domain) $safeMissing = [System.Net.WebUtility]::HtmlEncode([string]$issue.Missing) "<tr><td>$safeDomain</td><td>$safeMissing</td></tr>" } $generatedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $moduleName = 'EmailDnsAudit' $html = @" <!DOCTYPE html> <html lang='en'> <head> <meta charset='utf-8' /> <meta name='viewport' content='width=device-width, initial-scale=1' /> <title>Email DNS Security Report</title> <style> :root { color-scheme: light dark; --bg: #0f172a; --panel: #111827; --text: #e5e7eb; --muted: #94a3b8; --accent: #38bdf8; --ok: #22c55e; --warn: #f59e0b; --err: #ef4444; --line: #1f2937; } @media (prefers-color-scheme: light) { :root { --bg: #f8fafc; --panel: #ffffff; --text: #0f172a; --muted: #475569; --accent: #0369a1; --line: #e2e8f0; } } body { margin: 0; font-family: Segoe UI, Arial, sans-serif; background: var(--bg); color: var(--text); } .container { max-width: 1250px; margin: 32px auto; padding: 0 20px; } .header { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 24px; margin-bottom: 18px; } h1 { margin: 0 0 8px 0; font-size: 30px; } .meta { color: var(--muted); font-size: 14px; margin-top: 2px; } .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 18px; } .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; } .card .label { color: var(--muted); font-size: 13px; margin-bottom: 6px; } .card .value { font-size: 28px; font-weight: 700; } .section-title { margin: 18px 0 8px 2px; font-size: 17px; color: var(--accent); } .domain-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 14px; } .domain-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; cursor: pointer; list-style: none; } .domain-head::-webkit-details-marker { display: none; } .domain-controls { display: flex; align-items: center; gap: 8px; } .domain-head h3 { margin: 0; font-size: 18px; } .section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; list-style: none; padding: 12px 14px; background: rgba(56, 189, 248, 0.08); color: var(--accent); font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .section-head::-webkit-details-marker { display: none; } .collapse-hint { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 8px; padding: 4px 10px; font-size: 12px; } .global-controls { display: flex; justify-content: flex-end; margin: 6px 0 10px 0; } .filter-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 12px; } .filter-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } .filter-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); } .global-btn { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; } .global-btn:hover { border-color: var(--accent); color: var(--accent); } .filtered-out { opacity: 0.55; border-style: dashed; } .hidden-by-filter { display: none; } .record-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 10px; } .record-item { border: 1px solid var(--line); border-radius: 10px; padding: 10px; background: rgba(148, 163, 184, 0.04); } .record-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); margin-bottom: 6px; font-weight: 600; } .record-content { font-size: 13px; line-height: 1.35; } .table-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } table { width: 100%; border-collapse: collapse; } th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; font-size: 13px; vertical-align: top; } th { background: rgba(56, 189, 248, 0.08); color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } tr:last-child td { border-bottom: none; } .badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; } .badge.ok { background: rgba(34, 197, 94, 0.2); color: var(--ok); } .badge.warn { background: rgba(245, 158, 11, 0.2); color: var(--warn); } .badge.err { background: rgba(239, 68, 68, 0.2); color: var(--err); } .record-line { margin-bottom: 6px; line-height: 1.35; word-break: break-word; } .record-line:last-child { margin-bottom: 0; } .muted { color: var(--muted); } .footer { margin-top: 12px; color: var(--muted); font-size: 12px; } </style> </head> <body> <div class='container'> <section class='header'> <h1>Email DNS Security Report</h1> <div class='meta'>Source: $([System.Net.WebUtility]::HtmlEncode($inputSourceDisplay))</div> $(if ($defaultDomainDisplay) { "<div class='meta'>Default domain: $([System.Net.WebUtility]::HtmlEncode($defaultDomainDisplay))</div>" } else { '' }) <div class='meta'>Scanned domains: $totalScannedDomains</div> </section> <section class='summary'> <div class='card'><div class='label'>Domains In Report</div><div class='value'>$totalDomains</div></div> <div class='card'><div class='label'>MX Present</div><div class='value'>$domainsWithMx</div></div> <div class='card'><div class='label'>MX not pointing to Microsoft</div><div class='value'>$domainsMxNotMicrosoft</div></div> <div class='card'><div class='label'>SPF Present</div><div class='value'>$domainsWithSpf</div></div> <div class='card'><div class='label'>DMARC Present</div><div class='value'>$domainsWithDmarc</div></div> <div class='card'><div class='label'>DKIM Found</div><div class='value'>$domainsWithDkim</div></div> </section> <div class='section-title'>Issues Only</div> <details class='table-wrap' id='issues-section'> <summary class='section-head'> <span>Issues Only</span> <span class='collapse-hint'>Click to expand/collapse</span> </summary> <table> <thead> <tr> <th>Domain</th> <th>Missing Controls</th> </tr> </thead> <tbody> $(if ($issuesRowsHtml.Count -gt 0) { $issuesRowsHtml -join [Environment]::NewLine } else { "<tr><td colspan='2'><span class='badge ok'>No issues found</span></td></tr>" }) </tbody> </table> </details> <div class='section-title'>View Filters</div> <section class='filter-panel'> <div class='filter-row'> <label class='filter-item'><input type='checkbox' id='f-mx-noteop' onchange='applyDomainFilters()' /> MX not pointing to Microsoft</label> <label class='filter-item'><input type='checkbox' id='f-missing-mx' onchange='applyDomainFilters()' /> Missing MX</label> <label class='filter-item'><input type='checkbox' id='f-missing-spf' onchange='applyDomainFilters()' /> Missing SPF</label> <label class='filter-item'><input type='checkbox' id='f-missing-dmarc' onchange='applyDomainFilters()' /> Missing DMARC</label> <label class='filter-item'><input type='checkbox' id='f-missing-dkim' onchange='applyDomainFilters()' /> Missing DKIM</label> <label class='filter-item'><input type='checkbox' id='f-hide-nonmatching' onchange='applyDomainFilters()' checked /> Hide non-matching</label> <button type='button' class='global-btn' onclick='applyDomainFilters()'>Apply Filters</button> <button type='button' class='global-btn' onclick='resetDomainFilters()'>Reset</button> </div> <div class='meta' id='filter-summary'>Showing all domains in this report.</div> </section> <div class='section-title'>Records By Domain</div> <div class='global-controls'> <button type='button' class='global-btn' onclick='toggleEverything(this)'>Expand All Sections</button> <button type='button' class='global-btn' onclick='toggleAllSections(this)'>Expand All</button> </div> $($domainSectionsHtml -join [Environment]::NewLine) <div class='section-title'>DNS Record Guide</div> <section class='table-wrap'> <table> <thead> <tr> <th>Record</th> <th>What It Means</th> <th>Why It Is Needed</th> </tr> </thead> <tbody> <tr> <td>MX</td> <td>Specifies the mail servers that receive email for your domain.</td> <td>Without valid MX, inbound email delivery may fail or be misrouted.</td> </tr> <tr> <td>SPF (TXT)</td> <td>Lists which sending servers are allowed to send mail for your domain.</td> <td>Reduces spoofing risk by helping receivers reject unauthorized senders.</td> </tr> <tr> <td>DMARC (TXT at _dmarc)</td> <td>Defines policy and reporting for SPF/DKIM alignment checks.</td> <td>Enforces anti-spoofing policy and provides visibility through aggregate/forensic reports.</td> </tr> <tr> <td>DKIM (TXT at selector._domainkey)</td> <td>Publishes public keys used to verify cryptographic email signatures.</td> <td>Confirms message integrity and domain authenticity for outbound email.</td> </tr> </tbody> </table> </section> <div class='footer'>Report generated by $moduleName | Developer: $([System.Net.WebUtility]::HtmlEncode($DeveloperName)) | Generated: $generatedAt</div> </div> <script> function applyDomainFilters() { const mxNotMicrosoft = document.getElementById('f-mx-noteop').checked; const missingMx = document.getElementById('f-missing-mx').checked; const missingSpf = document.getElementById('f-missing-spf').checked; const missingDmarc = document.getElementById('f-missing-dmarc').checked; const missingDkim = document.getElementById('f-missing-dkim').checked; const hideNonMatching = document.getElementById('f-hide-nonmatching').checked; const sections = document.querySelectorAll('.domain-details'); let visibleCount = 0; sections.forEach((section) => { const hasMx = section.dataset.hasMx === 'true'; const hasSpf = section.dataset.hasSpf === 'true'; const hasDmarc = section.dataset.hasDmarc === 'true'; const hasDkim = section.dataset.hasDkim === 'true'; const mxPointsToEop = section.dataset.mxEop === 'true'; const pass = (!mxNotMicrosoft || !mxPointsToEop) && (!missingMx || !hasMx) && (!missingSpf || !hasSpf) && (!missingDmarc || !hasDmarc) && (!missingDkim || !hasDkim); if (pass) { visibleCount += 1; section.classList.remove('filtered-out'); section.classList.remove('hidden-by-filter'); } else { section.classList.toggle('hidden-by-filter', hideNonMatching); section.classList.toggle('filtered-out', !hideNonMatching); section.open = false; } }); const active = []; if (mxNotMicrosoft) active.push('MX not pointing to Microsoft'); if (missingMx) active.push('Missing MX'); if (missingSpf) active.push('Missing SPF'); if (missingDmarc) active.push('Missing DMARC'); if (missingDkim) active.push('Missing DKIM'); if (hideNonMatching) active.push('Hide non-matching'); const summary = document.getElementById('filter-summary'); if (active.length === 0) { summary.textContent = 'Showing all domains in this report (' + visibleCount + ').'; } else { summary.textContent = 'Active filters: ' + active.join(' | ') + '. Showing ' + visibleCount + ' domain(s).'; } } function resetDomainFilters() { document.getElementById('f-mx-noteop').checked = false; document.getElementById('f-missing-mx').checked = false; document.getElementById('f-missing-spf').checked = false; document.getElementById('f-missing-dmarc').checked = false; document.getElementById('f-missing-dkim').checked = false; document.getElementById('f-hide-nonmatching').checked = true; applyDomainFilters(); } function toggleAllSections(button) { const sections = document.querySelectorAll('.domain-details'); const collapseAll = button.textContent === 'Collapse All'; sections.forEach((section) => { section.open = !collapseAll; }); button.textContent = collapseAll ? 'Expand All' : 'Collapse All'; } function toggleEverything(button) { const expandAll = button.textContent === 'Expand All Sections'; const domainSections = document.querySelectorAll('.domain-details'); const issuesSection = document.getElementById('issues-section'); domainSections.forEach((section) => { section.open = expandAll; }); if (issuesSection) { issuesSection.open = expandAll; } const domainToggleButton = Array.from(document.querySelectorAll('.global-btn')).find((btn) => btn !== button && (btn.textContent === 'Expand All' || btn.textContent === 'Collapse All')); if (domainToggleButton) { domainToggleButton.textContent = expandAll ? 'Collapse All' : 'Expand All'; } button.textContent = expandAll ? 'Collapse All Sections' : 'Expand All Sections'; } applyDomainFilters(); </script> </body> </html> "@ Set-Content -Path $OutputHtml -Value $html -Encoding UTF8 Write-Host "HTML report written to: $OutputHtml" if (-not $NoOpenHtml) { Write-Host "Opening HTML report in your default browser..." Start-Process -FilePath $OutputHtml } if ($OutputCsv) { $consoleResults | Sort-Object Domain | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 Write-Host "CSV report written to: $OutputCsv" } if ($OutputJson) { $consoleResults | Sort-Object Domain | ConvertTo-Json -Depth 6 | Set-Content -Path $OutputJson -Encoding UTF8 Write-Host "JSON report written to: $OutputJson" } |