Public/Get-NestedGroupReport.ps1

function Get-NestedGroupReport {
    <#
    .SYNOPSIS
        Identifies security groups in ACLs that are nested beyond a safe depth.
 
    .DESCRIPTION
        For each security group found in the NTFS ACLs of the specified paths, recursively
        resolves group membership using Get-ADGroupMember to determine nesting depth. Groups
        nested more than MaxNestingDepth levels are flagged. Deeply nested groups make
        permission troubleshooting nearly impossible and are a common audit concern because
        effective access becomes unpredictable.
 
    .PARAMETER Path
        One or more file system paths to scan.
 
    .PARAMETER MaxNestingDepth
        The maximum acceptable group nesting depth. Groups nested deeper than this value
        are flagged. Defaults to 3.
 
    .PARAMETER MaxDepth
        Maximum folder recursion depth for scanning. Defaults to 3.
 
    .EXAMPLE
        Get-NestedGroupReport -Path 'D:\Shares' -MaxNestingDepth 2
 
    .EXAMPLE
        Get-NestedGroupReport -Path '\\server\data' -MaxNestingDepth 4 -MaxDepth 5
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$MaxNestingDepth = 3,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$MaxDepth = 3
    )

    begin {
        # Cache resolved groups to avoid redundant AD queries
        $groupCache = @{}

        function Get-GroupNestingDepth {
            <#
            .SYNOPSIS
                Recursively resolves a group's membership to determine its nesting depth.
            #>

            param(
                [string]$GroupName,
                [int]$CurrentDepth = 0,
                [System.Collections.Generic.HashSet[string]]$Visited = $null
            )

            if ($null -eq $Visited) {
                $Visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            }

            # Prevent circular references
            if (-not $Visited.Add($GroupName)) {
                return @{
                    Depth       = $CurrentDepth
                    MemberChain = @($GroupName, '(circular reference)')
                }
            }

            # Check cache
            if ($groupCache.ContainsKey($GroupName)) {
                return $groupCache[$GroupName]
            }

            $maxFound    = $CurrentDepth
            $deepestChain = @($GroupName)

            try {
                $members = Get-ADGroupMember -Identity $GroupName -ErrorAction Stop

                foreach ($member in $members) {
                    if ($member.objectClass -eq 'group') {
                        $nested = Get-GroupNestingDepth -GroupName $member.SamAccountName -CurrentDepth ($CurrentDepth + 1) -Visited $Visited

                        if ($nested.Depth -gt $maxFound) {
                            $maxFound     = $nested.Depth
                            $deepestChain = @($GroupName) + $nested.MemberChain
                        }
                    }
                }
            }
            catch {
                Write-Verbose "Cannot resolve group '$GroupName': $_"
            }

            $result = @{
                Depth       = $maxFound
                MemberChain = $deepestChain
            }

            $groupCache[$GroupName] = $result
            return $result
        }

        function Get-FoldersToDepth {
            param(
                [string]$RootPath,
                [int]$Depth
            )

            $folders = @([PSCustomObject]@{ FullName = $RootPath; Depth = 0 })

            if ($Depth -ge 1) {
                $children = Get-ChildItem -Path $RootPath -Directory -Recurse -Depth ($Depth - 1) -ErrorAction SilentlyContinue
                foreach ($child in $children) {
                    $relativePath = $child.FullName.Substring($RootPath.Length).TrimStart('\', '/')
                    $currentDepth = ($relativePath -split '[\\/]').Count
                    $folders += [PSCustomObject]@{ FullName = $child.FullName; Depth = $currentDepth }
                }
            }

            $folders
        }

        $results          = [System.Collections.Generic.List[PSObject]]::new()
        $processedGroups  = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    }

    process {
        foreach ($scanPath in $Path) {
            if (-not (Test-Path -Path $scanPath)) {
                Write-Warning "Path not found: $scanPath"
                continue
            }

            Write-Verbose "Scanning for nested groups: $scanPath (MaxNestingDepth: $MaxNestingDepth, MaxDepth: $MaxDepth)"

            $folders = Get-FoldersToDepth -RootPath $scanPath -Depth $MaxDepth

            foreach ($folder in $folders) {
                try {
                    $acl = Get-Acl -Path $folder.FullName -ErrorAction Stop
                }
                catch {
                    Write-Warning "Cannot read ACL on $($folder.FullName): $_"
                    continue
                }

                # Get unique identity references from the ACL
                $identities = $acl.Access | Select-Object -ExpandProperty IdentityReference | Select-Object -ExpandProperty Value -Unique

                foreach ($identity in $identities) {
                    # Extract the account name (strip domain)
                    $accountName = $identity
                    if ($identity -match '^(.+)\\(.+)$') {
                        $accountName = $Matches[2]
                    }

                    # Skip if we already processed this group for this folder
                    $cacheKey = "$($folder.FullName)|$accountName"
                    if (-not $processedGroups.Add($cacheKey)) {
                        continue
                    }

                    # Check if this identity is a group
                    try {
                        $null = Get-ADGroup -Identity $accountName -ErrorAction Stop
                    }
                    catch {
                        continue   # Not a group, skip
                    }

                    # Resolve nesting depth
                    $nesting = Get-GroupNestingDepth -GroupName $accountName

                    $finding = if ($nesting.Depth -gt $MaxNestingDepth) {
                        "EXCESSIVE NESTING - Depth $($nesting.Depth) exceeds maximum of $MaxNestingDepth"
                    }
                    else {
                        'OK'
                    }

                    $result = [PSCustomObject]@{
                        Path            = $folder.FullName
                        GroupName       = $identity
                        NestingDepth    = $nesting.Depth
                        MemberChain     = ($nesting.MemberChain -join ' > ')
                        Finding         = $finding
                    }

                    $results.Add($result)
                }
            }
        }
    }

    end {
        $results
    }
}