PowerShell Active Directory Group Nesting Audit Script

PowerShell Active Directory Group Nesting Audit Script

PowerShell Tips Editor 5 min read
PowerShell Active Directory Group Nesting Audit Script

Deep Active Directory group nesting is one of those problems that grows silently over years. A user gets added to Group A, which is nested in Group B, which is in Group C, which is in Group D — and suddenly their Kerberos token has 200 group SIDs and authentication starts failing with token bloat errors. Security audits flag deep nesting as a control weakness. This script recursively maps the full membership chain of every group in your domain and builds a flat report with depth column so you can see exactly where the nesting is deepest.

Understanding AD Group Types and Scope

Before auditing, it helps to know what you are looking at. AD groups have two dimensions: type (Security or Distribution) and scope (Domain Local, Global, Universal). Security groups contribute SIDs to tokens; distribution groups do not. Universal groups are replicated to the Global Catalog and can contain members from any domain in the forest, making them the most common nesting offender in multi-domain environments. Your audit should focus on Security groups, especially Universal ones.

# Get all security groups and their scope
Get-ADGroup -Filter { GroupCategory -eq 'Security' } -Properties GroupScope |
    Group-Object GroupScope |
    Select-Object Name, Count |
    Format-Table -AutoSize
Name         Count
----         -----
DomainLocal    412
Global         891
Universal      203

Recursive Get-ADGroupMember with Depth Tracking

The built-in -Recursive switch on Get-ADGroupMember flattens the tree completely — you get all leaf members but lose the nesting structure and depth information. To track depth you need a recursive function that calls itself and increments a counter. The function emits a row for every group-within-group relationship it finds, recording the parent group, the nested group, and the current depth level.

function Get-GroupNestingTree {
    param(
        [string]$GroupName,
        [int]$CurrentDepth = 0,
        [int]$MaxDepth = 10,
        [System.Collections.Generic.HashSet[string]]$Visited = $null
    )

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

    # Circular reference guard
    if (-not $Visited.Add($GroupName)) {
        [PSCustomObject]@{
            ParentGroup  = $GroupName
            NestedGroup  = 'CIRCULAR REFERENCE'
            Depth        = $CurrentDepth
            IsCircular   = $true
        }
        return
    }

    if ($CurrentDepth -ge $MaxDepth) { return }

    $members = Get-ADGroupMember -Identity $GroupName -ErrorAction SilentlyContinue |
               Where-Object { $_.objectClass -eq 'group' }

    foreach ($member in $members) {
        [PSCustomObject]@{
            ParentGroup  = $GroupName
            NestedGroup  = $member.Name
            Depth        = $CurrentDepth + 1
            IsCircular   = $false
        }
        # Recurse — pass a copy of Visited so sibling branches don't block each other
        $branchVisited = [System.Collections.Generic.HashSet[string]]::new(
            $Visited, [System.StringComparer]::OrdinalIgnoreCase
        )
        Get-GroupNestingTree -GroupName $member.Name `
                             -CurrentDepth ($CurrentDepth + 1) `
                             -MaxDepth $MaxDepth `
                             -Visited $branchVisited
    }
}

Detecting Circular Group References

Circular references — Group A nested in Group B, Group B nested in Group A — should not exist in a healthy AD, but they do occur in forests that have been migrated or had groups imported from third-party tools. Without a visited-set guard, a recursive function will stack-overflow on a circular reference. The function above uses a HashSet<string> per branch to detect revisits and emit a CIRCULAR REFERENCE sentinel row rather than recursing indefinitely. Note that the visited set is cloned per branch — this prevents a group appearing in two separate legitimate branches from being incorrectly flagged as circular.

Building a Flat Membership Report with Depth Column

Invoke the recursive function for every security group in the domain and collect all rows into a list. This can take several minutes in large forests — add progress output to make it auditable while running.

$allGroups = Get-ADGroup -Filter { GroupCategory -eq 'Security' } |
             Select-Object -ExpandProperty Name

$nestingReport = [System.Collections.Generic.List[PSCustomObject]]::new()
$i = 0

foreach ($group in $allGroups) {
    $i++
    if ($i % 50 -eq 0) { Write-Progress -Activity 'Auditing group nesting' -Status "$i / $($allGroups.Count)" -PercentComplete (($i / $allGroups.Count) * 100) }
    $rows = Get-GroupNestingTree -GroupName $group
    if ($rows) { $nestingReport.AddRange([PSCustomObject[]]$rows) }
}

Write-Progress -Activity 'Auditing group nesting' -Completed
Write-Host "Total nesting relationships found: $($nestingReport.Count)"

Flagging Groups Exceeding a Maximum Nesting Depth

With the flat report built, filtering for violations is straightforward. A depth of 3 or more is typically considered excessive for most compliance frameworks. Adjust the threshold to match your security policy.

$depthThreshold = 3

$violations = $nestingReport | Where-Object { $_.Depth -ge $depthThreshold -or $_.IsCircular }
Write-Host "Groups exceeding depth $depthThreshold or circular: $($violations.Count)"

$violations | Sort-Object Depth -Descending | Format-Table ParentGroup, NestedGroup, Depth, IsCircular -AutoSize

Exporting the Audit Report to HTML

An HTML report is easier for stakeholders to review than a CSV. Use ConvertTo-Html with a minimal inline stylesheet to produce a self-contained file. Color-code rows by depth severity for visual clarity:

$style = @"
<style>
  body { font-family: Segoe UI, sans-serif; font-size: 13px; }
  table { border-collapse: collapse; width: 100%; }
  th { background: #1e3a5f; color: white; padding: 6px 10px; text-align: left; }
  td { padding: 5px 10px; border-bottom: 1px solid #ddd; }
  tr.depth3 { background: #fff3cd; }
  tr.depth5 { background: #f8d7da; }
  tr.circular { background: #dc3545; color: white; }
</style>
"@

$html = $nestingReport |
    Sort-Object Depth -Descending |
    ConvertTo-Html -Title 'AD Group Nesting Audit' -Head $style -PreContent "<h2>AD Group Nesting Audit — $(Get-Date -Format 'yyyy-MM-dd')</h2>"

$outputPath = ".\ADGroupNestingAudit-$(Get-Date -Format yyyyMMdd).html"
$html | Out-File $outputPath -Encoding UTF8
Write-Host "Report saved to $outputPath"

Common Errors

  • Circular group membership causes infinite recursion — must track visited groups. Never use a simple foreach recursive call without a visited set. A circular reference will recurse until the call stack overflows with a StackOverflowException that cannot be caught. Use a HashSet<string> cloned per branch as shown above.
  • Get-ADGroupMember -Recursive flattens nesting and loses depth information. The built-in recursive mode is useful for enumerating all leaf members of a group, but it does not tell you how deep the chain is. Use the custom recursive function to preserve depth data for the audit report.

Related Cmdlets / See Also

Wrapping Up

A group nesting audit is a one-afternoon project that prevents years of access management pain. Run the recursive audit quarterly, export the HTML report for your security team, and set a policy that flags any nesting beyond depth two for review before it is committed. Fixing the deepest chains first yields the most immediate token-size improvement.

Send-Item -To