Private/ADMonitor/Core/Compare-ADBaseline.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Compare-ADBaseline {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$PreviousBaseline,

        [Parameter(Mandatory)]
        [hashtable]$CurrentData
    )

    $changes = @{
        GroupChanges        = @()
        GPOChanges          = @()
        GPOLinkChanges      = @()
        TrustChanges        = @()
        ACLChanges          = @()
        AdminSDHolderChanged = $false
        KrbtgtChanged       = $false
        CertTemplateChanges = @()
        DelegationChanges   = @()
        DNSChanges          = @()
        SchemaChanges       = @()
        NewComputers        = @()
        NewServiceAccounts  = @()
        PasswordChanges     = @()
    }

    # ── 1. Privileged group membership changes ─────────────────────────
    $prevGroups = if ($PreviousBaseline.ContainsKey('privilegedGroups')) { $PreviousBaseline['privilegedGroups'] } else { @{} }

    $groupChanges = [System.Collections.Generic.List[hashtable]]::new()

    # Check current groups against baseline
    foreach ($groupName in $CurrentData.privilegedGroups.Keys) {
        $currentMembers = @($CurrentData.privilegedGroups[$groupName] | Sort-Object)
        $prevGroupInfo = if ($prevGroups.ContainsKey($groupName)) { $prevGroups[$groupName] } else { $null }

        if (-not $prevGroupInfo) {
            # Entire group is new to monitoring
            if ($currentMembers.Count -gt 0) {
                $groupChanges.Add(@{
                    Group      = $groupName
                    ChangeType = 'NewGroup'
                    Added      = $currentMembers
                    Removed    = @()
                })
            }
            continue
        }

        $prevMembers = @($prevGroupInfo.members | Sort-Object)

        # Compare member lists
        $added = @($currentMembers | Where-Object { $_ -notin $prevMembers })
        $removed = @($prevMembers | Where-Object { $_ -notin $currentMembers })

        if ($added.Count -gt 0 -or $removed.Count -gt 0) {
            $groupChanges.Add(@{
                Group      = $groupName
                ChangeType = 'MembershipChanged'
                Added      = $added
                Removed    = $removed
            })
        }
    }

    # Check for groups that disappeared
    foreach ($groupName in $prevGroups.Keys) {
        if (-not $CurrentData.privilegedGroups.ContainsKey($groupName)) {
            $prevMembers = @($prevGroups[$groupName].members)
            if ($prevMembers.Count -gt 0) {
                $groupChanges.Add(@{
                    Group      = $groupName
                    ChangeType = 'GroupRemoved'
                    Added      = @()
                    Removed    = $prevMembers
                })
            }
        }
    }

    $changes.GroupChanges = @($groupChanges)

    # ── 2. AdminSDHolder ACL changes ───────────────────────────────────
    $prevACLHash = if ($PreviousBaseline.ContainsKey('adminSDHolderACL') -and $PreviousBaseline['adminSDHolderACL'].ContainsKey('aclHash')) {
        $PreviousBaseline['adminSDHolderACL']['aclHash']
    } else { 'EMPTY' }

    $currentACLEntries = @($CurrentData.adminSDHolderACL | ForEach-Object {
        "$($_.identity)|$($_.rights)|$($_.type)"
    } | Sort-Object)

    $currentACLHash = if ($currentACLEntries.Count -gt 0) {
        $sha = [System.Security.Cryptography.SHA256]::Create()
        $joined = $currentACLEntries -join '||'
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined)
        $hashBytes = $sha.ComputeHash($bytes)
        [BitConverter]::ToString($hashBytes) -replace '-', ''
    } else { 'EMPTY' }

    if ($prevACLHash -ne $currentACLHash) {
        $changes.AdminSDHolderChanged = $true

        # Determine specific ACL differences
        $prevACEs = @()
        if ($PreviousBaseline.ContainsKey('adminSDHolderACL') -and $PreviousBaseline['adminSDHolderACL'].ContainsKey('entries')) {
            $prevACEs = @($PreviousBaseline['adminSDHolderACL']['entries'])
        }
        $currentACEs = @($CurrentData.adminSDHolderACL)

        $prevAceKeys = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($ace in $prevACEs) {
            [void]$prevAceKeys.Add("$($ace.identity)|$($ace.rights)|$($ace.type)")
        }

        $currentAceKeys = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($ace in $currentACEs) {
            [void]$currentAceKeys.Add("$($ace.identity)|$($ace.rights)|$($ace.type)")
        }

        $aclChanges = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($ace in $currentACEs) {
            $key = "$($ace.identity)|$($ace.rights)|$($ace.type)"
            if (-not $prevAceKeys.Contains($key)) {
                $aclChanges.Add(@{
                    ObjectDN   = 'AdminSDHolder'
                    ChangeType = 'Added'
                    Identity   = $ace.identity
                    Rights     = $ace.rights
                })
            }
        }
        foreach ($ace in $prevACEs) {
            $key = "$($ace.identity)|$($ace.rights)|$($ace.type)"
            if (-not $currentAceKeys.Contains($key)) {
                $aclChanges.Add(@{
                    ObjectDN   = 'AdminSDHolder'
                    ChangeType = 'Removed'
                    Identity   = $ace.identity
                    Rights     = $ace.rights
                })
            }
        }
        $changes.ACLChanges = @($aclChanges)
    }

    # ── 3. krbtgt password change ──────────────────────────────────────
    $prevKrbtgt = if ($PreviousBaseline.ContainsKey('krbtgt')) { $PreviousBaseline['krbtgt'] } else { @{} }

    if ($prevKrbtgt.ContainsKey('pwdLastSet') -and $prevKrbtgt['pwdLastSet']) {
        if ($CurrentData.krbtgtPwdLastSet -and $CurrentData.krbtgtPwdLastSet -ne $prevKrbtgt['pwdLastSet']) {
            $changes.KrbtgtChanged = $true
        }
        if ($CurrentData.krbtgtKeyVersion -ne 0 -and $prevKrbtgt.ContainsKey('keyVersion') -and
            $CurrentData.krbtgtKeyVersion -ne $prevKrbtgt['keyVersion']) {
            $changes.KrbtgtChanged = $true
        }
    }

    # ── 4. Trust relationship changes ──────────────────────────────────
    $prevTrusts = if ($PreviousBaseline.ContainsKey('trusts')) { $PreviousBaseline['trusts'] } else { @{} }

    $trustChanges = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($trust in $CurrentData.trusts) {
        $trustKey = $trust.name.ToLower()

        if (-not $prevTrusts.ContainsKey($trustKey)) {
            $trustChanges.Add(@{
                Name       = $trust.name
                ChangeType = 'Added'
                Direction  = $trust.direction
                Type       = $trust.type
                Details    = "New trust: $($trust.name) ($($trust.direction), $($trust.type))"
            })
            continue
        }

        $prevTrust = $prevTrusts[$trustKey]

        # Check for property changes
        $trustDiffs = [System.Collections.Generic.List[string]]::new()
        if ($trust.direction -ne $prevTrust.direction) { $trustDiffs.Add("direction: $($prevTrust.direction) -> $($trust.direction)") }
        if ($trust.type -ne $prevTrust.type) { $trustDiffs.Add("type: $($prevTrust.type) -> $($trust.type)") }
        if ($trust.isTransitive -ne $prevTrust.isTransitive) { $trustDiffs.Add("transitive: $($prevTrust.isTransitive) -> $($trust.isTransitive)") }
        if ($trust.sidFiltering -ne $prevTrust.sidFiltering) { $trustDiffs.Add("sidFiltering: $($prevTrust.sidFiltering) -> $($trust.sidFiltering)") }
        if ($trust.trustAttributes -ne $prevTrust.trustAttributes) { $trustDiffs.Add("attributes: $($prevTrust.trustAttributes) -> $($trust.trustAttributes)") }

        if ($trustDiffs.Count -gt 0) {
            $trustChanges.Add(@{
                Name       = $trust.name
                ChangeType = 'Modified'
                Direction  = $trust.direction
                Type       = $trust.type
                Details    = "Trust modified: $($trustDiffs -join '; ')"
            })
        }
    }

    # Removed trusts
    foreach ($trustKey in $prevTrusts.Keys) {
        $found = $CurrentData.trusts | Where-Object { $_.name.ToLower() -eq $trustKey }
        if (-not $found) {
            $trustChanges.Add(@{
                Name       = $prevTrusts[$trustKey].name
                ChangeType = 'Removed'
                Direction  = $prevTrusts[$trustKey].direction
                Type       = $prevTrusts[$trustKey].type
                Details    = "Trust removed: $($prevTrusts[$trustKey].name)"
            })
        }
    }

    $changes.TrustChanges = @($trustChanges)

    # ── 5. GPO changes (Full mode) ────────────────────────────────────
    $prevGPOs = if ($PreviousBaseline.ContainsKey('gpoObjects')) { $PreviousBaseline['gpoObjects'] } else { @{} }

    $gpoChanges = [System.Collections.Generic.List[hashtable]]::new()
    $gpoLinkChanges = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($gpoGuid in $CurrentData.gpoObjects.Keys) {
        $currentGPO = $CurrentData.gpoObjects[$gpoGuid]

        if (-not $prevGPOs.ContainsKey($gpoGuid)) {
            $gpoChanges.Add(@{
                GUID       = $gpoGuid
                Name       = $currentGPO.name
                ChangeType = 'Added'
                Details    = "New GPO: $($currentGPO.name)"
            })
            continue
        }

        $prevGPO = $prevGPOs[$gpoGuid]

        # Version change means content modification
        if ($currentGPO.versionNumber -ne $prevGPO.versionNumber) {
            $gpoChanges.Add(@{
                GUID            = $gpoGuid
                Name            = $currentGPO.name
                ChangeType      = 'Modified'
                PreviousVersion = $prevGPO.versionNumber
                CurrentVersion  = $currentGPO.versionNumber
                Details         = "GPO modified: $($currentGPO.name) (v$($prevGPO.versionNumber) -> v$($currentGPO.versionNumber))"
            })
        }

        # Link changes
        $currentLinkHash = ($currentGPO.linkedTo | ForEach-Object {
            "$($_.containerDN)|$($_.isEnabled)|$($_.isEnforced)"
        } | Sort-Object) -join '||'
        $currentLinkHashVal = if ($currentLinkHash) {
            $sha = [System.Security.Cryptography.SHA256]::Create()
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($currentLinkHash)
            $hashBytes = $sha.ComputeHash($bytes)
            [BitConverter]::ToString($hashBytes) -replace '-', ''
        } else { 'EMPTY' }

        $prevLinkHash = if ($prevGPO.ContainsKey('linkHash')) { $prevGPO['linkHash'] } else { 'EMPTY' }

        if ($currentLinkHashVal -ne $prevLinkHash) {
            $gpoLinkChanges.Add(@{
                GUID       = $gpoGuid
                Name       = $currentGPO.name
                ChangeType = 'LinkChanged'
                Details    = "GPO link changed: $($currentGPO.name)"
            })
        }
    }

    # Removed GPOs
    foreach ($gpoGuid in $prevGPOs.Keys) {
        if (-not $CurrentData.gpoObjects.ContainsKey($gpoGuid)) {
            $gpoChanges.Add(@{
                GUID       = $gpoGuid
                Name       = $prevGPOs[$gpoGuid].name
                ChangeType = 'Removed'
                Details    = "GPO removed: $($prevGPOs[$gpoGuid].name)"
            })
        }
    }

    $changes.GPOChanges = @($gpoChanges)
    $changes.GPOLinkChanges = @($gpoLinkChanges)

    # ── 6. Sensitive ACL changes (Full mode) ───────────────────────────
    $prevAcls = if ($PreviousBaseline.ContainsKey('sensitiveAcls')) { $PreviousBaseline['sensitiveAcls'] } else { @{} }

    if (-not $changes.AdminSDHolderChanged) {
        # Only process non-AdminSDHolder ACL changes if AdminSDHolder wasn't already flagged
        $additionalAclChanges = [System.Collections.Generic.List[hashtable]]::new()

        foreach ($objName in $CurrentData.sensitiveAcls.Keys) {
            if ($objName -eq '_dangerousACEs') { continue }

            $currentObj = $CurrentData.sensitiveAcls[$objName]
            $currentAceStrings = @($currentObj.aces | ForEach-Object {
                "$($_.identity)|$($_.rights)|$($_.objectType)"
            } | Sort-Object)
            $currentHash = if ($currentAceStrings.Count -gt 0) {
                $sha = [System.Security.Cryptography.SHA256]::Create()
                $joined = $currentAceStrings -join '||'
                $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined)
                $hashBytes = $sha.ComputeHash($bytes)
                [BitConverter]::ToString($hashBytes) -replace '-', ''
            } else { 'EMPTY' }

            $prevHash = if ($prevAcls.ContainsKey($objName) -and $prevAcls[$objName].ContainsKey('aceHash')) {
                $prevAcls[$objName]['aceHash']
            } else { 'EMPTY' }

            if ($currentHash -ne $prevHash) {
                $additionalAclChanges.Add(@{
                    ObjectDN   = $currentObj.objectDN
                    ObjectName = $objName
                    ChangeType = 'Modified'
                    Identity   = ''
                    Rights     = ''
                    Details    = "ACL changed on: $objName"
                })
            }
        }

        if ($additionalAclChanges.Count -gt 0) {
            $changes.ACLChanges = @($changes.ACLChanges) + @($additionalAclChanges)
        }
    }

    # ── 7. Certificate template changes (Full mode) ────────────────────
    $prevCerts = if ($PreviousBaseline.ContainsKey('certTemplates')) { $PreviousBaseline['certTemplates'] } else { @{} }

    $certChanges = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($tmplName in $CurrentData.certTemplates.Keys) {
        $currentTmpl = $CurrentData.certTemplates[$tmplName]

        if (-not $prevCerts.ContainsKey($tmplName)) {
            $certChanges.Add(@{
                Name       = $tmplName
                ChangeType = 'Added'
                Details    = "New certificate template: $tmplName"
                EnrolleeSuppliesSubject = $currentTmpl.enrolleeSuppliesSubject
                AllowsAuthentication    = $currentTmpl.allowsAuthentication
            })
            continue
        }

        $prevTmpl = $prevCerts[$tmplName]
        $certDiffs = [System.Collections.Generic.List[string]]::new()

        if ($currentTmpl.whenChanged -ne $prevTmpl.whenChanged) { $certDiffs.Add('timestamp changed') }
        if ($currentTmpl.enrolleeSuppliesSubject -ne $prevTmpl.enrolleeSuppliesSubject) {
            $certDiffs.Add("enrolleeSuppliesSubject: $($prevTmpl.enrolleeSuppliesSubject) -> $($currentTmpl.enrolleeSuppliesSubject)")
        }
        if ($currentTmpl.allowsAuthentication -ne $prevTmpl.allowsAuthentication) {
            $certDiffs.Add("allowsAuthentication: $($prevTmpl.allowsAuthentication) -> $($currentTmpl.allowsAuthentication)")
        }
        if ($currentTmpl.isPublished -ne $prevTmpl.isPublished) {
            $certDiffs.Add("isPublished: $($prevTmpl.isPublished) -> $($currentTmpl.isPublished)")
        }

        if ($certDiffs.Count -gt 0) {
            $certChanges.Add(@{
                Name       = $tmplName
                ChangeType = 'Modified'
                Details    = "Template modified: $($certDiffs -join '; ')"
                EnrolleeSuppliesSubject = $currentTmpl.enrolleeSuppliesSubject
                AllowsAuthentication    = $currentTmpl.allowsAuthentication
            })
        }
    }

    foreach ($tmplName in $prevCerts.Keys) {
        if (-not $CurrentData.certTemplates.ContainsKey($tmplName)) {
            $certChanges.Add(@{
                Name       = $tmplName
                ChangeType = 'Removed'
                Details    = "Certificate template removed: $tmplName"
            })
        }
    }

    $changes.CertTemplateChanges = @($certChanges)

    # ── 8. Delegation changes (Full mode) ──────────────────────────────
    $prevDelegations = if ($PreviousBaseline.ContainsKey('delegations')) { $PreviousBaseline['delegations'] } else { @{} }

    $delegationChanges = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($ouDN in $CurrentData.delegations.Keys) {
        $currentEntries = $CurrentData.delegations[$ouDN]
        $currentStrings = @($currentEntries | ForEach-Object {
            "$($_.identity)|$($_.rights)|$($_.objectType)"
        } | Sort-Object)
        $currentHash = if ($currentStrings.Count -gt 0) {
            $sha = [System.Security.Cryptography.SHA256]::Create()
            $joined = $currentStrings -join '||'
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined)
            $hashBytes = $sha.ComputeHash($bytes)
            [BitConverter]::ToString($hashBytes) -replace '-', ''
        } else { 'EMPTY' }

        if (-not $prevDelegations.ContainsKey($ouDN)) {
            if ($currentEntries.Count -gt 0) {
                $delegationChanges.Add(@{
                    OUDN       = $ouDN
                    ChangeType = 'Added'
                    Details    = "New delegations on: $ouDN ($($currentEntries.Count) entries)"
                    Entries    = @($currentEntries)
                })
            }
            continue
        }

        $prevHash = if ($prevDelegations[$ouDN].ContainsKey('hash')) { $prevDelegations[$ouDN]['hash'] } else { 'EMPTY' }

        if ($currentHash -ne $prevHash) {
            $delegationChanges.Add(@{
                OUDN       = $ouDN
                ChangeType = 'Modified'
                Details    = "Delegations modified on: $ouDN"
                Entries    = @($currentEntries)
            })
        }
    }

    $changes.DelegationChanges = @($delegationChanges)

    # ── 9. DNS record changes (Full mode) ──────────────────────────────
    $prevDNS = if ($PreviousBaseline.ContainsKey('dnsRecords') -and $PreviousBaseline['dnsRecords'].ContainsKey('recordSet')) {
        [System.Collections.Generic.HashSet[string]]::new(
            [string[]]$PreviousBaseline['dnsRecords']['recordSet'],
            [StringComparer]::OrdinalIgnoreCase
        )
    } else {
        [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    }

    $currentDNSSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($rec in $CurrentData.dnsRecords) {
        [void]$currentDNSSet.Add("$($rec.zone)|$($rec.name)")
    }

    $dnsChanges = [System.Collections.Generic.List[hashtable]]::new()

    # New DNS records
    foreach ($key in $currentDNSSet) {
        if (-not $prevDNS.Contains($key)) {
            $parts = $key -split '\|', 2
            $dnsChanges.Add(@{
                Name       = $parts[1]
                Zone       = $parts[0]
                ChangeType = 'Added'
                Details    = "New DNS record: $($parts[1]) in zone $($parts[0])"
            })
        }
    }

    # Removed DNS records
    foreach ($key in $prevDNS) {
        if (-not $currentDNSSet.Contains($key)) {
            $parts = $key -split '\|', 2
            $dnsChanges.Add(@{
                Name       = $parts[1]
                Zone       = $parts[0]
                ChangeType = 'Removed'
                Details    = "DNS record removed: $($parts[1]) from zone $($parts[0])"
            })
        }
    }

    $changes.DNSChanges = @($dnsChanges)

    # ── 10. Schema changes (Full mode) ─────────────────────────────────
    $prevSchemaVersion = if ($PreviousBaseline.ContainsKey('schemaVersion')) { $PreviousBaseline['schemaVersion'] } else { 0 }

    if ($CurrentData.schemaVersion -ne 0 -and $prevSchemaVersion -ne 0 -and $CurrentData.schemaVersion -ne $prevSchemaVersion) {
        $changes.SchemaChanges = @(@{
            ChangeType      = 'VersionChanged'
            PreviousVersion = $prevSchemaVersion
            CurrentVersion  = $CurrentData.schemaVersion
            Details         = "Schema version changed: $prevSchemaVersion -> $($CurrentData.schemaVersion)"
        })
    }

    # ── 11. New computer accounts and service accounts from recentlyChanged ─
    $recentComputers = [System.Collections.Generic.List[hashtable]]::new()
    $recentServiceAccounts = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($obj in $CurrentData.recentlyChanged) {
        # Check if recently created (within last 7 days, created == changed for new objects)
        $isRecentCreation = $false
        if ($obj.whenCreated -and $obj.whenChanged) {
            try {
                $created = [datetime]::Parse($obj.whenCreated)
                $changed = [datetime]::Parse($obj.whenChanged)
                $timeDiff = [Math]::Abs(($changed - $created).TotalMinutes)
                $isRecentCreation = $timeDiff -lt 60 -and $created -gt [datetime]::UtcNow.AddDays(-7)
            } catch { }
        }

        if ($isRecentCreation) {
            if ($obj.objectClass -eq 'computer') {
                $recentComputers.Add(@{
                    DN          = $obj.dn
                    SAM         = $obj.sam
                    WhenCreated = $obj.whenCreated
                })
            }

            if ($obj.objectClass -eq 'user' -and ($obj.sam -match '^svc[_-]' -or $obj.sam -match '_svc$' -or $obj.dn -match 'OU=Service')) {
                $recentServiceAccounts.Add(@{
                    DN          = $obj.dn
                    SAM         = $obj.sam
                    WhenCreated = $obj.whenCreated
                })
            }
        }
    }

    $changes.NewComputers = @($recentComputers)
    $changes.NewServiceAccounts = @($recentServiceAccounts)
    $changes.RecentlyChanged = @($CurrentData.recentlyChanged)

    # ── 12. Password changes for privileged group members ────────────────
    $passwordChanges = [System.Collections.Generic.List[hashtable]]::new()

    # Detect recently-changed user objects that are members of privileged groups
    $privilegedMembers = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($groupName in $CurrentData.privilegedGroups.Keys) {
        foreach ($member in @($CurrentData.privilegedGroups[$groupName])) {
            [void]$privilegedMembers.Add($member)
        }
    }

    foreach ($obj in $CurrentData.recentlyChanged) {
        if ($obj.objectClass -eq 'user' -and $privilegedMembers.Contains($obj.sam)) {
            $passwordChanges.Add(@{
                SAM         = $obj.sam
                DN          = $obj.dn
                WhenChanged = $obj.whenChanged
                Group       = ($CurrentData.privilegedGroups.Keys | Where-Object {
                    $obj.sam -in @($CurrentData.privilegedGroups[$_])
                }) -join ', '
            })
        }
    }

    $changes.PasswordChanges = @($passwordChanges)

    return $changes
}