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 } } |