Private/AD/Core/Get-ADGroupPolicyObjects.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 Get-ADGroupPolicyObjects {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Connection,

        [switch]$Quiet
    )

    $result = @{
        GPOs                = @()
        GPOLinks            = @{}
        SYSVOLContent       = @{}
        GPOVersionMismatch  = @()
        WMIFilters          = @()
        GPOPermissions      = @{}
    }

    $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN
    $domainFqdn = ($Connection.DomainDN -replace ',DC=', '.' -replace '^DC=', '')

    # ── Query all GPOs ────────────────────────────────────────────────────────
    Write-Verbose 'Querying all Group Policy Objects from AD...'
    try {
        $gpoPoliciesDN = "CN=Policies,CN=System,$($Connection.DomainDN)"
        $gpoRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $gpoPoliciesDN

        $gpoResults = Invoke-LdapQuery -SearchRoot $gpoRoot `
            -Filter '(objectClass=groupPolicyContainer)' `
            -Properties @(
                'displayname', 'distinguishedname', 'name', 'whencreated', 'whenchanged',
                'versionnumber', 'gpcfilesyspath', 'flags', 'gpcmachineextensionnames',
                'gpcuserextensionnames'
            )

        Write-Verbose "Found $($gpoResults.Count) GPO(s) in AD."
    } catch {
        Write-Warning "Failed to query GPOs: $_"
        return $result
    }

    # ── Collect gPLink from all containers (OUs, domain root, sites) ──────────
    Write-Verbose 'Collecting gPLink attributes from OUs, domain root, and sites...'
    $gpoLinksMap = @{}

    # gPLink from OUs and domain root
    try {
        $linkContainers = Invoke-LdapQuery -SearchRoot $searchRoot `
            -Filter '(|(objectClass=organizationalUnit)(objectClass=domainDNS))' `
            -Properties @('distinguishedname', 'gplink')

        foreach ($container in $linkContainers) {
            $containerDN = $container['distinguishedname'] ?? ''
            $gpLink = $container['gplink']
            if ($containerDN -and $gpLink) {
                $gpoLinksMap[$containerDN] = $gpLink
            }
        }
    } catch {
        Write-Warning "Failed to read gPLink from OUs: $_"
    }

    # gPLink from sites
    try {
        $sitesDN = "CN=Sites,$($Connection.ConfigDN)"
        $sitesRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $sitesDN
        $siteContainers = Invoke-LdapQuery -SearchRoot $sitesRoot `
            -Filter '(objectClass=site)' `
            -Properties @('distinguishedname', 'gplink')

        foreach ($site in $siteContainers) {
            $siteDN = $site['distinguishedname'] ?? ''
            $gpLink = $site['gplink']
            if ($siteDN -and $gpLink) {
                $gpoLinksMap[$siteDN] = $gpLink
            }
        }
    } catch {
        Write-Verbose "Failed to read gPLink from sites (may not have permissions): $_"
    }

    # ── Parse gPLink values ───────────────────────────────────────────────────
    # gPLink format: [LDAP://CN={GUID},CN=Policies,CN=System,DC=...;flags][LDAP://...;flags]
    # flags: 0=enabled, 1=disabled, 2=enforced, 3=disabled+enforced
    $parsedLinks = @{}
    $gpoDNToLinkedContainers = @{}

    foreach ($containerDN in $gpoLinksMap.Keys) {
        $linkStr = $gpoLinksMap[$containerDN]
        $parsed = [System.Collections.Generic.List[hashtable]]::new()

        $linkMatches = [regex]::Matches($linkStr, '\[LDAP://([^;]+);(\d+)\]')
        foreach ($match in $linkMatches) {
            $gpoDN = $match.Groups[1].Value
            $linkFlags = [int]$match.Groups[2].Value

            $linkEntry = @{
                GPODN       = $gpoDN
                Flags       = $linkFlags
                IsEnabled   = ($linkFlags -band 1) -eq 0
                IsEnforced  = ($linkFlags -band 2) -ne 0
            }
            $parsed.Add($linkEntry)

            # Build reverse mapping: GPO DN -> containers where it is linked
            $gpoDNLower = $gpoDN.ToLower()
            if (-not $gpoDNToLinkedContainers.ContainsKey($gpoDNLower)) {
                $gpoDNToLinkedContainers[$gpoDNLower] = [System.Collections.Generic.List[hashtable]]::new()
            }
            $gpoDNToLinkedContainers[$gpoDNLower].Add(@{
                ContainerDN = $containerDN
                IsEnabled   = $linkEntry.IsEnabled
                IsEnforced  = $linkEntry.IsEnforced
            })
        }

        $parsedLinks[$containerDN] = @($parsed)
    }

    $result.GPOLinks = $parsedLinks

    # ── Build GPO list with link information ──────────────────────────────────
    $gpoList = [System.Collections.Generic.List[hashtable]]::new()
    $versionMismatches = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($gpo in $gpoResults) {
        $gpoDN = $gpo['distinguishedname'] ?? ''
        $displayName = $gpo['displayname'] ?? $gpo['name'] ?? ''
        $gpoGuid = $gpo['name'] ?? ''  # 'name' is the {GUID} form

        $versionNumber = [int]($gpo['versionnumber'] ?? 0)
        # Version: high 16 bits = user version, low 16 bits = computer version
        $versionUser     = ($versionNumber -shr 16) -band 0xFFFF
        $versionComputer = $versionNumber -band 0xFFFF

        $flags = [int]($gpo['flags'] ?? 0)
        $gpcFileSysPath = $gpo['gpcfilesyspath'] ?? ''

        # Get linked containers from reverse map
        $linkedTo = @()
        $gpoDNLower = $gpoDN.ToLower()
        if ($gpoDNToLinkedContainers.ContainsKey($gpoDNLower)) {
            $linkedTo = @($gpoDNToLinkedContainers[$gpoDNLower])
        }

        $gpoEntry = @{
            DisplayName     = $displayName
            DN              = $gpoDN
            GUID            = $gpoGuid
            WhenCreated     = $gpo['whencreated']
            WhenChanged     = $gpo['whenchanged']
            VersionUser     = $versionUser
            VersionComputer = $versionComputer
            VersionNumber   = $versionNumber
            GPCFileSysPath  = $gpcFileSysPath
            Flags           = $flags
            FlagDescription = switch ($flags) {
                0 { 'All settings enabled' }
                1 { 'User configuration disabled' }
                2 { 'Computer configuration disabled' }
                3 { 'All settings disabled' }
                default { "Unknown ($flags)" }
            }
            LinkedTo        = $linkedTo
            IsLinked        = $linkedTo.Count -gt 0
            IsEmpty         = $false  # Will be updated during SYSVOL scan
        }

        $gpoList.Add($gpoEntry)
    }

    $result.GPOs = @($gpoList)
    Write-Verbose "Processed $($gpoList.Count) GPO(s) with link analysis."

    # ── SYSVOL Content Analysis ───────────────────────────────────────────────
    Write-Verbose 'Analyzing SYSVOL content for GPOs...'
    $sysvolBase = "\\$domainFqdn\SYSVOL\$domainFqdn\Policies"
    $sysvolAccessible = Test-Path -LiteralPath $sysvolBase -ErrorAction SilentlyContinue

    if (-not $sysvolAccessible) {
        Write-Verbose "SYSVOL not accessible at $sysvolBase. Skipping SYSVOL analysis."
    }

    foreach ($gpoEntry in $gpoList) {
        $gpoGuid = $gpoEntry.GUID
        $displayName = $gpoEntry.DisplayName

        if (-not $sysvolAccessible -or -not $gpoGuid) {
            $result.SYSVOLContent[$displayName] = @{
                Error = 'SYSVOL not accessible'
            }
            continue
        }

        $gpoSysvolPath = Join-Path $sysvolBase $gpoGuid
        $sysvolInfo = @{
            HasScripts         = $false
            HasPreferences     = $false
            HasRegistryPol     = $false
            ScriptFiles        = @()
            PreferenceFiles    = @()
            CPasswordFound     = $false
            CPasswordLocations = @()
            GptIniVersion      = $null
        }

        try {
            if (-not (Test-Path -LiteralPath $gpoSysvolPath -ErrorAction SilentlyContinue)) {
                $gpoEntry.IsEmpty = $true
                $result.SYSVOLContent[$displayName] = $sysvolInfo
                continue
            }

            # Check for scripts
            $scriptDirs = @(
                (Join-Path $gpoSysvolPath 'Machine\Scripts'),
                (Join-Path $gpoSysvolPath 'User\Scripts')
            )
            $scriptFiles = [System.Collections.Generic.List[string]]::new()
            foreach ($scriptDir in $scriptDirs) {
                if (Test-Path -LiteralPath $scriptDir -ErrorAction SilentlyContinue) {
                    $found = Get-ChildItem -LiteralPath $scriptDir -Recurse -File -ErrorAction SilentlyContinue
                    foreach ($f in $found) {
                        $scriptFiles.Add($f.FullName)
                    }
                }
            }
            $sysvolInfo.HasScripts = $scriptFiles.Count -gt 0
            $sysvolInfo.ScriptFiles = @($scriptFiles)

            # Check for Preferences
            $prefDirs = @(
                (Join-Path $gpoSysvolPath 'Machine\Preferences'),
                (Join-Path $gpoSysvolPath 'User\Preferences')
            )
            $prefFiles = [System.Collections.Generic.List[string]]::new()
            $cpassLocations = [System.Collections.Generic.List[string]]::new()

            foreach ($prefDir in $prefDirs) {
                if (Test-Path -LiteralPath $prefDir -ErrorAction SilentlyContinue) {
                    $found = Get-ChildItem -LiteralPath $prefDir -Recurse -File -ErrorAction SilentlyContinue
                    foreach ($f in $found) {
                        $prefFiles.Add($f.FullName)

                        # Scan XML files for cpassword
                        if ($f.Extension -eq '.xml') {
                            try {
                                $xmlContent = Get-Content -LiteralPath $f.FullName -Raw -ErrorAction Stop
                                if ($xmlContent -match 'cpassword') {
                                    $cpassLocations.Add($f.FullName)
                                }
                            } catch {
                                Write-Verbose "Could not read $($f.FullName): $_"
                            }
                        }
                    }
                }
            }
            $sysvolInfo.HasPreferences = $prefFiles.Count -gt 0
            $sysvolInfo.PreferenceFiles = @($prefFiles)
            $sysvolInfo.CPasswordFound = $cpassLocations.Count -gt 0
            $sysvolInfo.CPasswordLocations = @($cpassLocations)

            # Check for Registry.pol
            $regPolPaths = @(
                (Join-Path $gpoSysvolPath 'Machine\Registry.pol'),
                (Join-Path $gpoSysvolPath 'User\Registry.pol')
            )
            foreach ($regPolPath in $regPolPaths) {
                if (Test-Path -LiteralPath $regPolPath -ErrorAction SilentlyContinue) {
                    $sysvolInfo.HasRegistryPol = $true
                    break
                }
            }

            # Check if GPO is effectively empty (only GPT.INI exists)
            $allFiles = @(Get-ChildItem -LiteralPath $gpoSysvolPath -Recurse -File -ErrorAction SilentlyContinue)
            $nonDefaultFiles = @($allFiles | Where-Object { $_.Name -ne 'GPT.INI' })
            $gpoEntry.IsEmpty = $nonDefaultFiles.Count -eq 0

            # ── GPT.INI version check ─────────────────────────────────────
            $gptIniPath = Join-Path $gpoSysvolPath 'GPT.INI'
            if (Test-Path -LiteralPath $gptIniPath -ErrorAction SilentlyContinue) {
                try {
                    $gptContent = Get-Content -LiteralPath $gptIniPath -ErrorAction Stop
                    foreach ($line in $gptContent) {
                        if ($line -match '^\s*Version\s*=\s*(\d+)') {
                            $sysvolVersion = [int]$Matches[1]
                            $sysvolInfo.GptIniVersion = $sysvolVersion

                            $sysvolUserVer     = ($sysvolVersion -shr 16) -band 0xFFFF
                            $sysvolComputerVer = $sysvolVersion -band 0xFFFF

                            if ($sysvolUserVer -ne $gpoEntry.VersionUser -or $sysvolComputerVer -ne $gpoEntry.VersionComputer) {
                                $mismatch = @{
                                    DisplayName        = $displayName
                                    GUID               = $gpoGuid
                                    ADVersionUser      = $gpoEntry.VersionUser
                                    ADVersionComputer  = $gpoEntry.VersionComputer
                                    SYSVOLVersionUser  = $sysvolUserVer
                                    SYSVOLVersionComputer = $sysvolComputerVer
                                }
                                $versionMismatches.Add($mismatch)
                            }
                            break
                        }
                    }
                } catch {
                    Write-Verbose "Could not parse GPT.INI for $displayName`: $_"
                }
            }
        } catch {
            Write-Verbose "SYSVOL analysis failed for GPO $displayName`: $_"
            $sysvolInfo['Error'] = "Failed: $_"
        }

        $result.SYSVOLContent[$displayName] = $sysvolInfo
    }

    $result.GPOVersionMismatch = @($versionMismatches)
    if ($versionMismatches.Count -gt 0) {
        Write-Verbose "Found $($versionMismatches.Count) GPO(s) with AD/SYSVOL version mismatch."
    }

    # ── WMI Filters ───────────────────────────────────────────────────────────
    Write-Verbose 'Querying WMI filters...'
    try {
        $wmiContainerDN = "CN=SOM,CN=WMIPolicy,CN=System,$($Connection.DomainDN)"
        $wmiRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $wmiContainerDN
        $wmiResults = Invoke-LdapQuery -SearchRoot $wmiRoot `
            -Filter '(objectClass=msWMI-Som)' `
            -Properties @('msWMI-Name', 'msWMI-Parm1', 'msWMI-Parm2', 'distinguishedname', 'msWMI-ID', 'whencreated')

        $wmiList = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($wmi in $wmiResults) {
            $wmiList.Add(@{
                Name        = $wmi['mswmi-name'] ?? ''
                Description = $wmi['mswmi-parm1'] ?? ''
                Query       = $wmi['mswmi-parm2'] ?? ''
                DN          = $wmi['distinguishedname'] ?? ''
                ID          = $wmi['mswmi-id'] ?? ''
                WhenCreated = $wmi['whencreated']
            })
        }

        $result.WMIFilters = @($wmiList)
        Write-Verbose "Found $($wmiList.Count) WMI filter(s)."
    } catch {
        Write-Verbose "Failed to query WMI filters (container may not exist): $_"
    }

    # ── GPO Permissions (edit vs apply) ───────────────────────────────────────
    Write-Verbose 'Analyzing GPO DACL permissions (edit vs apply)...'
    $gpoPerms = @{}

    foreach ($gpoEntry in $gpoList) {
        $gpoDN = $gpoEntry.DN
        $displayName = $gpoEntry.DisplayName

        try {
            $gpoAdEntry = New-LdapSearchRoot -Connection $Connection -SearchBase $gpoDN
            $gpoSd = $gpoAdEntry.ObjectSecurity
            if ($null -eq $gpoSd) { continue }

            $rules = $gpoSd.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])
            $canEdit = [System.Collections.Generic.List[string]]::new()
            $canApply = [System.Collections.Generic.List[string]]::new()
            $canLink = [System.Collections.Generic.List[string]]::new()

            foreach ($rule in $rules) {
                if ($rule.AccessControlType.ToString() -ne 'Allow') { continue }

                $sidStr = $rule.IdentityReference.Value
                $resolved = Resolve-ADSid -SidString $sidStr -SearchRoot $searchRoot
                $rights = $rule.ActiveDirectoryRights.ToString()

                # Edit permissions: write access to GPO object
                if ($rights -match 'GenericAll|GenericWrite|WriteDacl|WriteOwner|WriteProperty') {
                    if (-not $canEdit.Contains($resolved)) {
                        $canEdit.Add($resolved)
                    }
                }

                # Apply permissions: read + apply (GenericRead or ReadProperty with GenericExecute)
                if ($rights -match 'GenericRead|GenericExecute|ReadProperty') {
                    if (-not $canApply.Contains($resolved)) {
                        $canApply.Add($resolved)
                    }
                }
            }

            $gpoPerms[$displayName] = @{
                DN       = $gpoDN
                CanEdit  = @($canEdit)
                CanApply = @($canApply)
            }
        } catch {
            Write-Verbose "Failed to read DACL for GPO $displayName`: $_"
        }
    }

    # Check who can link GPOs (write gPLink on containers)
    try {
        $ouResults = Invoke-LdapQuery -SearchRoot $searchRoot `
            -Filter '(|(objectClass=organizationalUnit)(objectClass=domainDNS))' `
            -Properties @('distinguishedname')

        foreach ($ou in $ouResults) {
            $ouDN = $ou['distinguishedname'] ?? ''
            if (-not $ouDN) { continue }

            try {
                $ouEntry = New-LdapSearchRoot -Connection $Connection -SearchBase $ouDN
                $ouSd = $ouEntry.ObjectSecurity
                if ($null -eq $ouSd) { continue }

                $rules = $ouSd.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])
                foreach ($rule in $rules) {
                    if ($rule.AccessControlType.ToString() -ne 'Allow') { continue }
                    $rights = $rule.ActiveDirectoryRights.ToString()

                    # Writing gPLink requires WriteProperty on the specific attribute
                    # gPLink attribute GUID: f30e3bbe-9ff0-11d1-b603-0000f80367c1
                    $objectTypeGuid = if ($rule.ObjectType) { $rule.ObjectType.ToString() } else { $null }
                    $isGPLinkWrite = ($rights -match 'WriteProperty') -and
                                    ($objectTypeGuid -eq 'f30e3bbe-9ff0-11d1-b603-0000f80367c1' -or
                                     $rights -match 'GenericAll|GenericWrite')

                    if ($isGPLinkWrite) {
                        $sidStr = $rule.IdentityReference.Value
                        $resolved = Resolve-ADSid -SidString $sidStr -SearchRoot $searchRoot

                        # Add linking info to all GPOs (linking ability is per-container, not per-GPO)
                        foreach ($gpoDisplayName in $gpoPerms.Keys) {
                            if (-not $gpoPerms[$gpoDisplayName].ContainsKey('CanLinkAt')) {
                                $gpoPerms[$gpoDisplayName]['CanLinkAt'] = @{}
                            }
                            if (-not $gpoPerms[$gpoDisplayName]['CanLinkAt'].ContainsKey($resolved)) {
                                $gpoPerms[$gpoDisplayName]['CanLinkAt'][$resolved] = [System.Collections.Generic.List[string]]::new()
                            }
                            if (-not $gpoPerms[$gpoDisplayName]['CanLinkAt'][$resolved].Contains($ouDN)) {
                                $gpoPerms[$gpoDisplayName]['CanLinkAt'][$resolved].Add($ouDN)
                            }
                        }
                    }
                }
            } catch {
                Write-Verbose "Failed to check link permissions on $ouDN`: $_"
            }
        }
    } catch {
        Write-Verbose "Failed to analyze GPO link permissions: $_"
    }

    $result.GPOPermissions = $gpoPerms
    Write-Verbose "Completed GPO permission analysis for $($gpoPerms.Count) GPO(s)."

    return $result
}