📜 ⬆️ ⬇️

We build a report on the membership of users in AD groups: 4 problems in writing a Powershell script


Bill Stewart, scripting guru, in his article on WindowsITPro, describes the problems he had to face when writing a Powershell script that would deduce user membership in Active Directory groups. I had to make 4 improvements to make it work as it should. You can find out how Bill implemented the group membership output, and you can download the Powershell script itself under the cat.

Link to the final script.
www.windowsitpro.com/content/content/141463/141463.zip

I lost count, how many times have I met the question on the forums: “Does anyone know how to get information about all users and their membership in groups in the AD domain?”. Auditors and information security consultants also ask a similar question when they assess the Active Directory infrastructure (environment) in an organization. Since this question is quite urgent, I decided to write a PowerShell script that would simplify this task.
At first, I thought that writing a similar script was a couple of trifles, but on my way I encountered 4 obstacles that complicated my work. I will describe these issues a little later, but first I would like to talk about the basics of using Microsoft.NET in Powershell when searching by AD.

Using .NET to search by AD


')
Using .NET to search by AD, you can use the type accelerator in PowerShell to search for objects. (Type accelerator - abbreviated name for .NET class). For example, enter the following command to list all users in this domain:

PS C:\> $searcher = "(&(objectCategory=user)(objectClass=user))" PS C:\> $searcher.FindAll() 


[ADSISearcher] is a type accelerator for a .NET System.DirectoryServices.DirectorySearcher object. The string after this type accelerator sets the SearchFilter properties for this object to find all user objects, and the FindAll method starts the search. At the exit, we get a list of System.DirectoryServices.SearchResult objects.
Then we want to determine which group the user is in. To find out, we can use the Properties collection from the SearchResult object and extract an object attribute such as memberof. Using the $ searcher variable from the previous example, we can use the FindOne method (instead of FindAll ) to retrieve one result and display the user's membership in groups:

 PS C:\> $result = $searcher.FindOne() PS C:\> $result.Properties["memberof"] | sort-object 


The first command finds the first user that matches the search filter, and the second command lists the groups the user is in.
However, if you look closely at this list, you will notice the absence of an important detail: the user's primary group is not included in the memberof attribute. I would like to get a complete list of groups (including the main group), which leads to the first problem.

Problem # 1: How to find the user's main group



There is a workaround for excluding the main group from the memberof attribute. It is described in this article support.microsoft.com/kb/321360 We perform the following actions:
  1. Connect to the user's object using the WinNT provider (instead of the LDAP provider).
  2. Retrieve the user attribute primaryGroupID.
  3. Extract user group names using the WinNT provider, which includes the main group.
  4. Search these groups in AD using their sAMAccountName attributes.
  5. Find the group in which the primaryGroupToken attribute matches the user attribute primaryGroupID .

The problem with this “workaround” is that it requires the WinNT provider script to connect to the user object. That is, it is necessary that the script translate the distinguished name of the user (for example, CN = Ken Myer, OU = Marketing, DC = fabrikam, DC = com) to a format that the WinNT provider can use (for example, WinNT: // FABRIKAM / kenmyer, User).

Problem # 2: Translation from one name format to another



The NameTranslate object is a COM (ActiveX) object that uses the IADsNameTranslate interface, which translates the names of AD objects to alternate formats. You can use the NameTranslate object by creating an object and then calling its Init method to initialize. For example, list 1 shows the code of a VBScript script that the NameTranslate creates and initializes.

Listing 1: Creating and initializing a NameTranslate object in VBScript

 Const ADS_NAME_INITTYPE_GC = 3 Dim NameTranslate Set NameTranslate = CreateObject("NameTranslate") NameTranslate.Init ADS_NAME_INITTYPE_GC, vbNull 


However, the NameTranslate object does not work as expected in PowerShell, as shown in Figure 1.


Figure 1: Unexpected behavior of the NameTranslate object in PowerShell

The problem is that the NameTranslate object does not have a type library that .NET (and therefore PowerShell) uses to provide simple access to COM objects. But fortunately, this problem can be circumvented: the .NET InvokeMember method allows PowerShell to get or set properties or call a method from a COM object that is not in the type library. Listing 2 shows the Powershell equivalent of the VBScript script code shown in Table 1.

List 2: Creating and initializing a NameTranslate object in PowerShell

 $ADS_NAME_INITTYPE_GC = 3 $NameTranslate = new-object -comobject NameTranslate [Void] $NameTranslate.GetType().InvokeMember("Init", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_INITTYPE_GC, $NULL)) 


