Skip to content

Instantly share code, notes, and snippets.

@indented-automation
Last active February 7, 2024 16:59
Show Gist options
  • Save indented-automation/f3b252e5e2689efe51a0f9627e0e3a0e to your computer and use it in GitHub Desktop.
Save indented-automation/f3b252e5e2689efe51a0f9627e0e3a0e to your computer and use it in GitHub Desktop.
Active Directory

A small collection specialised scripts for Active Directory.

Includes:

  • Compare-ADMemberOf
  • Get-ADSystemInfo
  • Get-GroupMemberTree
  • Get-LdapObject
  • Get-MemberOfTree
  • Test-LdapSslConnection
  • Get-ADSchemaAttribute
function Compare-ADMemberOf {
param (
# A filter which defines the set of objects to compare.
[Parameter(Mandatory)]
[string]$Filter,
# Sort alphatically by default, otherwise sort by most common group (then alphabetically)
[ValidateSet('Alphabetical', 'MostCommon')]
[string]$SortBy = 'Alphabetical'
)
# An arbitrary query to define a set of users to compare
$results = Get-ADUser -Filter $Filter | ForEach-Object {
$dn = $_.DistinguishedName
$entry = [PSCustomObject]@{
Name = $_.Name
}
# Get the list of groups this object belongs to.
Get-ADGroup -Filter { member -eq $dn } | ForEach-Object {
$entry | Add-Member $_.Name 'x' -Force
}
$entry
}
# Give everything the same list of Groups (as noteproperty members)
$allGroups = $results |
ForEach-Object { $_.PSObject.Properties | Where-Object Name -ne Name | ForEach-Object Name } |
Sort-Object |
Select-Object -Unique
$allGroups = switch ($SortBy) {
'Alphabetical' { $allGroups }
'MostCommon' {
$results |
ForEach-Object { $_.PSObject.Properties } |
Where-Object Name -ne Name |
Group-Object Name -NoElement |
Sort-Object @{Expression = 'Count'; Descending = $true}, Name |
Select-Object -ExpandProperty Name
}
}
$results | Select-Object (@('Name') + $allGroups)
}
function Get-ADSchemaAttribute {
[CmdletBinding()]
param (
[Parameter(Mandatory, Position = 1, ValueFromPipeline)]
[String]$Name
)
process {
$params = @{
Filter = 'objectClass -eq "classSchema" -and ldapDisplayName -eq $Name'
SearchBase = (Get-ADRootDSE).SchemaNamingContext
Properties = @(
'mayContain'
'systemMayContain'
'systemMustContain'
'systemAuxiliaryClass'
'auxiliaryClass'
'subClassOf'
)
}
$class = Get-ADObject @params
$class.mayContain
$class.systemMayContain
$class.systemMustContain
foreach ($attributeName in 'auxiliaryClass', 'systemAuxiliaryClass') {
if ($class.$attributeName.Count -gt 0) {
$class.$attributeName | Get-ADSchemaAttribute
}
}
if ($class.subClassOf -and $class.Name -ne $class.subClassOf) {
$class.subClassOf | Get-ADSchemaAttribute
}
}
}
function Get-ADSystemInfo {
<#
.SYNOPSIS
Rewrites properties and methods associated with the ComObject
.DESCRIPTION
Get-ADSystemInfo adds members to the ADSystemInfo COM Object improving the accessibility of each property and method (greatly simplifying syntax).
.EXAMPLE
Get-ADSystemInfo
.EXAMPLE
(Get-ADSystemInfo).GetAnyDCName()
.EXAMPLE
(Get-ADSystemInfo).GetDCSiteName("A-DC")
.EXAMPLE
Get-ADSystemInfo | Get-Member
#>
[CmdletBinding()]
param( )
# Dynamically enumerating these is really hard. Hard-coded.
$properties = 'ComputerName',
'DomainDnsName',
'DomainShortName',
'ForestDnsName',
'IsNativeMode',
'PDCRoleOwner',
'SchemaRoleOwner',
'SiteName',
'UserName'
# Methods excluding GetDCSiteName, requires a single hostname as an argument
$methodsWithoutArgs = 'GetAnyDCName',
'GetTrees',
'RefreshSchemaCache'
# Create ComObject as the basic return value
$adSystemInfo = New-Object -ComObject ADSystemInfo
# Add each of the known properties as a NoteProperty to the base object
foreach ($property in $properties) {
$adSystemInfo | Add-Member $property ([__ComObject].InvokeMember($property, 'GetProperty', $null, $adSystemInfo, $null))
}
# Create a ScriptMethod caller for each of the known methods and add each to the base object
foreach ($method in $methodsWithoutArgs) {
$adSystemInfo | Add-Member -Name $method -Type ScriptMethod -Value (
[ScriptBlock]::Create(
'[__ComObject].InvokeMember("{0}", "InvokeMethod", $null, $this, $null)' -f $method
)
)
}
# Add GetDCSiteName taking a single (first) argument via $args[0]
$adSystemInfo | Add-Member -Name GetDCSiteName -Type ScriptMethod -Value {
[__ComObject].InvokeMember('GetDCSiteName', 'InvokeMethod', $null, $this, $args[0])
}
# Return the updated object
$adSystemInfo
}
function Get-GroupMemberTree {
<#
.SYNOPSIS
Get members of a group and present output as a tree.
.DESCRIPTION
A recursive function which uses repeated ADSI searches to build a member tree.
#>
[CmdletBinding(DefaultParameterSetName = 'ManualSearchRoot')]
param (
# A DN or SamAccountName used to start the search.
[Parameter(Mandatory = $true, Position = 1)]
[String]$Identity,
# The root of the current domain by default. A fixed value can be supplied if required. Note that the search root is also used to locate the suer if a DN is not supplied.
[Parameter(ParameterSetName = 'ManualSearchRoot', Position = 2)]
[String]$SearchRoot = (([ADSI]'LDAP://RootDSE').defaultNamingContext[0]),
# Use a Global Catalog to search instead of LDAP (used for forest-wide searches).
[Alias('GlobalCatalog')]
[Switch]$GC,
# Sets the SearchRoot value to the forest root domain taken from RootDSE.
[Parameter(Mandatory = $true, ParameterSetName = 'AutomaticForestSearchRoot')]
[Switch]$UseForestRoot,
# The character to use to indent values.
[Parameter(Position = 4)]
[String]$IndentChar = ' ',
# The starting indent level (repetition of the IndentCharacter value).
[Parameter()]
[UInt32]$IndentLevel = 0,
[System.Collections.Generic.HashSet[String]]$loopPrevention = (New-Object System.Collections.Generic.HashSet[String])
)
if ((Get-PSCallStack)[1].InvocationInfo.InvocationName -ne $myinvocation.InvocationName) {
'{0}{1}' -f ($IndentChar * $IndentLevel), $Identity
$IndentLevel++
}
$protocol = 'LDAP'
# Switch the protocol if the GC switch parameter is used.
if ($GC) {
$protocol = 'GC'
}
if ($UseForestRoot) {
$SearchRoot = ([ADSI]'LDAP://RootDSE').rootDomainNamingContext[0]
}
$searcher = [ADSISearcher]('{0}://{1}' -f $Protocol, $SearchRoot)
$searcher.PageSize = 1000
$searcher.PropertiesToLoad.AddRange(@('name', 'distinguishedName', 'objectClass'))
# If the value passed as identity is not an object DN or a GUID treat the value as a sAMAccountName
# and execute a search using the SearchRoot and GC parameters.
$guid = [Guid]::NewGuid()
if ([Guid]::TryParse($Identity, [Ref]$guid)) {
$guidHex = $guid.ToByteArray() | ForEach-Object { $_.ToString('X2') }
$filter = '(objectGuid=\{0})' -f ($guidHex -join '\')
} elseif ($Identity -notmatch '^CN=.+(?:DC=w+){1,}') {
$filter = '(|(sAMAccountName={0})(userPrincipalName={0}))' -f $Identity
}
# Attempt to resolve the identity to a DN
if ($filter) {
try {
$searcher.Filter = $filter
$searchResult = $searcher.FindOne()
if ($searchResult) {
$Identity = $searchResult.Properties['distinguishedName'][0]
}
} catch {
$pscmdlet.ThrowTerminatingError($_)
}
}
try {
$searcher.Filter = '(memberOf={0})' -f $Identity
$searcher.FindAll() | ForEach-Object {
'{0}{1}' -f ($IndentChar * $IndentLevel), $_.Properties['name'][0]
if (@($_.Properties['objectClass'])[-1] -eq 'group') {
$psboundparameters.Identity = $_.Properties['distinguishedName'][0]
$psboundparameters.IndentLevel = $IndentLevel + 1
$psboundparameters.LoopPrevention = $loopPrevention
if ($loopPrevention.Contains($psboundparameters.Identity)) {
Write-Debug ('Triggered loop avoidance: {0}' -f $_.Properties['distinguishedName'][0])
} else {
$null = $loopPrevention.Add($psboundparameters.Identity)
Get-GroupMemberTree @psboundparameters
}
}
}
} catch {
throw
}
}
Add-Type -Assembly System.DirectoryServices.Protocols
function Get-LdapObject {
<#
.SYNOPSIS
An LDAP search function using System.DirectoryServices.Protocols.
.DESCRIPTION
Get-ADObject uses System.DirectoryServices.Protocols to execute searches against an LDAP directory.
Return values are, in most cases, raw and comparitively complex to work with. This function is written for speed and flexibility over ease of use.
Get-ADObject has only been tested against Active Directory.
.EXAMPLE
C:\PS> $RootDSE = Get-LdapObject -SearchScope Base @Params
C:\PS> $RootDSE.Attributes.AttributeNames | ForEach-Object {
>> Write-Host ""
>> Write-Host "Attribute Name: $_" -ForegroundColor Green
>> Write-Host ""
>> $Count = $RootDSE.Attributes[$_].Count
>> for ($i = 0; $i -lt $Count; $i++) {
>> $RootDSE.Attributes[$_].Item($_)
>> }
>>}
Returns the content of RootDSE.
.EXAMPLE
C:\PS> Get-LdapObject -LdapFilter "(sAMAccountName=cdent)" -SearchRoot "DC=indented,DC=co,DC=uk"
Returns the user with sAMAccountName cdent from the LDAP directory indented.co.uk.
.EXAMPLE
C:\PS> Get-LdapObject -LdapFilter "(&(objectClass=user)(objectCategory=person))" -SearchRoot "DC=domain,DC=com" -Server "ServerName" -UseSSL -Credential (Get-Credential)
Attempt to bind to the specified directory using SSL, then execute a query for user objects.
#>
[CmdletBinding()]
[OutputType([System.DirectoryServices.Protocols.SearchResultEntry[]])]
param (
# An optional server to use for this query. If server is not populated Get-ADObject uses serverless binding, passing off server selection to the site-aware DC locator process.
#
# Server is mandatory when executing a query against a remote domain.
[String]$Server,
# The LDAP port to query. If UseSSL is set, the port will be changed to the default secure port, 636, unless a value is explictly supplied for this parameter.
[UInt16]$Port = 389,
# Specifies a user account that has permittion to perform this action. The default is the current user.
#
# Get-Credential can be used to create a PSCredential object for this parameter.
[PSCredential]$Credential,
# An LDAP filter to use with the search. The filter (objectClass=*) is used by default.
[String]$LdapFilter = "(objectClass=*)",
# The search root must be specified as a distinguishedName. The default value (blank) will allow Get-LdapObject to return values from RootDSE with a SearchScope to set to Base (see Examples).
[String]$SearchRoot,
# The search scope is either Base, OneLevel or Subtree. Subtree is the default value.
[DirectoryServices.Protocols.SearchScope]$SearchScope = "Subtree",
# An optional array of LDAP property names to return in the search result.
[String[]]$Properties,
# By default, Get-LdapObject expects to use a plain text connection. SSL can be requested.
[Switch]$UseSSL,
# Ignore errors raised when attempting to validate the server certificate.
[Switch]$IgnoreCertificateError
)
if ($UseSSL -and -not $psboundparameters.ContainsKey('Port')) {
$Port = 636
}
$directoryIdentifier = New-Object DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, $Port)
if ($Credential) {
$networkCredential = $Credential.GetNetworkCredential()
$ldapConnection = New-Object DirectoryServices.Protocols.LdapConnection($directoryIdentifier, $NetworkCredential)
$ldapConnection.AuthType = [DirectoryServices.Protocols.AuthType]::Basic
} else {
$ldapConnection = New-Object DirectoryServices.Protocols.LdapConnection($directoryIdentifier)
$ldapConnection.AuthType = [DirectoryServices.Protocols.AuthType]::Kerberos
}
if ($UseSSL) {
$ldapConnection.SessionOptions.ProtocolVersion = 3
$ldapConnection.SessionOptions.SecureSocketLayer = $true
}
if ($IgnoreCertiticateError) {
$ldapConnection.SessionOptions.VerifyServerCertificate = {
param (
[DirectoryServices.Protocols.LdapConnection]$Connection,
[Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
)
$true
}
}
$searchRequest = New-Object DirectoryServices.Protocols.SearchRequest
$searchRequest.DistinguishedName = $SearchRoot
$searchRequest.Filter = $LdapFilter
$searchRequest.Scope = $SearchScope
if ($Properties) {
$searchRequest.Attributes.AddRange($Properties)
}
$pageRequest = New-Object DirectoryServices.Protocols.PageResultRequestControl(1000)
$searchRequest.Controls.Add($pageRequest) | Out-Null
$null = $searchRequest.Controls.Add((New-Object DirectoryServices.Protocols.SearchOptionsControl("DomainScope")))
try {
$ldapConnection.Bind()
while (!$complete) {
$searchResponse = $LdapConnection.SendRequest($searchRequest)
if ($searchResponse.Controls) {
$PageResponse = [DirectoryServices.Protocols.PageResultResponseControl]$searchResponse.Controls[0]
if ($PageResponse.Cookie.Length -gt 0) {
$searchRequest.Controls[0].Cookie = $PageResponse.Cookie
} else {
$complete = $true
}
} else {
$complete = $true
}
# Leave the result entries in the output pipeline
$searchResponse.Entries
}
} catch {
$pscmdlet.ThrowTerminatingError($_)
}
}
function Get-MemberOfTree {
<#
.SYNOPSIS
Get memberOf for an object and present output as a tree.
.DESCRIPTION
A recursive function which uses repeated ADSI searches to build a memberOf tree.
#>
[CmdletBinding(DefaultParameterSetName = 'ManualSearchRoot')]
param (
# A DN or SamAccountName used to start the search.
[Parameter(Mandatory = $true, Position = 1)]
[String]$Identity,
# The root of the current domain by default. A fixed value can be supplied if required. Note that the search root is also used to locate the suer if a DN is not supplied.
[Parameter(ParameterSetName = 'ManualSearchRoot', Position = 2)]
[String]$SearchRoot = (([ADSI]'LDAP://RootDSE').defaultNamingContext[0]),
# Use a Global Catalog to search instead of LDAP (used for forest-wide searches).
[Alias('GlobalCatalog')]
[Switch]$GC,
# Sets the SearchRoot value to the forest root domain taken from RootDSE.
[Parameter(Mandatory = $true, ParameterSetName = 'AutomaticForestSearchRoot')]
[Switch]$UseForestRoot,
# The character to use to indent values.
[Parameter(Position = 4)]
[String]$IndentChar = ' ',
# The starting indent level (repetition of the IndentCharacter value).
[Parameter()]
[UInt32]$IndentLevel = 0,
[System.Collections.Generic.HashSet[String]]$loopPrevention = (New-Object System.Collections.Generic.HashSet[String])
)
if ((Get-PSCallStack)[1].InvocationInfo.InvocationName -ne $myinvocation.InvocationName) {
'{0}{1}' -f ($IndentChar * $IndentLevel), $Identity
$IndentLevel++
}
$protocol = 'LDAP'
# Switch the protocol if the GC switch parameter is used.
if ($GC) {
$protocol = 'GC'
}
if ($UseForestRoot) {
$SearchRoot = ([ADSI]'LDAP://RootDSE').rootDomainNamingContext[0]
}
$searcher = [ADSISearcher]('{0}://{1}' -f $Protocol, $SearchRoot)
$searcher.PageSize = 1000
$searcher.PropertiesToLoad.AddRange(@('name', 'distinguishedName', 'objectClass'))
# If the value passed as identity is not an object DN or a GUID treat the value as a sAMAccountName
# and execute a search using the SearchRoot and GC parameters.
$guid = [Guid]::NewGuid()
if ([Guid]::TryParse($Identity, [Ref]$guid)) {
$guidHex = $guid.ToByteArray() | ForEach-Object { $_.ToString('X2') }
$filter = '(objectGuid=\{0})' -f ($guidHex -join '\')
} elseif ($Identity -notmatch '^CN=.+(?:DC=w+){1,}') {
$filter = '(|(sAMAccountName={0})(userPrincipalName={0}))' -f $Identity
}
# Attempt to resolve the identity to a DN
if ($filter) {
try {
$searcher.Filter = $filter
$searchResult = $searcher.FindOne()
if ($searchResult) {
$Identity = $searchResult.Properties['distinguishedName'][0]
}
} catch {
$pscmdlet.ThrowTerminatingError($_)
}
}
try {
$searcher.Filter = '(member={0})' -f $Identity
$searcher.FindAll() | ForEach-Object {
'{0}{1}' -f ($IndentChar * $IndentLevel), $_.Properties['name'][0]
if (@($_.Properties['objectClass'])[-1] -eq 'group') {
$psboundparameters.Identity = $_.Properties['distinguishedName'][0]
$psboundparameters.IndentLevel = $IndentLevel + 1
$psboundparameters.LoopPrevention = $loopPrevention
if ($loopPrevention.Contains($psboundparameters.Identity)) {
Write-Debug ('Triggered loop avoidance: {0}' -f $_.Properties['distinguishedName'][0])
} else {
$null = $loopPrevention.Add($psboundparameters.Identity)
Get-MemberOfTree @psboundparameters
}
}
}
} catch {
throw
}
}
function Test-LdapSslConnection {
<#
.SYNOPSIS
Test and LDAPS connection.
.DESCRIPTION
Test an LDAP connection returning information about the negotiated SSL connection including the server certificate.
The state message "The LDAP server is unavailable" indicates the server is either offline or unwilling to negotiate an SSL connection.
.INPUTS
System.String
.EXAMPLE
Test-LdapSSLConnection
Attempt to bind using SSL and serverless binding.
.EXAMPLE
Test-LdapSSLConnection -ComputerName servername
Attempt to negotiate SSL with "servername".
.NOTES
Change log:
31/03/2015 - Chris Dent - First release.
#>
[CmdletBinding()]
[OutputType('Indented.LDAP.ConnectionInformation')]
param (
# The name of a computer to test. By default serverless binding is used.
[Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
[Alias('DnsHostName')]
[String]$ComputerName = "",
# The port to connect to, by default the LDAPS port (636) is used.
[UInt16]$Port = 636,
# Credentials to use for the bind attempt. This command requires no special privileges.
[PSCredential]$Credential
)
process {
$directoryIdentifier = New-Object DirectoryServices.Protocols.LdapDirectoryIdentifier($ComputerName, $Port)
if ($psboundparameters.ContainsKey("Credential")) {
$connection = New-Object DirectoryServices.Protocols.LdapConnection($directoryIdentifier, $Credential.GetNetworkCredential())
$connection.AuthType = [DirectoryServices.Protocols.AuthType]::Basic
} else {
$connection = New-Object DirectoryServices.Protocols.LdapConnection($directoryIdentifier)
$connection.AuthType = [DirectoryServices.Protocols.AuthType]::Kerberos
}
$connection.SessionOptions.ProtocolVersion = 3
$connection.SessionOptions.SecureSocketLayer = $true
# Declare a script level variable which can be used to return information from the delegate.
New-Variable LdapCertificate -Scope Script -Force
# Create a callback delegate to retrieve the negotiated certificate.
# Note:
# * The certificate is unlikely to return the subject.
# * The delegate is documented as using the X509Certificate type, automatically casting this to X509Certificate2 allows access to more information.
$connection.SessionOptions.VerifyServerCertificate = {
param(
[DirectoryServices.Protocols.LdapConnection]$Connection,
[Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
)
$Script:LdapCertificate = $Certificate
return $true
}
$state = "Connected"
try {
$connection.Bind()
} catch {
$state = "Failed ($($_.Exception.InnerException.Message.Trim()))"
}
[PSCustomObject]@{
ComputerName = $ComputerName
Port = $Port
State = $state
Protocol = $connection.SessionOptions.SslInformation.Protocol
AlgorithmIdentifier = $connection.SessionOptions.SslInformation.AlgorithmIdentifier
CipherStrength = $connection.SessionOptions.SslInformation.CipherStrength
Hash = $connection.SessionOptions.SslInformation.Hash
HashStrength = $connection.SessionOptions.SslInformation.HashStrength
KeyExchangeAlgorithm = [Security.Authentication.ExchangeAlgorithmType][Int]$Connection.SessionOptions.SslInformation.KeyExchangeAlgorithm
ExchangeStrength = $connection.SessionOptions.SslInformation.ExchangeStrength
X509Certificate = $Script:LdapCertificate
PSTypeName = 'Indented.LDAP.ConnectionInformation'
}
}
}
@WintelRob
Copy link

Thanks for the great code examples!! I don't know how to properly use GitHub, so could I make a suggestion for the "Test-LdapSslConnection"? On my system, the "ExchangeAlgorithmType" cannot be mapped because, apparently, it doesn't exist in my computer. I'm not sure what the correct name mapping is, but I found one Microsoft (social forums) that mentioned my specific case, so I added this code, where I just typecast the value to a string and add the probable name in parens:

    # Code to handle when the system cannot interpret the newer code '44550'
    # to convert to the algorithm name. We just don't get the type name and
    # convert the number to a string to combine with the name we think it 
    # should be.
    If ([Int]$Connection.SessionOptions.SslInformation.KeyExchangeAlgorithm -eq 44550)
    {
        $KeyExchangeAlgo = [string]$Connection.SessionOptions.SslInformation.KeyExchangeAlgorithm + ' (ECDH_Ephem)'
    }
    Else
    {
        $KeyExchangeAlgo = [Security.Authentication.ExchangeAlgorithmType][Int]$Connection.SessionOptions.SslInformation.KeyExchangeAlgorithm
    }
    
    [PSCustomObject]@{
        ComputerName         = $ComputerName

@ninmonkey
Copy link

ninmonkey commented Dec 17, 2021

I think these are missing: (all links are yours)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment