Public/Get-StaleGPOs.ps1

function Get-StaleGPOs {
    <#
    .SYNOPSIS
        Finds Group Policy Objects that have not been modified within a given threshold.
 
    .DESCRIPTION
        Scans every GPO in the domain and compares its ModificationTime against a
        configurable staleness threshold (default: 365 days). Stale GPOs often represent
        policies created for a project that ended years ago, test policies that were
        never cleaned up, or settings that have been superseded by newer GPOs.
 
        By default, only GPOs that are BOTH stale AND unlinked are returned (the safest
        cleanup candidates). Use -IncludeLinked to also show stale GPOs that are still
        linked to OUs.
 
        This function is read-only and never modifies or deletes GPOs.
 
    .PARAMETER DaysStale
        Number of days since last modification to consider a GPO stale. Must be between
        30 and 3650. Default is 365.
 
    .PARAMETER IncludeLinked
        Include stale GPOs that are still linked to OUs. By default, only stale GPOs
        that are also unlinked are returned.
 
    .EXAMPLE
        Get-StaleGPOs
 
        Returns GPOs not modified in over 365 days that are also unlinked.
 
    .EXAMPLE
        Get-StaleGPOs -DaysStale 180 -IncludeLinked
 
        Returns all GPOs not modified in 180 days, whether linked or not.
 
    .OUTPUTS
        [PSCustomObject] with properties: DisplayName, Id, ModificationTime, DaysSinceModified, IsLinked, Finding
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateRange(30, 3650)]
        [int]$DaysStale = 365,

        [Parameter()]
        [switch]$IncludeLinked
    )

    begin {
        Write-Verbose "Get-StaleGPOs: Starting scan for GPOs not modified in $DaysStale+ days"

        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-StaleGPOs: Retrieved $($AllGPOs.Count) GPOs from domain"
        }
        catch {
            Write-Error "Failed to retrieve GPOs: $_"
            return
        }

        $CutoffDate = (Get-Date).AddDays(-$DaysStale)
        $StaleCount = 0

        foreach ($GPO in $AllGPOs) {
            # Skip GPOs modified within the threshold
            if ($GPO.ModificationTime -gt $CutoffDate) {
                continue
            }

            $DaysSinceModified = [math]::Round(((Get-Date) - $GPO.ModificationTime).TotalDays, 0)

            Write-Verbose "Get-StaleGPOs: '$($GPO.DisplayName)' last modified $DaysSinceModified days ago"

            # Determine link status via XML report
            $IsLinked = $false
            try {
                [xml]$Report = Get-GPOReport -Guid $GPO.Id -ReportType Xml -ErrorAction Stop

                $Links = $Report.GPO.LinksTo
                if ($null -ne $Links -and @($Links).Count -gt 0) {
                    $IsLinked = $true
                }
            }
            catch {
                Write-Warning "Get-StaleGPOs: Could not determine link status for '$($GPO.DisplayName)': $_"
            }

            # By default, only return stale + unlinked GPOs
            if ($IsLinked -and -not $IncludeLinked) {
                continue
            }

            $Finding = if ($IsLinked) { 'STALE_LINKED' } else { 'STALE_UNLINKED' }

            $StaleCount++
            [PSCustomObject]@{
                DisplayName       = $GPO.DisplayName
                Id                = $GPO.Id.ToString()
                ModificationTime  = $GPO.ModificationTime
                DaysSinceModified = $DaysSinceModified
                IsLinked          = $IsLinked
                Finding           = $Finding
            }
        }

        Write-Verbose "Get-StaleGPOs: Scan complete. Found $StaleCount stale GPO(s) out of $($AllGPOs.Count) total."
    }
}