Logo

TL;DR:

Script en PowerShell pour optimiser la récupération dans une variable en mémoire d'un grand nombre d'objets contenus dans un annuaire AD.

Concepts & outils:

PowerShell, Active Directory, LDAP

Sommaire

Introduction

Méthode

Script

Script de collecte complet

Problématique

En PowerShell, on veut récupérer dans des variables en RAM (hash tables) tous les objets user, group et computer d'un domaine AD en un minimum de requêtes LDAP et sans faire fumer un DC plus que nécessaire.

On sait déjà faire naïvement:

Get-ADUser -filter * -properties *

Mais sur un AD d'un million d'objets, ... c'est long (voire ça ne peut pas se terminer correctement).

On présente ici une solution consistant à découper cela en plusieurs requêtes LDAP afin de charger en mémoire toutes les données utiles en un minimum de temps.

Méthode

En PowerShell:

D'abord, on spécifie les attributs à récupérer, même s'il y en a beaucoup, plutôt que de faire bêtement "*". Par exemple

$Properties  = @("MemberOf", "Manager", "PasswordLastSet", "pwdLastSet", "PasswordNeverExpires", "PasswordNotRequired", "c", "CN", "co", "Company", "Country", "DisplayName", "EmailAddress", "EmployeeID", "GivenName", "mail", "Name", "sn", "Surname", "UserPrincipalName", "ServicePrincipalNames", "DoesNotRequirePreAuth", "PrimaryGroupID", "PasswordExpired")

Récupérer dans une variable la liste de tous les objets:

$users       = Get-ADObject -Filter {ObjectClass -eq "user" -and ObjectClass -ne "computer"} -Properties DistinguishedName -Server $DOMAIN
$nbusers     = $users.count
Note:

Un Get-ADObject est beaucoup plus rapide qu'un Get-ADUser car cette dernière commande est une généralisation de Get-ADObject qui effectue davantage de travail. Get-ADObject effectue moins de conversion d'attributs, crée des objets .NET plus simples, expose moins de propriétés calculées, et récupère souvent moins d'attributs LDAP.

Extraire la liste des premiers caractères pour tous les objets:

  $chars     = [char[]]::new($users.Count)
  $total     = $users.Count
  $i         = 0
  foreach ($item in $users) {
    $dn      = $item.DistinguishedName
    $s       = $dn.Substring(3)  # enlève "CN="
    if ($s.Length -gt 0) {
      $chars[$i] = [char]::ToLowerInvariant($s[0])
    } else {
      $chars[$i] = [char]0
    }
    $i++
  }
  $uchars    = $chars | sort -unique
  $nbchars   = $uchars.count

$uchars contient la liste des premiers caractères.

On peut maintenant scinder la collecte globale en autant de requêtes que de premiers caractères différents existants, en faisant une boucle:

  $users     = @{}
  $iteration = 0
  foreach ($char in $uchars) {
    $percent = [int]((++$iteration) * 100 / $nbchars)
    Write-Progress -Activity "Building final users hashtable" -PercentComplete $percent -Status "$percent %"
    Get-ADUser -Server $DOMAIN -Filter "SamAccountName -like '$char*'" -Properties $Properties | ForEach-Object { $users[$_.DistinguishedName] = $_ }
  }
Note:

Bien sûr si tous les comptes commencent par le même caractère, ça ne marche plus, il faut changer le critère de séparation des objets, mais le principe reste le même.

Utilisation, une fois les objets chargés en mémoire:

Compter les objets

$users.count

Afficher les clés

$users.keys

Construire un index des clés

$index = @()
$users.Keys | ForEach-Object { $index += $_ }

Lister les clés

$index

Interroger un objet

$users["CN=user1234,OU=PARIS,OU=EMEA,OU=CONTOSO,DC=LAB,DC=LOCAL"]

Interroger une properties d'un objet

($users["CN=user1234,OU=PARIS,OU=EMEA,OU=CONTOSO,DC=LAB,DC=LOCAL"]).samaccountname

