# Active Directory Privileged Group Membership Audit Script

This PowerShell script helps you create a repeatable audit record for privileged Active Directory groups. It exports direct and nested membership, classifies member types, and produces CSV files you can review offline and attach to a change ticket or audit workbook.

## What it does

- Queries one or more privileged AD groups
- Exports group membership evidence with timestamps
- Attempts to classify members as user, service account, computer, or nested group
- Flags potentially risky patterns such as disabled accounts and nested groups
- Produces a consolidated review file for remediation follow-up

## What it does not do

- It does not remove accounts from groups
- It does not modify directory objects
- It does not require credentials embedded in the script

## Prerequisites

- RSAT ActiveDirectory module installed, or a PowerShell environment that can query Active Directory
- Read access to the target domain or forest
- Permission to enumerate nested group membership if you want recursive results

## Usage example

```powershell
.\Invoke-PrivilegedGroupAudit.ps1 -GroupNames @('Domain Admins','Enterprise Admins','Administrators') -OutputPath 'C:\Audit\AD-Privileged-Groups'
```

## Output files

- `privileged-group-audit-summary.csv`
- `privileged-group-audit-members.csv`
- `privileged-group-audit-findings.csv`
- A transcript file for run-level evidence

## Review guidance

Use the exported files to confirm:

- The scope of the groups audited
- Direct versus nested membership
- Account type and likely business purpose
- Disabled, stale, temporary, or unexpected access
- Ownership and justification gaps that need remediation

---

