|
| 1 | +# PSScriptAnalyzer suppressions - these warnings are acceptable for this interactive script |
| 2 | +# .SYNOPSIS |
| 3 | +# AD Group Member Audit Script with interactive prompts |
| 4 | +# .DESCRIPTION |
| 5 | +# This script audits Active Directory group members. Write-Host is intentionally used |
| 6 | +# for interactive user prompts and colored output, which is appropriate for this use case. |
| 7 | +param( |
| 8 | + [Parameter(Mandatory = $false, |
| 9 | + HelpMessage = "Comma-separated list of AD group names (e.g. 'Group1,Group2,Group3')")] |
| 10 | + [string]$GroupNames, |
| 11 | + |
| 12 | + [Parameter(Mandatory = $false, |
| 13 | + HelpMessage = "Path to a text file containing group names (one per line)")] |
| 14 | + [string]$GroupNamesFile, |
| 15 | + |
| 16 | + [Parameter(Mandatory = $false, |
| 17 | + HelpMessage = "Path to output CSV file")] |
| 18 | + [string]$OutputCsvPath = $(Join-Path -Path (Get-Location) -ChildPath ("ADGroupAudit_{0:yyyyMMdd_HHmmss}.csv" -f (Get-Date))), |
| 19 | + |
| 20 | + [Parameter(Mandatory = $false, |
| 21 | + HelpMessage = "Path to log file")] |
| 22 | + [string]$LogFilePath = $(Join-Path -Path (Get-Location) -ChildPath ("ADGroupAudit_{0:yyyyMMdd_HHmmss}.log" -f (Get-Date))) |
| 23 | +) |
| 24 | + |
| 25 | +# --- Interactive prompts for required parameters if not provided --- |
| 26 | + |
| 27 | +if ([string]::IsNullOrWhiteSpace($GroupNames) -and [string]::IsNullOrWhiteSpace($GroupNamesFile)) { |
| 28 | + Write-Host "" |
| 29 | + Write-Host "=== AD Group Member Audit Script ===" -ForegroundColor Cyan |
| 30 | + Write-Host "" |
| 31 | + Write-Host "Please provide the group names:" -ForegroundColor Yellow |
| 32 | + Write-Host "" |
| 33 | + Write-Host "Options:" -ForegroundColor Yellow |
| 34 | + Write-Host " 1. Enter comma-separated group names (e.g. 'Group1,Group2,Group3')" -ForegroundColor Gray |
| 35 | + Write-Host " 2. Enter a file path containing group names (one per line)" -ForegroundColor Gray |
| 36 | + Write-Host "" |
| 37 | + |
| 38 | + $userInput = Read-Host "Enter group names or file path" |
| 39 | + |
| 40 | + if ([string]::IsNullOrWhiteSpace($userInput)) { |
| 41 | + Write-Error "Group names or file path is required. Script cannot continue." |
| 42 | + exit 1 |
| 43 | + } |
| 44 | + |
| 45 | + # Check if input is a file path |
| 46 | + if (Test-Path -LiteralPath $userInput -ErrorAction SilentlyContinue) { |
| 47 | + $GroupNamesFile = $userInput |
| 48 | + Write-Host "Using file path: $GroupNamesFile" -ForegroundColor Green |
| 49 | + } else { |
| 50 | + $GroupNames = $userInput |
| 51 | + Write-Host "Using comma-separated group names: $GroupNames" -ForegroundColor Green |
| 52 | + } |
| 53 | + Write-Host "" |
| 54 | +} |
| 55 | + |
| 56 | +# --- Core script starts here --- |
| 57 | + |
| 58 | +# Start transcript logging |
| 59 | +try { |
| 60 | + Start-Transcript -Path $LogFilePath -Append -ErrorAction Stop |
| 61 | +} catch { |
| 62 | + Write-Warning "Failed to start transcript logging: $($_.Exception.Message)" |
| 63 | +} |
| 64 | + |
| 65 | +Write-Host "Starting AD Group Member audit..." |
| 66 | +Write-Host "Output CSV : $OutputCsvPath" |
| 67 | +Write-Host "Log file : $LogFilePath" |
| 68 | +Write-Host "Start time : $(Get-Date)" |
| 69 | +Write-Host "" |
| 70 | + |
| 71 | +function Write-Log { |
| 72 | + param( |
| 73 | + [string]$Message, |
| 74 | + [string]$Level = "INFO" |
| 75 | + ) |
| 76 | + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 77 | + $line = "[$timestamp] [$Level] $Message" |
| 78 | + Write-Host $line |
| 79 | +} |
| 80 | + |
| 81 | +# Check if ActiveDirectory module is available |
| 82 | +try { |
| 83 | + Import-Module ActiveDirectory -ErrorAction Stop |
| 84 | + Write-Log "ActiveDirectory module loaded successfully" |
| 85 | +} catch { |
| 86 | + Write-Error "Failed to import ActiveDirectory module. Please ensure RSAT-AD-PowerShell is installed." |
| 87 | + Write-Log "Failed to import ActiveDirectory module: $($_.Exception.Message)" "ERROR" |
| 88 | + try { |
| 89 | + Stop-Transcript | Out-Null |
| 90 | + } catch { |
| 91 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 92 | + } |
| 93 | + exit 1 |
| 94 | +} |
| 95 | + |
| 96 | +# Collect group names from either parameter or file |
| 97 | +$groupList = New-Object System.Collections.Generic.List[string] |
| 98 | + |
| 99 | +if (-not [string]::IsNullOrWhiteSpace($GroupNamesFile)) { |
| 100 | + Write-Log "Reading group names from file: $GroupNamesFile" |
| 101 | + |
| 102 | + if (-not (Test-Path -LiteralPath $GroupNamesFile)) { |
| 103 | + Write-Error "File not found: $GroupNamesFile" |
| 104 | + Write-Log "File not found: $GroupNamesFile" "ERROR" |
| 105 | + try { |
| 106 | + Stop-Transcript | Out-Null |
| 107 | + } catch { |
| 108 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 109 | + } |
| 110 | + exit 1 |
| 111 | + } |
| 112 | + |
| 113 | + try { |
| 114 | + $fileContent = Get-Content -Path $GroupNamesFile -ErrorAction Stop |
| 115 | + foreach ($line in $fileContent) { |
| 116 | + $trimmedLine = $line.Trim() |
| 117 | + if (-not [string]::IsNullOrWhiteSpace($trimmedLine)) { |
| 118 | + $groupList.Add($trimmedLine) |
| 119 | + } |
| 120 | + } |
| 121 | + Write-Log "Loaded $($groupList.Count) group names from file" |
| 122 | + } catch { |
| 123 | + Write-Error "Failed to read file: $($_.Exception.Message)" |
| 124 | + Write-Log "Failed to read file: $($_.Exception.Message)" "ERROR" |
| 125 | + try { |
| 126 | + Stop-Transcript | Out-Null |
| 127 | + } catch { |
| 128 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 129 | + } |
| 130 | + exit 1 |
| 131 | + } |
| 132 | +} elseif (-not [string]::IsNullOrWhiteSpace($GroupNames)) { |
| 133 | + Write-Log "Parsing comma-separated group names" |
| 134 | + |
| 135 | + $groups = $GroupNames -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
| 136 | + |
| 137 | + foreach ($group in $groups) { |
| 138 | + $groupList.Add($group) |
| 139 | + } |
| 140 | + |
| 141 | + Write-Log "Loaded $($groupList.Count) group names from parameter" |
| 142 | +} else { |
| 143 | + Write-Error "No group names provided. Please use -GroupNames or -GroupNamesFile parameter." |
| 144 | + Write-Log "No group names provided" "ERROR" |
| 145 | + try { |
| 146 | + Stop-Transcript | Out-Null |
| 147 | + } catch { |
| 148 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 149 | + } |
| 150 | + exit 1 |
| 151 | +} |
| 152 | + |
| 153 | +if ($groupList.Count -eq 0) { |
| 154 | + Write-Error "No valid group names found." |
| 155 | + Write-Log "No valid group names found" "ERROR" |
| 156 | + try { |
| 157 | + Stop-Transcript | Out-Null |
| 158 | + } catch { |
| 159 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 160 | + } |
| 161 | + exit 1 |
| 162 | +} |
| 163 | + |
| 164 | +Write-Log "Processing $($groupList.Count) group(s)..." |
| 165 | + |
| 166 | +$errors = New-Object System.Collections.Generic.List[psobject] |
| 167 | +$idCounter = 0 |
| 168 | +$script:CsvInitialized = $false |
| 169 | + |
| 170 | +# Helper: write one row to CSV (streaming, header once) |
| 171 | +function Write-UserRow { |
| 172 | + param( |
| 173 | + [pscustomobject]$Row, |
| 174 | + [string]$CsvPath |
| 175 | + ) |
| 176 | + |
| 177 | + if (-not $script:CsvInitialized) { |
| 178 | + $Row | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 |
| 179 | + $script:CsvInitialized = $true |
| 180 | + } else { |
| 181 | + $Row | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 -Append |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +# Process each group |
| 186 | +$groupIndex = 0 |
| 187 | +foreach ($groupName in $groupList) { |
| 188 | + $groupIndex++ |
| 189 | + Write-Progress -Activity "Auditing AD Groups" -Status "Processing group: $groupName ($groupIndex of $($groupList.Count))" -PercentComplete (($groupIndex / $groupList.Count) * 100) |
| 190 | + |
| 191 | + Write-Log "Processing group: $groupName" |
| 192 | + |
| 193 | + try { |
| 194 | + # Verify group exists |
| 195 | + $group = Get-ADGroup -Identity $groupName -ErrorAction Stop |
| 196 | + Write-Log "Found group: $groupName (DistinguishedName: $($group.DistinguishedName))" |
| 197 | + |
| 198 | + # Get all members of the group |
| 199 | + $members = Get-ADGroupMember -Identity $groupName -ErrorAction Stop |
| 200 | + |
| 201 | + if ($members.Count -eq 0) { |
| 202 | + Write-Log "Group '$groupName' has no members" "WARN" |
| 203 | + continue |
| 204 | + } |
| 205 | + |
| 206 | + Write-Log "Found $($members.Count) member(s) in group '$groupName'" |
| 207 | + |
| 208 | + # Process each member |
| 209 | + foreach ($member in $members) { |
| 210 | + try { |
| 211 | + # Check if member has SamAccountName (users do, groups/computers might not) |
| 212 | + $memberIdentity = $null |
| 213 | + if ($member.SamAccountName) { |
| 214 | + $memberIdentity = $member.SamAccountName |
| 215 | + } elseif ($member.DistinguishedName) { |
| 216 | + $memberIdentity = $member.DistinguishedName |
| 217 | + } else { |
| 218 | + Write-Log "Member object has no identifiable attribute (SamAccountName or DN): $($member | ConvertTo-Json -Compress)" "WARN" |
| 219 | + continue |
| 220 | + } |
| 221 | + |
| 222 | + # Get detailed user information |
| 223 | + $user = Get-ADUser -Identity $memberIdentity -Properties DisplayName, EmailAddress, UserPrincipalName, Enabled, Department, Title, Office, Manager, LastLogonDate, Created, Modified -ErrorAction Stop |
| 224 | + |
| 225 | + $idCounter++ |
| 226 | + |
| 227 | + # Get manager name if available |
| 228 | + $managerName = $null |
| 229 | + if ($user.Manager) { |
| 230 | + try { |
| 231 | + $manager = Get-ADUser -Identity $user.Manager -Properties DisplayName -ErrorAction SilentlyContinue |
| 232 | + if ($manager) { |
| 233 | + $managerName = $manager.DisplayName |
| 234 | + } |
| 235 | + } catch { |
| 236 | + # Manager lookup failed, leave as null |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + $row = [pscustomobject]@{ |
| 241 | + ID = $idCounter |
| 242 | + GroupName = $groupName |
| 243 | + GroupDN = $group.DistinguishedName |
| 244 | + SamAccountName = $user.SamAccountName |
| 245 | + DisplayName = $user.DisplayName |
| 246 | + UserPrincipalName = $user.UserPrincipalName |
| 247 | + EmailAddress = $user.EmailAddress |
| 248 | + Enabled = $user.Enabled |
| 249 | + Department = $user.Department |
| 250 | + Title = $user.Title |
| 251 | + Office = $user.Office |
| 252 | + Manager = $managerName |
| 253 | + LastLogonDate = $user.LastLogonDate |
| 254 | + Created = $user.Created |
| 255 | + Modified = $user.Modified |
| 256 | + MemberDN = $member.DistinguishedName |
| 257 | + MemberObjectClass = if ($member.objectClass) { ($member.objectClass -join ',') } else { "User" } |
| 258 | + AuditDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 259 | + } |
| 260 | + |
| 261 | + Write-UserRow -Row $row -CsvPath $OutputCsvPath |
| 262 | + |
| 263 | + } catch { |
| 264 | + # Member might not be a user (could be a group or computer) |
| 265 | + $memberSamAccount = if ($member.SamAccountName) { $member.SamAccountName } else { "N/A" } |
| 266 | + Write-Log "Member '$memberSamAccount' (ObjectClass: $($member.objectClass)) is not a user account or could not be retrieved: $($_.Exception.Message)" "WARN" |
| 267 | + |
| 268 | + # Still record the member even if we can't get full user details |
| 269 | + $idCounter++ |
| 270 | + $row = [pscustomobject]@{ |
| 271 | + ID = $idCounter |
| 272 | + GroupName = $groupName |
| 273 | + GroupDN = $group.DistinguishedName |
| 274 | + SamAccountName = $memberSamAccount |
| 275 | + DisplayName = $null |
| 276 | + UserPrincipalName = $null |
| 277 | + EmailAddress = $null |
| 278 | + Enabled = $null |
| 279 | + Department = $null |
| 280 | + Title = $null |
| 281 | + Office = $null |
| 282 | + Manager = $null |
| 283 | + LastLogonDate = $null |
| 284 | + Created = $null |
| 285 | + Modified = $null |
| 286 | + MemberDN = if ($member.DistinguishedName) { $member.DistinguishedName } else { if ($member.Name) { $member.Name } else { "N/A" } } |
| 287 | + MemberObjectClass = if ($member.objectClass) { ($member.objectClass -join ',') } else { "Unknown" } |
| 288 | + AuditDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 289 | + } |
| 290 | + |
| 291 | + Write-UserRow -Row $row -CsvPath $OutputCsvPath |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + } catch { |
| 296 | + $errObj = [pscustomobject]@{ |
| 297 | + GroupName = $groupName |
| 298 | + Error = $_.Exception.Message |
| 299 | + TimeStamp = Get-Date |
| 300 | + } |
| 301 | + $errors.Add($errObj) | Out-Null |
| 302 | + Write-Log "Failed to process group '$groupName': $($_.Exception.Message)" "ERROR" |
| 303 | + } |
| 304 | +} |
| 305 | + |
| 306 | +Write-Progress -Activity "Auditing AD Groups" -Completed |
| 307 | + |
| 308 | +Write-Log "Finished collecting group members. CSV written to '$OutputCsvPath'" |
| 309 | + |
| 310 | +if ($errors.Count -gt 0) { |
| 311 | + $errorCsvPath = [System.IO.Path]::ChangeExtension($OutputCsvPath, ".errors.csv") |
| 312 | + try { |
| 313 | + $errors | Export-Csv -Path $errorCsvPath -NoTypeInformation -Encoding UTF8 |
| 314 | + Write-Log "Encountered $($errors.Count) errors. Details saved to '$errorCsvPath'" "WARN" |
| 315 | + } catch { |
| 316 | + Write-Log "Failed to export error details: $($_.Exception.Message)" "ERROR" |
| 317 | + } |
| 318 | +} else { |
| 319 | + Write-Log "No errors encountered." |
| 320 | +} |
| 321 | + |
| 322 | +Write-Host "" |
| 323 | +Write-Host "End time : $(Get-Date)" |
| 324 | +Write-Host "Total rows : $idCounter" |
| 325 | +Write-Host "Groups processed: $($groupList.Count)" |
| 326 | +Write-Host "Audit complete." |
| 327 | + |
| 328 | +try { |
| 329 | + Stop-Transcript | Out-Null |
| 330 | +} catch { |
| 331 | + Write-Warning "Failed to stop transcript: $($_.Exception.Message)" |
| 332 | +} |
| 333 | + |
0 commit comments