Script

Voici un exemple complet de script pouvant récupérer tous les users dans un domaine AD en un temps raisonnable.

$DOMAIN      = "lab.local"


## Attributs à récupérer
$Properties  = @("MemberOf", "Manager", "PasswordLastSet", "pwdLastSet", "PasswordNeverExpires", "PasswordNotRequired", "c", "CN", "co", "Company", "Country", "DisplayName", "EmailAddress", "EmployeeID", "GivenName", "mail", "Name", "sn", "Surname", "UserPrincipalName", "ServicePrincipalNames", "DoesNotRequirePreAuth", "PrimaryGroupID", "PasswordExpired")


## 1/ Récupération de la liste des users via Get-ADObject
Write-Host "> Getting all user objects from $DOMAIN..."
$ts = Measure-Command {
  $users     = Get-ADObject -Filter {ObjectClass -eq "user" -and ObjectClass -ne "computer"} -Properties DistinguishedName -Server $DOMAIN
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
$nbusers     = $users.count
Write-Host "> $DOMAIN contains $nbusers users."


## 2/ Extraction de la liste des 1ers caractères
Write-Host "> Sorting objects by first character..."
$ts = Measure-Command {
  $chars     = [char[]]::new($users.Count)
  $total     = $users.Count
  $i         = 0
  foreach ($item in $users) {
    $dn      = $item.DistinguishedName
    $s       = $dn.Substring(3)  # enlève "CN="
    if ($s.Length -gt 0) {
      $chars[$i] = [char]::ToLowerInvariant($s[0])
    } else {
      $chars[$i] = [char]0
    }
    $i++
  }
  $uchars    = $chars | sort -unique
  $nbchars   = $uchars.count
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
Write-Host "> Dividing query in $nbchars chunks."


## 3/ Récupération de la liste des users via Get-ADUser
$ts = Measure-Command {
  Write-Host "> Gathering data..."
  $users     = @{}
  $iteration = 0
  foreach ($char in $uchars) {
    $percent = [int]((++$iteration) * 100 / $nbchars)
    Write-Progress -Activity "Building final users hashtable" -PercentComplete $percent -Status "$percent %"
    Get-ADUser -Server $DOMAIN -Filter "SamAccountName -like '$char*'" -Properties $Properties | ForEach-Object { $users[$_.DistinguishedName] = $_ }
  }
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts

Script de collecte complet

Voici un exemple concret de script capable de chercher tous les domaines AD connus et de "dumper" leur contenu. On se base sur le principe exposé précédemment et on récupère:

<###############################################################################
  Recup-All.ps1
   - récupération de la liste de tous les domaines connus et trustés du domaine
     courant
   - test de tous les domaines de la liste pour voir s'ils sont requêtables
   - pour chaque domaine joignable et requêtable, récupération de tous les
     users, groups, computers
   - stockage de toutes les infos récupérées dans des fichiers
###############################################################################>

## Définir les types d'objets à extraire
$ObjectTypes            = @("user", "group", "computer", "organizationalUnit", "contact")

## What to do
$GET_user               = $True
$GET_group              = $True
$GET_computer           = $True
$GET_organizationalUnit = $True
$GET_contact            = $True

## Nb. max d'objets pour des requêtes AD simples
$LIMIT_SINGLEQUERY      = 5000

## Attributs à récupérer pour chaque type d'objet
$Properties_user        = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "Enabled", "co", "Company", "Country", "DisplayName", "DoesNotRequirePreAuth", "EmailAddress", "EmployeeID", "GivenName", "mail", "Manager", "MemberOf", "PasswordExpired", "PasswordLastSet", "PasswordNeverExpires", "PasswordNotRequired", "PrimaryGroupID", "pwdLastSet", "ServicePrincipalNames", "Surname", "UserPrincipalName")
$Properties_group       = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "GroupCategory", "GroupScope", "ManagedBy", "MemberOf", "Members")
$Properties_computer    = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "Enabled", "DNSHostName", "OperatingSystem", "OperatingSystemVersion", "PasswordExpired")
$Properties_contact     = @("ObjectGUID", "CN", "CanonicalName", "DistinguishedName", "Name")
$Properties_organizationalUnit = @("ObjectGUID", "CN", "CanonicalName", "DistinguishedName", "Name", "ProtectedFromAccidentalDeletion")