```powershell
<#!
.SYNOPSIS
    Exports and classifies privileged Active Directory group memberships for audit review.

.DESCRIPTION
    This script queries one or more privileged Active Directory groups, exports membership evidence,
    classifies members into common account types, and creates audit-friendly CSV outputs.

    It is intentionally read-only and performs no changes to directory objects.

.NOTES
    Requires the ActiveDirectory module for full functionality.
    If the module is unavailable, the script will stop with a clear error.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string[]]$GroupNames,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath = (Join-Path -Path (Get-Location) -ChildPath 'ad-privileged-audit'),

    [Parameter(Mandatory = $false)]
    [switch]$Recursive,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeDisabledCheck = $true,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeTranscript = $true
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Test-ModuleAvailable {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    return [bool](Get-Module -ListAvailable -Name $Name)
}

function Get-AccountClassification {
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Member
    )

    $objectClass = ($Member.objectClass | ForEach-Object { $_.ToString().ToLowerInvariant() })
    $sam = [string]$Member.SamAccountName
    $name = [string]$Member.Name

    switch ($objectClass) {
        'user' {
            if ($sam -match '^(svc|service|sa|sql|app|batch|backup|sched)') { return 'Service Account' }
            if ($sam -match '^(adm|admin|breakglass|bg-)') { return 'Privileged User' }
            return 'User'
        }
        'computer' { return 'Computer Account' }
        'group' { return 'Nested Group' }
        default {
            if ($name -match 'service|svc|sql|app|backup|batch') { return 'Likely Service Account' }
            return 'Unknown'
        }
    }
}

function Get-DisabledState {
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Member
    )

    if (-not $IncludeDisabledCheck) {
        return 'Not Checked'
    }

    try {
        if ($Member.objectClass -eq 'user' -or $Member.objectClass -eq 'computer') {
            $adObject = Get-ADObject -Identity $Member.DistinguishedName -Properties Enabled -ErrorAction Stop
            if ($null -ne $adObject.Enabled -and $adObject.Enabled -eq $false) { return 'Disabled' }
            if ($null -ne $adObject.Enabled -and $adObject.Enabled -eq $true) { return 'Enabled' }
        }
    }
    catch {
        return 'Unknown'
    }

    return 'Unknown'
}

# Validate input
if (-not (Test-ModuleAvailable -Name 'ActiveDirectory')) {
    throw 'The ActiveDirectory module was not found. Install RSAT or run in a management environment that includes the module.'
}

Import-Module ActiveDirectory -ErrorAction Stop

if (-not (Test-Path -Path $OutputPath)) {
    New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
}

$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$summaryPath = Join-Path -Path $OutputPath -ChildPath 'privileged-group-audit-summary.csv'
$membersPath = Join-Path -Path $OutputPath -ChildPath 'privileged-group-audit-members.csv'
$findingsPath = Join-Path -Path $OutputPath -ChildPath 'privileged-group-audit-findings.csv'
$transcriptPath = Join-Path -Path $OutputPath -ChildPath "privileged-group-audit-$timestamp.txt"

if ($IncludeTranscript) {
    Start-Transcript -Path $transcriptPath -Force | Out-Null
}

$allMembers = New-Object System.Collections.Generic.List[object]
$allFindings = New-Object System.Collections.Generic.List[object]
$allSummary = New-Object System.Collections.Generic.List[object]

foreach ($groupName in $GroupNames) {
    Write-Host "Auditing group: $groupName"

    try {
        $group = Get-ADGroup -Identity $groupName -ErrorAction Stop
    }
    catch {
        $allFindings.Add([pscustomobject]@{
            GroupName = $groupName
            FindingType = 'LookupFailure'
            Severity = 'High'
            Detail = 'Group could not be resolved in Active Directory.'
            Recommendation = 'Confirm scope, domain boundary, and group name.'
        })
        continue
    }

    $members = @()
    try {
        if ($Recursive) {
            $members = Get-ADGroupMember -Identity $group.DistinguishedName -Recursive -ErrorAction Stop
        }
        else {
            $members = Get-ADGroupMember -Identity $group.DistinguishedName -ErrorAction Stop
        }
    }
    catch {
        $allFindings.Add([pscustomobject]@{
            GroupName = $groupName
            FindingType = 'MembershipQueryFailure'
            Severity = 'High'
            Detail = 'Membership could not be queried.'
            Recommendation = 'Verify permissions and whether recursive enumeration is supported in this environment.'
        })
        continue
    }

    $memberCount = 0
    foreach ($member in $members) {
        $memberCount++
        $classification = Get-AccountClassification -Member $member
        $disabledState = Get-DisabledState -Member $member

        $record = [pscustomobject]@{
            AuditTimestamp = $timestamp
            GroupName = $group.Name
            GroupSamAccountName = $group.SamAccountName
            GroupDistinguishedName = $group.DistinguishedName
            MemberName = $member.Name
            MemberSamAccountName = $member.SamAccountName
            MemberObjectClass = $member.objectClass
            MemberDistinguishedName = $member.DistinguishedName
            Classification = $classification
            DisabledState = $disabledState
            Recursive = [bool]$Recursive
        }

        $allMembers.Add($record)

        if ($classification -eq 'Nested Group') {
            $allFindings.Add([pscustomobject]@{
                GroupName = $groupName
                FindingType = 'NestedMembership'
                Severity = 'Medium'
                Detail = "Nested group present: $($member.Name)"
                Recommendation = 'Review the nested group owner, membership, and change controls.'
            })
        }

        if ($disabledState -eq 'Disabled') {
            $allFindings.Add([pscustomobject]@{
                GroupName = $groupName
                FindingType = 'DisabledMember'
                Severity = 'Medium'
                Detail = "Disabled account remains in privileged group: $($member.Name)"
                Recommendation = 'Confirm whether the membership is still required and remove if not justified.'
            })
        }
    }

    $allSummary.Add([pscustomobject]@{
        AuditTimestamp = $timestamp
        GroupName = $group.Name
        GroupSamAccountName = $group.SamAccountName
        DistinguishedName = $group.DistinguishedName
        MemberCount = $memberCount
        Recursive = [bool]$Recursive
        ReviewedBy = $env:USERNAME
        ReviewStatus = 'Pending validation of justification and ownership'
    })
}

$allSummary | Export-Csv -Path $summaryPath -NoTypeInformation -Encoding UTF8
$allMembers | Export-Csv -Path $membersPath -NoTypeInformation -Encoding UTF8
$allFindings | Export-Csv -Path $findingsPath -NoTypeInformation -Encoding UTF8

if ($IncludeTranscript) {
    Stop-Transcript | Out-Null
}

Write-Host ''
Write-Host 'Audit complete.'
Write-Host "Summary:  $summaryPath"
Write-Host "Members:  $membersPath"
Write-Host "Findings: $findingsPath"
if ($IncludeTranscript) {
    Write-Host "Log:      $transcriptPath"
}

<#
Post-run review checklist:
- Confirm the audit scope matches the intended domain or forest.
- Validate direct vs nested membership for each group.
- Attach business justification and owner for each member.
- Investigate disabled, temporary, or unexpected accounts.
- Record remediation actions with owners and due dates.
#>
```

## Suggested audit workflow

1. Define the privileged groups in scope.
2. Run the script from a management workstation.
3. Review the exported CSV files offline.
4. Validate ownership and justification with tickets or IAM records.
5. Flag risky or unverified memberships for remediation.
6. Re-run after changes to confirm the directory now matches policy.