I wanted the script to solve another name problem. The memberof attribute for user AD contains a list of distinguished names in which the user is a member, but instead I wanted to get the samaccountname attribute for each group. The script uses the NameTranslate object to deal with this problem.

Problem # 3: What to do with special characters



Microsoft's documentation regarding distinguished names mentions that individual characters must be omitted (for example, with the prefix “\”) in order to be interpreted in the right way (more detail is written in this article ). Fortunately, the COM object Pathname gives this opportunity. The script uses the Pathname object to skip those distinguished names that contain special characters. The Pathname object also requires the .NET method InvokeMember because, like the NameTranslate object, this object does not have a type library.

Problem # 4: Increase Productivity



If you look back at Problem # 1 (How to find the main group of a user), you will notice that a workaround requires searching for user groups. Doing this procedure for multiple accounts, you will understand how it is not optimal. Extracting the samaccountname attribute for each group in the memberof attribute I mentioned when considering Problem # 2 (Translation from one name format to another) is also suboptimal and time consuming. To solve this problem, the script uses two global hash tables (global hash tables), which produce hashing results for improved performance.

Get-UsersAndGroups.ps1



Get-UsersAndGroups.ps1 is a ready-to-use Powershell script that displays a list of users and their group memberships. The script command line syntax is as follows:
 Get-UsersAndGroups [[-SearchLocation] <String[]>] [-SearchScope <String>] 


The -SearchLocation parameter represents one or more distinguished names for user accounts. Because the distinguished name contains commas (,), they must be placed in brackets (single or double) each distinguished name so that PowerShell does not interpret them as an array. The parameter name -SearchLocation is optional. The script also accepts pipeline input; each value from the pipeline must be a distinguished name to search for.
The -SearchScope value indicates the possible search scale for AD. This value should be one of the following: Base - Search is limited to the base object, not used; OneLevel - search for the nearest child objects of the base object and Subtree - search by color. If this value is not specified, then the default is Subtree. Use the -SearchScope OneLevel if you want a specific unit (OU), but none of the OU is nested. The script displays objects that contain the properties listed in Table 1.



Overcoming 4 problems



The script solves the above problems:


Simplify auditing groups and users


Writing the script Get-UsersAndGroups.ps1 was not so simple as it seemed to me at first glance, but it could not be done easier. The simplest script application is the following command:

 PS C:\> Get-UsersAndGroups | Export-CSV Report.csv -NoTypeInformation 


It creates a .csv file that contains a complete list of users and groups for this domain. The name in its arsenal is such a script, we can quickly and easily create a report for groups and users.

Once again we duplicate the link to the final script.
www.windowsitpro.com/content/content/141463/141463.zip