<###############################################################################
  Liste des Domaines
###############################################################################>

## Fonction de récupération des domaines connus de l'infra AD
function Get-AllADDomains {
  param(
    [string]$RootDomain = (Get-ADDomain).DNSRoot,
    [hashtable]$Seen = @{}
  )
  if ($Seen.ContainsKey($RootDomain)) { return }
  $Seen[$RootDomain] = $true
  try {
    Write-Host "[Discovery] Browsing $RootDomain ..."
    $trusts = Get-ADTrust -Server $RootDomain -Filter * -ErrorAction Stop
    foreach ($t in $trusts) {
      $target = $t.Target
      if (-not $Seen.ContainsKey($target)) {
        Get-AllADDomains -RootDomain $target -Seen $Seen
      }
    }
  } catch {
    Write-Host "(Break)"
  }
  return $Seen
}

## Récupération des domaines via la fonction Get-AllADDomains
Write-Host ""
Write-Host "Getting all discoverable AD domains."
$domains = Get-AllADDomains
$domainslist = $domains.Keys | sort -Unique

Write-Host ""
Write-Host "Raw AD domains list:"
$domainslist

## Validation des domaines / suppression des domaines non joignables
Write-Host ""
Write-Host "Now validating queryable domains."
$ok = @()
$timeout = 30
foreach ($domain in $domainslist) {
  Write-Host "Check: $domain ..."
  $job = Start-Job -ScriptBlock {
    param($domain)
    try {
      Get-ADUser -Server $domain -Filter * -ResultSetSize 1 -ErrorAction Stop | Out-Null
      return $true
    } catch {
      return $false
    }
  } -ArgumentList $domain

  if (Wait-Job $job -Timeout $timeout) {
    $result = Receive-Job $job
    if ($result) { $ok += $domain }
  }
  Remove-Job $job -Force
}
$domains = $ok

Write-Host ""
Write-Host "Final AD domains list:"
$domains

## Save domains list
$domains | Out-File .\domains.txt



<###############################################################################
  Fonction pour supprimer les caractères accentués de $uchars
###############################################################################>

function Remove-AccentDuplicates {
  param(
    [Parameter(Mandatory)]
    [string[]]$Chars
  )

  $normalized = @{}

  foreach ($c in $Chars) {
    $decomposed = $c.Normalize([System.Text.NormalizationForm]::FormD)
    $plain = -join ($decomposed.ToCharArray() | Where-Object {
      [Globalization.CharUnicodeInfo]::GetUnicodeCategory($_) -ne 'NonSpacingMark'
    })
    $normalized[$plain] += ,$c
  }

  $result = @()
  foreach ($pair in $normalized.GetEnumerator()) {
    if ($pair.Value -contains $pair.Key) {
      $result += $pair.Key
    } else {
      $result += $pair.Value
    }
  }

  return ($result | Sort-Object)
}



<###############################################################################
  Récup. Users/Groups/Computers
###############################################################################>

