Public/Get-UnlinkedGPOs.ps1

function Get-UnlinkedGPOs {
    <#
    .SYNOPSIS
        Finds Group Policy Objects that are not linked to any OU, site, or domain.
 
    .DESCRIPTION
        Retrieves every GPO in the domain, generates an XML report for each, and checks
        for the presence of <LinksTo> elements. GPOs with zero links are returned as
        findings. Unlinked GPOs still consume SYSVOL space and appear in GPMC but never
        apply to any object -- they are safe deletion candidates.
 
        This function is read-only and never modifies or deletes GPOs.
 
    .EXAMPLE
        Get-UnlinkedGPOs
 
        Returns all GPOs in the current domain that have no links.
 
    .EXAMPLE
        Get-UnlinkedGPOs | Export-Csv -Path .\unlinked.csv -NoTypeInformation
 
        Exports unlinked GPO findings to CSV for offline review.
 
    .OUTPUTS
        [PSCustomObject] with properties: DisplayName, Id, CreationTime, ModificationTime, Owner, Finding
    #>

    [CmdletBinding()]
    param()

    begin {
        Write-Verbose 'Get-UnlinkedGPOs: Starting scan for unlinked Group Policy Objects'

        # Validate that the GroupPolicy module is available
        if (-not (Get-Module -ListAvailable -Name GroupPolicy)) {
            throw 'The GroupPolicy RSAT module is required but not installed. Install RSAT tools and try again.'
        }
        Import-Module GroupPolicy -ErrorAction Stop -Verbose:$false
    }

    process {
        try {
            $AllGPOs = @(Get-GPO -All -ErrorAction Stop)
            Write-Verbose "Get-UnlinkedGPOs: Retrieved $($AllGPOs.Count) GPOs from domain"
        }
        catch {
            Write-Error "Failed to retrieve GPOs: $_"
            return
        }

        $UnlinkedCount = 0

        foreach ($GPO in $AllGPOs) {
            Write-Verbose "Get-UnlinkedGPOs: Checking links for '$($GPO.DisplayName)'"

            try {
                [xml]$Report = Get-GPOReport -Guid $GPO.Id -ReportType Xml -ErrorAction Stop
            }
            catch {
                Write-Warning "Get-UnlinkedGPOs: Could not generate report for '$($GPO.DisplayName)': $_"
                continue
            }

            # The GPO XML report uses a namespace -- check for LinksTo elements
            $NamespaceManager = New-Object System.Xml.XmlNamespaceManager($Report.NameTable)
            $NamespaceManager.AddNamespace('gpo', 'http://www.microsoft.com/GroupPolicy/Settings')
            $Links = $Report.SelectNodes('//gpo:LinksTo', $NamespaceManager)

            # Fallback: also try without namespace (some report formats differ)
            if ($null -eq $Links -or $Links.Count -eq 0) {
                $Links = $Report.GPO.LinksTo
            }

            if ($null -eq $Links -or @($Links).Count -eq 0) {
                $UnlinkedCount++
                [PSCustomObject]@{
                    DisplayName      = $GPO.DisplayName
                    Id               = $GPO.Id.ToString()
                    CreationTime     = $GPO.CreationTime
                    ModificationTime = $GPO.ModificationTime
                    Owner            = $GPO.Owner
                    Finding          = 'UNLINKED'
                }
            }
        }

        Write-Verbose "Get-UnlinkedGPOs: Scan complete. Found $UnlinkedCount unlinked GPO(s) out of $($AllGPOs.Count) total."
    }
}