The script itself:

 # Get-UsersAndGroups.ps1 # Written by Bill Stewart (bstewart@iname.com) #requires -version 2 <# .SYNOPSIS Retreves users, and group membership for each user, from Active Directory. .DESCRIPTION Retreves users, and group membership for each user, from Active Directory. Note that each user's primary group is included in the output, and caching is used to improve performance. .PARAMETER SearchLocation Distinnguished name (DN) of where to begin searching for user accounts; eg "OU=Information Technology,DC=fabrikam,DC=com". If you omit this parameter, the default is the current domain (eg, "DC=fabrikam,DC=com"). .PARAMETER SearchScope Specifies the scope for the Active Directory search. Must be one of the following values: Base (Limit the search to the base object, not used), OneLevel (Searches the immediate child objects of the base object), or Subtree (Searches the whole subtree, including the base object and all its child objects). The default value is Subtree. To search only a location but not its children, specify OneLevel. .OUTPUTS PSObjects containing the following properties: DN The user's distinguished name CN The user's common name UserName The user's logon name Disabled True if the user is disabled; false otherwise Group The groups the user is a member of (one object per group) #> [CmdletBinding()] param( [parameter(Position=0,ValueFromPipeline=$TRUE)] [String[]] $SearchLocation="", [String][ValidateSet("Base","OneLevel","Subtree")] $SearchScope="Subtree" ) begin { $ADS_NAME_INITTYPE_GC = 3 $ADS_SETTYPE_DN = 4 $ADS_NAME_TYPE_1779 = 1 $ADS_NAME_TYPE_NT4 = 3 $ADS_UF_ACCOUNTDISABLE = 2 # Assume pipeline input if SearchLocation is unbound and doesn't exist. $PIPELINEINPUT = (-not $PSBOUNDPARAMETERS.ContainsKey("SearchLocation")) -and (-not $SearchLocation) # If -SearchLocation is a single-element array containing an emty string # (ie, -SearchLocation not specified and no pipeline), then populate with # distinguished name of current domain. In this case, input is not coming # from the pipeline. if (($SearchLocation.Count -eq 1) -and ($SearchLocation[0] -eq "")) { try { $SearchLocation[0] = ([ADSI] "").distinguishedname[0] } catch [System.Management.Automation.RuntimeException] { throw "Unable to retrieve the distinguished name for the current domain." } $PIPELINEINPUT = $FALSE } # These hash tables cache primary groups and group names for performance. $PrimaryGroups = @{} $Groups = @{} # Create and initialize a NameTranslate object. If it fails, throw an error. $NameTranslate = new-object -comobject "NameTranslate" try { [Void] $NameTranslate.GetType().InvokeMember("Init", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_INITTYPE_GC, $NULL)) } catch [System.Management.Automation.MethodInvocationException] { throw $_ } # Create a Pathname object. $Pathname = new-object -comobject "Pathname" # Returns the last two elements of the DN using the Pathname object. function get-rootname([String] $dn) { [Void] $Pathname.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $Pathname, ($dn, $ADS_SETTYPE_DN)) $numElements = $Pathname.GetType().InvokeMember("GetNumElements", "InvokeMethod", $NULL, $Pathname, $NULL) $rootName = "" ($numElements - 2)..($numElements - 1) | foreach-object { $element = $Pathname.GetType().InvokeMember("GetElement", "InvokeMethod", $NULL, $Pathname, $_) if ($rootName -eq "") { $rootName = $element } else { $rootName += ",$element" } } $rootName } # Returns an "escaped" copy of the specified DN using the Pathname object. function get-escaped([String] $dn) { [Void] $Pathname.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $Pathname, ($dn, $ADS_SETTYPE_DN)) $numElements = $Pathname.GetType().InvokeMember("GetNumElements", "InvokeMethod", $NULL, $Pathname, $NULL) $escapedDN = "" for ($n = 0; $n -lt $numElements; $n++) { $element = $Pathname.GetType().InvokeMember("GetElement", "InvokeMethod", $NULL, $Pathname, $n) $escapedElement = $Pathname.GetType().InvokeMember("GetEscapedElement", "InvokeMethod", $NULL, $Pathname, (0, $element)) if ($escapedDN -eq "") { $escapedDN = $escapedElement } else { $escapedDN += ",$escapedElement" } } $escapedDN } # Return the primary group name for a user. Algorithm taken from # http://support.microsoft.com/kb/321360 function get-primarygroupname([String] $dn) { # Pass DN of user to NameTranslate object. [Void] $NameTranslate.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_TYPE_1779, $dn)) # Get NT4-style name of user from NameTranslate object. $nt4Name = $NameTranslate.GetType().InvokeMember("Get", "InvokeMethod", $NULL, $NameTranslate, $ADS_NAME_TYPE_NT4) # Bind to user using ADSI's WinNT provider and get primary group ID. $user = [ADSI] "WinNT://$($nt4Name.Replace('\', '/')),User" $primaryGroupID = $user.primaryGroupID[0] # Retrieve user's groups (primary group is included using WinNT). $groupNames = $user.Groups() | foreach-object { $_.GetType().InvokeMember("Name", "GetProperty", $NULL, $_, $NULL) } # Query string is sAMAccountName attribute for each group. $queryFilter = "(|" $groupNames | foreach-object { $queryFilter += "(sAMAccountName=$($_))" } $queryFilter += ")" # Build a DirectorySearcher object. $searchRootDN = get-escaped (get-rootname $dn) $searcher = [ADSISearcher] $queryFilter $searcher.SearchRoot = [ADSI] "LDAP://$searchRootDN" $searcher.PageSize = 128 $searcher.SearchScope = "Subtree" [Void] $searcher.PropertiesToLoad.Add("samaccountname") [Void] $searcher.PropertiesToLoad.Add("primarygrouptoken") # Find the group whose primaryGroupToken attribute matches user's # primaryGroupID attribute. foreach ($searchResult in $searcher.FindAll()) { $properties = $searchResult.Properties if ($properties["primarygrouptoken"][0] -eq $primaryGroupID) { $groupName = $properties["samaccountname"][0] return $groupName } } } # Return a DN's sAMAccount name based on the distinguished name. function get-samaccountname([String] $dn) { # Pass DN of group to NameTranslate object. [Void] $NameTranslate.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_TYPE_1779, $dn)) # Return the NT4-style name of the group without the domain name. $nt4Name = $NameTranslate.GetType().InvokeMember("Get", "InvokeMethod", $NULL, $NameTranslate, $ADS_NAME_TYPE_NT4) $nt4Name.Substring($nt4Name.IndexOf("\") + 1) } function get-usersandgroups2($location) { # Finds user objects. $searcher = [ADSISearcher] "(&(objectCategory=User)(objectClass=User))" $searcher.SearchRoot = [ADSI] "LDAP://$(get-escaped $location)" # Setting the PageSize property prevents limiting of search results. $searcher.PageSize = 128 $searcher.SearchScope = $SearchScope # Specify which attributes to retrieve ([Void] prevents output). [Void] $searcher.PropertiesToLoad.Add("distinguishedname") [Void] $searcher.PropertiesToLoad.Add("cn") [Void] $searcher.PropertiesToLoad.Add("samaccountname") [Void] $searcher.PropertiesToLoad.Add("useraccountcontrol") [Void] $searcher.PropertiesToLoad.Add("primarygroupid") [Void] $searcher.PropertiesToLoad.Add("memberof") # Sort results by CN attribute. $searcher.Sort = new-object System.DirectoryServices.SortOption $searcher.Sort.PropertyName = "cn" foreach ($searchResult in $searcher.FindAll()) { $properties = $searchResult.Properties $dn = $properties["distinguishedname"][0] write-progress "Get-UsersAndGroups" "Searching $location" -currentoperation $dn $cn = $properties["cn"][0] $userName = $properties["samaccountname"][0] $disabled = ($properties["useraccountcontrol"][0] -band $ADS_UF_ACCOUNTDISABLE) -ne 0 # Create an ArrayList containing user's group memberships. $memberOf = new-object System.Collections.ArrayList $primaryGroupID = $properties["primarygroupid"][0] # If primary group is already cached, add the name to the array; # otherwise, find out the primary group name and cache it. if ($PrimaryGroups.ContainsKey($primaryGroupID)) { [Void] $memberOf.Add($PrimaryGroups[$primaryGroupID]) } else { $primaryGroupName = get-primarygroupname $dn $PrimaryGroups.Add($primaryGroupID, $primaryGroupName) [Void] $memberOf.Add($primaryGroupName) } # If the user's memberOf attribute is defined, find the group names. if ($properties["memberof"]) { foreach ($groupDN in $properties["memberof"]) { # If the group name is aleady cached, add it to the array; # otherwise, find out the group name and cache it. if ($Groups.ContainsKey($groupDN)) { [Void] $memberOf.Add($Groups[$groupDN]) } else { $groupName = get-samaccountname $groupDN $Groups.Add($groupDN, $groupName) [Void] $memberOf.Add($groupName) } } } # Sort the ArrayList and output one object per group. $memberOf.Sort() foreach ($groupName in $memberOf) { $output = new-object PSObject $output | add-member NoteProperty "DN" $dn $output | add-member NoteProperty "CN" $cn $output | add-member NoteProperty "UserName" $userName $output | add-member NoteProperty "Disabled" $disabled $output | add-member NoteProperty "Group" $groupName $output } } } } process { if ($PIPELINEINPUT) { get-usersandgroups2 $_ } else { $SearchLocation | foreach-object { get-usersandgroups2 $_ } } } 


via WindowsITPro

PS For a variety of reports on the structure and changes of AD, you can use the NetWrix AD Change Reporter . The program allows you to be aware of changes in AD and at the same time does not require you to tediously work with logs or manual automation through scripts. You can learn more about the program on the NetWrix website.

Source: https://habr.com/ru/post/148472/


All Articles