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
    }
}