With groups containing more than 1,500 members, performing the following will not provide the entire population.
$groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://CN=All Employees,OU=Distribution Lists,DC=ad,DC=mydomain,DC=local") foreach($member in $groupObject.member) { Write-Output $member }You will only enumerate the first 1,500 distinguished names of the multivalued attribute 'member' and output them to the console. Big enough to convince you that you retrieved all the members on first glance but on further investigation you will realize you are missing some people. Hopefully, you caught that and not the person that requested the report with the resolved distinguished names. To overcome this limitation, you need to search within the group object itself and return a range of values within the 'member' attribute; looping until you have enumerated each and every distinguished name.
Active Directory groups can contain user objects, computer objects, contact objects and other groups. To obtain the full membership of a Security Group to understand who has rights to an asset or a Distribution List to understand who will receive an e-mail, you must be aware of nested groups. If a group is a member is a member of the initial group, you will need to enumerate that group and any other groups that it may contain and so on. One of the issues that you can encounter with nested groups is the Administrator that nested a group that is member of a group that itself is patriline to that group. This leads to looping in reporting and must be accounted when recursively enumerating the membership. I tackle this problem by storing the nested group distinguished names that were enumerated in an array and at each recursion evaluating if that distinguished name is an element.
The code sample below deals with these two issues related to group enumeration. I have tested this against code against a group that contained 32,000+ member objects with multiple layers of nesting genreating a 2.9 megabyte report. The only big deficit in the code is there is no provision to deal with objects that are of the foreignSecurityPrincipal class. If you have cross-forest membership in groups, you can add that to the if-then statement and add your own formatting function for those objects. A nice feature of this script is that if you need to enumerate a large number of groups that have similar sAMAccountNames you can wild card them in the command line argument, such as "-group *AdSales*" or "-group DL_*". The code in the script is fairly portable. You can recombine these functions to enumerate the groups associated to NTFS security to provide an access report. You just need to update the functions that deal with the formatting of the objects to provide the desired information.
param([string]$domain,[string]$group,[switch]$verbose) #-------------------------------------------------------------------------------------------------# Function Find-GroupDistinguishedNamesBysAMAccountName($domain,$sAMAccountName) { $groupDistinguishedNames = @() $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher $directorySearcher.SearchRoot = (New-Object System.DirectoryServices.DirectoryEntry(("LDAP://" + (Get-LocalDomainController $domain) + "/" + (Get-DomainDn $domain)))) $directorySearcher.Filter = "(&(objectClass=group)(objectCategory=group)(sAMAccountName=$sAMAccountName))" $directorySearcher.PropertiesToLoad.Clear() $directorySearcher.PropertiesToLoad.Add("distinguishedName") | Out-Null $searchResults = $directorySearcher.FindAll() if($searchResults -ne $null) { foreach($searchResult in $searchResults) { if($searchResult.Properties.Contains("distinguishedName")) { $groupDistinguishedNames += $searchResult.Properties.Item("distinguishedName")[0] } } } else { $groupDistinguishedNames = $null } $directorySearcher.Dispose() return $groupDistinguishedNames } #-------------------------------------------------------------------------------------------------# Function Get-GroupType($groupType) { if($groupType -eq -2147483646) { $groupType = "Global Security Group" } elseif($groupType -eq -2147483644) { $groupType = "Domain Local Security Group" } elseif($groupType -eq -2147483643) { $groupType = "BuiltIn Group" } elseif($groupType -eq -2147483640) { $groupType = "Universal Security Group" } elseif($groupType -eq 2) { $groupType = "Global Distribution List" } elseif($groupType -eq 4) { $groupType = "Local Distribution List" } elseif($groupType -eq 8) { $groupType = "Universal Distribution List" } else { $groupType = "Unknown Group Type" } return $groupType } #-------------------------------------------------------------------------------------------------# Function Get-GroupMembers($distinguishedName,$level,$groupsReported) { $reportText = @() $groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://" + ($distinguishedName -replace "/","\/")) if($groupObject.groupType[0] -ne -2147483640 -or $groupObject.groupType[0] -ne 8) { $groupObject = New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController(Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/")) } $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher($groupObject) $lastQuery = $false $quitLoop = $false if($groupObject.member.Count -ge 1000) { $rangeStep = 1000 } elseif($groupObject.member.Count -eq 0) { $lastQuery = $true $quitLoop = $true } else { $rangeStep = $groupObject.member.Count } $rangeLow = 0 $rangeHigh = $rangeLow + ($rangeStep - 1) $level = $level + 2 while(!$quitLoop) { if(!$lastQuery) { $attributeWithRange = "member;range=$rangeLow-$rangeHigh" } else { $attributeWithRange = "member;range=$rangeLow-*" } $directorySearcher.PropertiesToLoad.Clear() $directorySearcher.PropertiesToLoad.Add($attributeWithRange) | Out-Null $searchResult = $directorySearcher.FindOne() $directorySearcher.Dispose() if($searchResult.Properties.Contains($attributeWithRange)) { foreach($member in $searchResult.Properties.Item($attributeWithRange)) { $memberObject = Get-ActiveDirectoryObject $member if($memberObject.objectClass -eq "group") { $reportText += Format-Group $memberObject $level $groupsReported } elseif ($memberObject.objectClass -eq "contact") { $reportText += Format-Contact $memberObject $level } elseif ($memberObject.objectClass -eq "computer") { $reportText += Format-Computer $memberObject $level } elseif ($memberObject.objectClass -eq "user") { $reportText += Format-User $memberObject $level } else { Write-Warning "NOT SUPPORTED: $member" } } if($lastQuery) { $quitLoop = $true } } else { $lastQuery = $true } if(!$lastQuery) { $rangeLow = $rangeHigh + 1 $rangeHigh = $rangeLow + ($rangeStep - 1) } } return $reportText } #-------------------------------------------------------------------------------------------------# Function Format-User($userObject,$level) { $reportText = @() if($userObject.displayName) { $identity = ((" " * $level) + $userObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]") } else { $identity = ((" " * $level) + $userObject.name.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]") } if($userObject.mail) { $identity = ("$identity <" + $userObject.mail.ToString() + ">") } if($userObject.userAccountControl[0] -band 0x0002) { $identity = "$identity (User Disabled)" } else { $identity = "$identity (User Enabled)" } $reportText += $identity $description = ((" " * $level) + " Description: " + $userObject.description.ToString()) $reportText += $description if($verbose) { Write-Host ($reportText | Out-String) } return $reportText } Function Format-Contact($contactObject,$level) { $reportText = @() $identity = ((" " * $level) + $contactObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $contactObject.distinguishedName) + "] <" + $contactObject.mail.ToString() + "> (Contact)") $description = ((" " * $level) + " Description: " + $contactObject.description.ToString()) $reportText += $identity $reportText += $description if($verbose) { Write-Host ($reportText | Out-String) } return $reportText } Function Format-Computer($computerObject,$level) { $reportText = @() $identity = ((" " * $level) + $computerObject.name + " [" + (Get-ObjectNetBiosDomain $computerObject.distinguishedName) + "\" + $computerObject.sAMAccountName.ToString() + "] (Computer)") $operatingSystem = ((" " * $level) + " OS: " + $computerObject.operatingSystem.ToString()) $reportText += $identity if($computerObject.dNSHostName) { $fqdn = ((" " * $level) + " FQDN: " + ($computerObject.dNSHostName.ToString()).ToLower()) $reportText += $fqdn $reportText += $operatingSystem } else { $reportText += $operatingSystem } if($verbose) { Write-Host ($reportText | Out-String) } return $reportText } Function Format-Group($groupObject,$level,$groupsReported) { $reportText = @() $identity = ((" " * $level) + $groupObject.name.ToString() + " [" + (Get-ObjectNetBIOSDomain $groupObject.distinguishedName) + "\" + $groupObject.sAMAccountName.ToString() + "]") if($groupObject.mail) { $identity = ("$identity <" + $groupObject.mail.ToString() + ">") } $identity = ("$identity (" + (Get-GroupType $groupObject.groupType) + ")") $reportText += $identity if($verbose) { Write-Host ($reportText | Out-String) } if($groupsReported -notcontains $groupObject.distinguishedName) { $groupsReported += $groupObject.distinguishedName $reportText += Get-GroupMembers $groupObject.distinguishedName.ToString() $level $groupsReported } else { $reportText += ((" " * $level) + " [previously reported nested group]") } $reportText += "" return $reportText } #-------------------------------------------------------------------------------------------------# Function Get-AllForestDomains { $domains = @() $forestInfo = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest() foreach($domain in $forestInfo.Domains) { $domains += $domain.name } return $domains } Function Get-DomainDn($domain) { return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).defaultNamingContext).ToString() } Function Get-ObjectAdDomain($distinguishedName) { return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".") } Function Get-ObjectNetBiosDomain($distinguishedName) { return ((Get-ObjectAdDomain $distinguishedName).Split(".")[0]).ToUpper() } Function Get-LocalDomainController($domain) { return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).dnsHostName).ToString() } Function Get-ActiveDirectoryObject($distinguishedName) { return New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController (Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/")) } #-------------------------------------------------------------------------------------------------# Set-Variable -name reportsDirectory -option Constant -value "$pwd\Reports" Set-Variable -name forestDomains -option Constant -value @(Get-AllForestDomains) #-------------------------------------------------------------------------------------------------# if(!([bool]$domain) -or !([bool]$group)) { Write-Host (" Example: .\Export-GroupMembership.ps1 -domain " + $forestDomains[0] + " -group Administrators -verbose") -foregroundcolor Yellow Write-Host "" } if(!([bool]$domain)) { Write-Host " You are missing the `"-domain`" Switch" -Foregroundcolor Red Write-Host "" Write-Host " The domain of the group." Write-Host " Valid Domains:" foreach($forestDomain in $forestDomains) { Write-Host (" $forestDomain [" + ($forestDomain.Split("."))[0] + "]") } Write-Host "" Write-Host " Please enter the Domain below" while(!([bool]$domain)) { $domain = Read-Host -prompt "`tDomain" } Write-Host "" } if(!([bool]$group)) { Write-Host " You are missing the `"-group`" Switch" -Foregroundcolor Red Write-Host "" Write-Host " Please enter the group name below" while(!([bool]$group)) { $group = Read-Host -prompt "`tGroup" } Write-Host "" } $validDomain = $false foreach($forestDomain in $forestDomains) { if($forestDomain -eq $domain) { $validDomain = $true break } if((($forestDomain.Split("."))[0]) -eq $domain) { $validDomain = $true break } } if($validDomain -eq $false) { Write-Host "" Write-Host "$domain is not a valid domain in your current forest." -foregroundcolor Red Write-Host "" exit } if(!(Test-Path -path $reportsDirectory)) { New-Item -path $reportsDirectory -type Directory | Out-Null } $groupDistinguishedNames = Find-GroupDistinguishedNamesBysAMAccountName $domain $group if($groupDistinguishedNames -eq $null) { Write-Host "Unable to locate $domain\$group. Exiting..." -foregroundColor Red exit } foreach($groupDistinguishedName in $groupDistinguishedNames) { $groupObject = Get-ActiveDirectoryObject $groupDistinguishedName $groupName = $groupObject.name.ToString() $groupsAMAccountName = $groupObject.sAMAccountName.ToString() $groupDomain = Get-ObjectAdDomain $groupDistinguishedName $groupNetBiosDomain = Get-ObjectNetBiosDomain $groupDistinguishedName $groupType = Get-GroupType $groupObject.groupType $outputFilename = ($groupDomain + "-" + $groupsAMAccountName + ".txt") $reportText = @() $reportText += ("#" + ("-" * 78) + "#") $reportText += " Members of $groupName [$groupNetBiosDomain\$groupsAMAccountName]" $reportText += (" Description: " + $groupObject.description.ToString()) if($groupObject.mail -and $groupType -match "Distribution") { $reportText += " Distribution List E-Mail Address: " + $groupObject.mail.ToString() } elseif($groupObject.mail) { $reportText += " Security Group E-Mail Address: " + $groupObject.mail.ToString() } $reportText += " Group Type: $GroupType" $reportText += (" Report generated on " + (Get-Date -format D) + " at " + (Get-Date -format T)) $reportText += ("#" + ("-" * 78) + "#") if($verbose) { Write-Host ($reportText | Out-String) } $reportText += Get-GroupMembers $groupDistinguishedName 0 @() $reportText += ("#" + ("-" * 78) + "#") if($verbose) { Write-Host ("#" + ("-" * 78) + "#") } Set-Content -path "$reportsDirectory\$outputFilename" -value ($reportText | Out-String) }
Hiya,
ReplyDeleteFab post - very useful.
One query however. How can you included nested Dynamic AD Groups in the above? I've added the below lines but it's not seemingly exporting anything from these particular DL's:
} elseif ($memberObject.objectClass -eq "msExchDynamicDistributionList") {
$reportText += Format-Group $memberObject $level $groupsReported
Cheers
Steve
Thanks! Dynamic Distribution Lists do not contain members in a member attribute. Essentially, they are LDAP queries that are performed at the time of expansion by Exchange to provide real-time (dynamic) membership. So in order to provide the membership of an object of the class "msExchDynamicDistributionList", you need to execute the LDAP query using the filter storied in the msExchDynamicDLFilter attribute of the object. My code can be updated to satisfy this request. You need to perform the query (global catalog) and then pass the distinguished name of each member to appropriate formatting function. If I have some spare cycles, I will update the script as it is an interesting challenge and I have been negligent in updating this blog.
ReplyDelete