Public/Get-DirectUserACEs.ps1

function Get-DirectUserACEs {
    <#
    .SYNOPSIS
        Finds ACL entries assigned directly to user accounts rather than groups.
 
    .DESCRIPTION
        Scans NTFS permissions on the specified paths and identifies access control entries
        that reference individual user accounts instead of security groups. Direct user ACEs
        are a common compliance finding because they bypass group-based access control and
        make permission management error-prone. Departed employees with direct ACEs are a
        frequent audit failure.
 
    .PARAMETER Path
        One or more file system paths to scan. Accepts pipeline input.
 
    .PARAMETER MaxDepth
        Maximum folder recursion depth. Defaults to 3 to prevent runaway scans.
 
    .PARAMETER ExcludeBuiltIn
        When set (default: true), excludes built-in accounts such as NT AUTHORITY\SYSTEM,
        BUILTIN\Administrators, NT AUTHORITY\LOCAL SERVICE, etc. These are expected to have
        direct ACEs.
 
    .EXAMPLE
        Get-DirectUserACEs -Path '\\server\share\Finance'
 
    .EXAMPLE
        '\\server\share\Finance', '\\server\share\HR' | Get-DirectUserACEs -MaxDepth 5
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$MaxDepth = 3,

        [Parameter()]
        [switch]$ExcludeBuiltIn = $true
    )

    begin {
        # Built-in accounts that are expected to have direct ACEs
        $builtInPatterns = @(
            'NT AUTHORITY\SYSTEM'
            'NT AUTHORITY\LOCAL SERVICE'
            'NT AUTHORITY\NETWORK SERVICE'
            'BUILTIN\Administrators'
            'BUILTIN\Users'
            'BUILTIN\IIS_IUSRS'
            'CREATOR OWNER'
            'NT SERVICE\*'
        )

        function Test-BuiltInIdentity {
            param([string]$Identity)
            foreach ($pattern in $builtInPatterns) {
                if ($Identity -like $pattern) { return $true }
            }
            return $false
        }

        function Get-IdentityType {
            <#
            .SYNOPSIS
                Determines whether an identity reference is a user or group account.
            #>

            param([string]$Identity)

            # Strip domain prefix for lookup
            $accountName = $Identity
            if ($Identity -match '^(.+)\\(.+)$') {
                $accountName = $Matches[2]
            }

            # Try AD group first (groups are the expected case)
            try {
                $null = Get-ADGroup -Identity $accountName -ErrorAction Stop
                return 'Group'
            }
            catch {
                # Not a group - continue
            }

            # Try AD user
            try {
                $null = Get-ADUser -Identity $accountName -ErrorAction Stop
                return 'User'
            }
            catch {
                # Not found in AD - could be local or orphaned SID
            }

            # Check if it looks like an unresolved SID (departed employee)
            if ($Identity -match '^S-1-5-21-') {
                return 'User'   # Unresolved SIDs are almost always departed users
            }

            return 'Unknown'
        }

        function Get-FoldersToDepth {
            param(
                [string]$RootPath,
                [int]$Depth
            )

            $folders = @([PSCustomObject]@{ FullName = $RootPath; Depth = 0 })

            if ($Depth -ge 1) {
                $children = Get-ChildItem -Path $RootPath -Directory -Recurse -Depth ($Depth - 1) -ErrorAction SilentlyContinue
                foreach ($child in $children) {
                    $relativePath = $child.FullName.Substring($RootPath.Length).TrimStart('\', '/')
                    $currentDepth = ($relativePath -split '[\\/]').Count
                    $folders += [PSCustomObject]@{ FullName = $child.FullName; Depth = $currentDepth }
                }
            }

            $folders
        }

        $results = [System.Collections.Generic.List[PSObject]]::new()
    }

    process {
        foreach ($scanPath in $Path) {
            if (-not (Test-Path -Path $scanPath)) {
                Write-Warning "Path not found: $scanPath"
                continue
            }

            Write-Verbose "Scanning for direct user ACEs: $scanPath (MaxDepth: $MaxDepth)"

            $folders = Get-FoldersToDepth -RootPath $scanPath -Depth $MaxDepth

            foreach ($folder in $folders) {
                try {
                    $acl = Get-Acl -Path $folder.FullName -ErrorAction Stop
                }
                catch {
                    Write-Warning "Cannot read ACL on $($folder.FullName): $_"
                    continue
                }

                foreach ($ace in $acl.Access) {
                    $identity = $ace.IdentityReference.Value

                    # Skip built-in accounts if requested
                    if ($ExcludeBuiltIn -and (Test-BuiltInIdentity -Identity $identity)) {
                        continue
                    }

                    $objectType = Get-IdentityType -Identity $identity

                    $finding = if ($objectType -eq 'User') {
                        'DIRECT USER ACE'
                    }
                    else {
                        'OK'
                    }

                    $result = [PSCustomObject]@{
                        Path        = $folder.FullName
                        Identity    = $identity
                        AccessType  = $ace.AccessControlType.ToString()
                        Rights      = $ace.FileSystemRights.ToString()
                        IsInherited = $ace.IsInherited
                        ObjectType  = $objectType
                        Finding     = $finding
                    }

                    $results.Add($result)
                }
            }
        }
    }

    end {
        $results
    }
}