## Main job
foreach ($ObjectType in $ObjectTypes) {

  $cmd        = "Get-AD$($ObjectType.Substring(0,1).ToUpper() + $ObjectType.Substring(1))"
  if ($ObjectType -eq "organizationalUnit" -or $ObjectType -eq "contact") {
    $cmd      = "Get-ADObject"
  }
  $Properties = Get-Variable -Name "Properties_$ObjectType" -ValueOnly
  $Proceed    = Get-Variable -Name "GET_$ObjectType" -ValueOnly
  
  if ($Proceed) {
    foreach ($DOMAIN in $domains) {

      ## Build filter for Get-AD* commands
      $exclude = $ObjectTypes | Where-Object { $_ -ne $ObjectType }
      $filterParts = @()
      $filterParts += "(ObjectClass -eq '$ObjectType')"
      foreach ($ex in $exclude) {
        $filterParts += "(ObjectClass -ne '$ex')"
      }
      $filter = $filterParts -join ' -and '


      ## Prepare CSV export
      $csvPath = ".\data\${DOMAIN}-${ObjectType}s.csv"
      if (Test-Path $csvPath) { Remove-Item $csvPath }


      ## Comptage du nombre d'objets
      Write-Host "> Getting all $ObjectType objects from $DOMAIN..."
      $ts = Measure-Command {
        $objects   = Get-ADObject -Filter $filter -Properties DistinguishedName -Server $DOMAIN
      }
      Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
      $nbobjects     = $objects.count
      Write-Host "> $DOMAIN contains $nbobjects ${ObjectType}s."


      ## Action si moins de 5k objets => récupération directe
      if ($nbobjects -lt $LIMIT_SINGLEQUERY) {
        Write-Host "> Gathering data in single query..."

        $ts = Measure-Command {
          & $cmd -Server $DOMAIN -Filter * -Properties $Properties |
            ForEach-Object { $props = [ordered]@{}; foreach ($p in $Properties) { $v = $_.$p; if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) { $props[$p] = $v -join ";"; } else { $props[$p] = $v; } } [PSCustomObject]$props } |
            Select-Object $Properties |
            Export-Csv -Path $csvPath -Delimiter ';' -NoTypeInformation -Encoding UTF8 -Force
        }
        Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts


      ## Action si 5k objets ou plus => récupération par pages
      } else {
        Write-Host "> Gathering data in multiple queries by pages"

        ## a. Extraction de la liste des 1ers caractères
        Write-Host "> Sorting objects by first character..."
        $ts = Measure-Command {
          $chars     = [char[]]::new($objects.Count)
          $total     = $objects.Count
          $i         = 0
          foreach ($item in $objects) {
            $dn      = $item.DistinguishedName
            $s       = $dn.Substring(3)  # enlève "CN="
            if ($s.Length -gt 0) {
              $chars[$i] = [char]::ToLowerInvariant($s[0])
            } else {
              $chars[$i] = [char]0
            }
            $i++
          }
          $uchars    = $chars | sort -unique
          $uchars    = Remove-AccentDuplicates -Chars $uchars
          $nbchars   = $uchars.count
        }
        Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
        Write-Host "> Dividing query in $nbchars chunks."

        ## b. Récupération de la liste des objets via commande powershell
        $ts = Measure-Command {
          Write-Host "> Gathering data..."
          $objects     = @()
          $iteration = 0
          foreach ($char in $uchars) {
            $percent = [int]((++$iteration) * 100 / $nbchars)
            Write-Progress -Activity "Building ${ObjectType}s list for $DOMAIN" -PercentComplete $percent -Status "$percent %"
            $filter_char = $char -replace "'", "''"
            & $cmd -Server $DOMAIN -Filter "Name -like '$filter_char*'" -Properties $Properties |
              ForEach-Object { $props = [ordered]@{}; foreach ($p in $Properties) { $v = $_.$p; if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) { $props[$p] = $v -join ";"; } else { $props[$p] = $v; } } [PSCustomObject]$props } |
              Select-Object $Properties |
              Export-Csv -Path $csvPath -Delimiter ';' -NoTypeInformation -Encoding UTF8 -Append -Force
          }
        }
        Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts

      } # if ($nbobjects -lt $LIMIT_SINGLEQUERY)

    } # foreach ($DOMAIN in $domains)

  } # if ($Proceed)

} # foreach ($ObjectType in $ObjectTypes)


#end#

--
TG - 10/2025

Cet article a été généré par un humain