Public/Get-DNSChanges.ps1
|
function Get-DNSChanges { <# .SYNOPSIS Detects DNS record changes within a specified timeframe. .DESCRIPTION Queries DNS server audit and analytical logs for record creation, deletion, and modification events. Supports comparison against a baseline snapshot to detect changes that may not appear in event logs. Tracks A, AAAA, CNAME, MX, SRV, and PTR record changes. .PARAMETER HoursBack Number of hours to look back for changes. Default is 24. Range: 1-720. .PARAMETER ZoneName Optional array of specific zone names to check. If omitted, all AD-integrated zones are queried. .EXAMPLE Get-DNSChanges -HoursBack 48 Returns all DNS changes across all AD-integrated zones from the last 48 hours. .EXAMPLE Get-DNSChanges -ZoneName 'contoso.com','10.in-addr.arpa' Returns DNS changes for specific zones from the last 24 hours. #> [CmdletBinding()] param( [Parameter()] [ValidateRange(1, 720)] [int]$HoursBack = 24, [Parameter()] [string[]]$ZoneName ) begin { $Changes = [System.Collections.Generic.List[PSCustomObject]]::new() $CutoffTime = (Get-Date).AddHours(-$HoursBack) # DNS Server event IDs for audit logging # These correspond to DNS Server Audit Events on Windows Server 2012+ $DnsEventIdMap = @{ 513 = 'Added' # Zone record added 514 = 'Removed' # Zone record deleted 515 = 'Modified' # Zone record modified (update) 516 = 'Added' # DNSSEC-signed record added 517 = 'Removed' # DNSSEC-signed record removed 519 = 'Modified' # Node modified 521 = 'Added' # Zone scope record added 522 = 'Removed' # Zone scope record removed 525 = 'Added' # Zone created 526 = 'Removed' # Zone deleted 530 = 'Added' # Record added via dynamic update 531 = 'Removed' # Record deleted via dynamic update 535 = 'Modified' # Record modified via dynamic update (IXFR) 541 = 'Added' # Record added via scavenging 542 = 'Removed' # Record removed via scavenging 547 = 'Modified' # Record modified during aging/scavenging 577 = 'Added' # Record added via DNSSEC signing 578 = 'Removed' # Record removed via DNSSEC signing } # DNS record type mapping (numeric to friendly name) $RecordTypeMap = @{ '1' = 'A' '2' = 'NS' '5' = 'CNAME' '6' = 'SOA' '12' = 'PTR' '15' = 'MX' '16' = 'TXT' '28' = 'AAAA' '33' = 'SRV' } } process { # ---------------------------------------------------------------- # Phase 1: Determine target zones # ---------------------------------------------------------------- $TargetZones = @() if ($ZoneName) { $TargetZones = $ZoneName } else { Write-Verbose "Discovering AD-integrated DNS zones..." try { $AllZones = Get-DnsServerZone -ErrorAction Stop | Where-Object { $_.ZoneType -ne 'Forwarder' -and $_.IsAutoCreated -eq $false } $TargetZones = $AllZones | Select-Object -ExpandProperty ZoneName Write-Verbose "Found $($TargetZones.Count) zones: $($TargetZones -join ', ')" } catch { Write-Warning "Failed to enumerate DNS zones: $_. Attempting event log analysis only." } } # ---------------------------------------------------------------- # Phase 2: Query DNS Server Audit event log # ---------------------------------------------------------------- Write-Verbose "Querying DNS Server audit events..." # Try the DNS Server Audit log first (Windows Server 2012 R2+) $DnsLogNames = @('DNS Server', 'Microsoft-Windows-DNSServer/Audit') $EventsFound = $false foreach ($LogName in $DnsLogNames) { try { $FilterHash = @{ LogName = $LogName StartTime = $CutoffTime } $Events = Get-WinEvent -FilterHashtable $FilterHash -ErrorAction Stop $EventsFound = $true Write-Verbose "Found $($Events.Count) events in '$LogName'" foreach ($Event in $Events) { $EventChangeType = $null if ($DnsEventIdMap.ContainsKey($Event.Id)) { $EventChangeType = $DnsEventIdMap[$Event.Id] } elseif ($Event.Id -ge 513 -and $Event.Id -le 582) { # Generic DNS operational event $EventChangeType = 'Modified' } else { continue } # Parse event message for DNS record details $Message = $Event.Message $EventZone = '' $RecordName = '' $RecordType = '' $RecordValue = '' $EventChangedBy = '' # Try structured XML parsing first try { $EventXml = [xml]$Event.ToXml() $DataItems = @{} $EventXml.Event.EventData.Data | ForEach-Object { $DataItems[$_.Name] = $_.'#text' } if ($DataItems['ZONE']) { $EventZone = $DataItems['ZONE'] } if ($DataItems['Zone']) { $EventZone = $DataItems['Zone'] } if ($DataItems['QNAME']) { $RecordName = $DataItems['QNAME'] } if ($DataItems['NAME']) { $RecordName = $DataItems['NAME'] } if ($DataItems['Source']) { $EventChangedBy = $DataItems['Source'] } if ($DataItems['QTYPE']) { $TypeNum = $DataItems['QTYPE'] $RecordType = if ($RecordTypeMap.ContainsKey($TypeNum)) { $RecordTypeMap[$TypeNum] } else { "Type$TypeNum" } } if ($DataItems['TYPE']) { $RecordType = $DataItems['TYPE'] } if ($DataItems['DATA']) { $RecordValue = $DataItems['DATA'] } if ($DataItems['RDATA']) { $RecordValue = $DataItems['RDATA'] } } catch { Write-Verbose "Could not parse event XML, falling back to message parsing." } # Fall back to message-based parsing if needed if (-not $RecordName -and $Message) { if ($Message -match 'Node\s+(\S+)') { $RecordName = $Matches[1] } elseif ($Message -match 'name\s+(\S+)') { $RecordName = $Matches[1] } } if (-not $EventZone -and $Message) { if ($Message -match 'zone\s+(\S+)') { $EventZone = $Matches[1] } } if (-not $RecordType -and $Message) { if ($Message -match '\b(A|AAAA|CNAME|MX|SRV|PTR|NS|SOA|TXT)\b') { $RecordType = $Matches[1] } } # Filter to target zones if specified if ($TargetZones.Count -gt 0 -and $EventZone) { if ($TargetZones -notcontains $EventZone) { continue } } # Determine severity $Severity = switch ($EventChangeType) { 'Added' { 'Low' } 'Removed' { 'Medium' } 'Modified' { 'Low' } default { 'Low' } } # Elevated severity for critical record types or zones if ($RecordType -in @('MX', 'NS', 'SOA', 'SRV')) { $Severity = 'Medium' } # Build detail $Detail = "$EventChangeType $RecordType record '$RecordName'" if ($EventZone) { $Detail += " in zone '$EventZone'" } if ($RecordValue) { $Detail += " (value: $RecordValue)" } if ($EventChangedBy) { $Detail += " by $EventChangedBy" } $OldVal = '' $NewVal = '' switch ($EventChangeType) { 'Added' { $NewVal = $RecordValue } 'Removed' { $OldVal = $RecordValue } 'Modified' { $NewVal = $RecordValue } } $Changes.Add([PSCustomObject]@{ ChangeTime = $Event.TimeCreated ChangeType = $EventChangeType Category = 'DNS' ObjectName = $RecordName ObjectType = if ($RecordType) { $RecordType } else { 'DNSRecord' } ChangedBy = $EventChangedBy OldValue = $OldVal NewValue = $NewVal Detail = $Detail Source = "DNS Event $($Event.Id) ($LogName)" Severity = $Severity ZoneName = $EventZone RecordName = $RecordName RecordType = $RecordType }) } # If we found events in this log, don't check the other log name if ($EventsFound) { break } } catch { if ($_.Exception.Message -match 'No events were found') { Write-Verbose "No events in '$LogName' for the specified timeframe." } elseif ($_.Exception.Message -match 'could not be found|does not exist') { Write-Verbose "Log '$LogName' not available, trying next..." } else { Write-Warning "Failed to query '$LogName': $_" } } } # ---------------------------------------------------------------- # Phase 3: Query DNS zones directly for recently modified records # (timestamp-based detection as supplemental check) # ---------------------------------------------------------------- if ($TargetZones.Count -gt 0) { Write-Verbose "Checking DNS zone records for recent timestamp changes..." foreach ($Zone in $TargetZones) { try { $Records = Get-DnsServerResourceRecord -ZoneName $Zone -ErrorAction Stop | Where-Object { $_.Timestamp -and $_.Timestamp -ge $CutoffTime } foreach ($Record in $Records) { $RType = $Record.RecordType $RName = $Record.HostName $RData = switch ($RType) { 'A' { $Record.RecordData.IPv4Address.ToString() } 'AAAA' { $Record.RecordData.IPv6Address.ToString() } 'CNAME' { $Record.RecordData.HostNameAlias } 'MX' { "$($Record.RecordData.Preference) $($Record.RecordData.MailExchange)" } 'SRV' { "$($Record.RecordData.Priority) $($Record.RecordData.Weight) $($Record.RecordData.Port) $($Record.RecordData.DomainName)" } 'PTR' { $Record.RecordData.PtrDomainName } 'NS' { $Record.RecordData.NameServer } 'TXT' { ($Record.RecordData.DescriptiveText -join '; ') } default { $Record.RecordData.ToString() } } # Check if we already captured this via event log $AlreadyCaptured = $Changes | Where-Object { $_.RecordName -eq $RName -and $_.ZoneName -eq $Zone -and $_.RecordType -eq $RType } if (-not $AlreadyCaptured) { $Changes.Add([PSCustomObject]@{ ChangeTime = $Record.Timestamp ChangeType = 'Modified' Category = 'DNS' ObjectName = $RName ObjectType = $RType ChangedBy = '' OldValue = '' NewValue = $RData Detail = "DNS $RType record '$RName' updated in zone '$Zone' (value: $RData)" Source = "DNS Zone Query ($Zone)" Severity = 'Low' ZoneName = $Zone RecordName = $RName RecordType = $RType }) } } } catch { Write-Warning "Failed to query records in zone '${Zone}': $_" } } } } end { $SortedChanges = $Changes | Sort-Object ChangeTime -Descending Write-Verbose "Found $($SortedChanges.Count) DNS changes in the last $HoursBack hours." return $SortedChanges } } |