SpfLookups.ps1
|
function Get-SpfLookupCount { <# .SYNOPSIS Count SPF include lookups for a domain or SPF record string. .DESCRIPTION Uses Resolve-DnsName to retrieve TXT records and find the SPF record (v=spf1). Recursively follows include: directives and counts each DNS lookup performed for includes. Enforces a maximum lookup limit (default 10) and avoids infinite loops by tracking visited domains. .PARAMETER Domain The domain to query for an SPF record. If you already have an SPF record string, pass it via -SpfRecord instead. .PARAMETER SpfRecord Provide the SPF record string directly. When supplied, no initial DNS lookup is performed (unless recursive includes require it). .PARAMETER MaxLookups Maximum number of DNS include lookups to perform. Default 10. .PARAMETER ResolveTimeoutSeconds Timeout for Resolve-DnsName calls (in seconds). Default 5. .OUTPUTS PSCustomObject with properties: Domain, SpfRecord, IncludeCount, LookupsPerformed, ReachedLimit, Errors .EXAMPLE Get-SpfLookupCount -Domain 'example.com' #> param( [Parameter(Mandatory=$false)] [string] $Domain, [Parameter(Mandatory=$false)] [string] $SpfRecord, [int] $MaxLookups = 10, [int] $ResolveTimeoutSeconds = 5 ) if (-not $Domain -and -not $SpfRecord) { throw 'Either -Domain or -SpfRecord must be supplied.' } $state = [pscustomobject]@{ Domain = $Domain SpfRecord = $null IncludeCount = 0 LookupsPerformed = 0 ReachedLimit = $false Errors = @() Visited = @{} } function Get-TxtSpf { param([string] $d) try { # Resolve-DnsName may return multiple records; pick strings that start with v=spf1 $res = Resolve-DnsName -Name $d -Type TXT -ErrorAction Stop $strings = $res | ForEach-Object { $_.Strings } | Where-Object { $_ -match '^v=spf1' } if ($strings) { return ($strings -join ' ') } return $null } catch { # Avoid parsing issues by using explicit variable delimiter and string concatenation $state.Errors += ('DNS lookup failed for ' + ${d} + ': ' + $_.ToString()) return $null } } function Process-SpfString { param([string] $spf) if (-not $spf) { return } # split on whitespace but keep quoted parts intact $tokens = $spf -split '\s+' foreach ($t in $tokens) { if ($state.LookupsPerformed -ge $MaxLookups) { $state.ReachedLimit = $true return } if ($t -match '^include:(.+)$') { $incDomain = $Matches[1] if ($state.Visited.ContainsKey($incDomain)) { continue } $state.Visited[$incDomain] = $true # Count this as a lookup $state.IncludeCount++ $state.LookupsPerformed++ # Retrieve SPF for included domain $incSpf = Get-TxtSpf -d $incDomain if ($incSpf) { Process-SpfString -spf $incSpf if ($state.ReachedLimit) { return } } } elseif ($t -like 'redirect=*') { # Process redirect=target at top level (counts as lookup) if ($t -match '^redirect=(.+)$') { $redir = $Matches[1] if (-not $state.Visited.ContainsKey($redir)) { if ($state.LookupsPerformed -ge $MaxLookups) { $state.ReachedLimit = $true; return } $state.Visited[$redir] = $true $state.IncludeCount++ $state.LookupsPerformed++ $rspf = Get-TxtSpf -d $redir if ($rspf) { Process-SpfString -spf $rspf } } } } } } # Start: get initial SPF if ($SpfRecord) { $state.SpfRecord = $SpfRecord # If a direct SPF string provided, still process includes inside it Process-SpfString -spf $SpfRecord } else { $spf = Get-TxtSpf -d $Domain if ($spf) { $state.SpfRecord = $spf # mark domain as visited so we don't loop if ($Domain) { $state.Visited[$Domain] = $true } Process-SpfString -spf $spf } else { $state.Errors += "No SPF record found for $Domain" } } # Prepare output (do not expose Visited hashtable) return [pscustomobject]@{ Domain = $state.Domain SpfRecord = $state.SpfRecord IncludeCount = $state.IncludeCount LookupsPerformed = $state.LookupsPerformed ReachedLimit = $state.ReachedLimit Errors = $state.Errors } } # Export-ModuleMember -Function Get-SpfLookupCount # Note: exporting is commented out so this file can be dot-sourced in a session. |