Public/Get-OrphanedSIDs.ps1

function Get-OrphanedSIDs {
    <#
    .SYNOPSIS
        Scans file/folder ACLs for orphaned SIDs (deleted accounts that still have permissions).
 
    .DESCRIPTION
        Recursively scans the specified paths for access control entries that reference
        unresolved SIDs — accounts that have been deleted from AD but still have
        permissions on files and folders.
 
        Orphaned SIDs are a security risk (unknown access) and a compliance finding.
 
    .PARAMETER Path
        One or more UNC or local paths to scan.
 
    .PARAMETER RemoveOrphans
        If specified, removes the orphaned SID entries from ACLs.
        Use with -WhatIf to preview changes.
 
    .PARAMETER MaxDepth
        Maximum folder recursion depth. Defaults to unlimited.
 
    .EXAMPLE
        Get-OrphanedSIDs -Path "\\fileserver\shared"
 
        Scans and reports orphaned SIDs.
 
    .EXAMPLE
        Get-OrphanedSIDs -Path "\\fileserver\shared" -RemoveOrphans -WhatIf
 
        Shows which orphaned SIDs would be removed.
 
    .EXAMPLE
        Get-OrphanedSIDs -Path "\\fileserver\shared" -RemoveOrphans -Confirm
 
        Removes orphaned SIDs with confirmation prompts.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [switch]$RemoveOrphans,

        [int]$MaxDepth = 0
    )

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

            Write-Verbose "Scanning: $scanPath"

            $gciParams = @{
                Path    = $scanPath
                Recurse = $true
                Force   = $true
                ErrorAction = 'SilentlyContinue'
            }
            if ($MaxDepth -gt 0) { $gciParams['Depth'] = $MaxDepth }

            # Include the root path itself plus all children
            $items = @(Get-Item $scanPath) + @(Get-ChildItem @gciParams)

            foreach ($item in $items) {
                try {
                    $acl = Get-Acl -Path $item.FullName -ErrorAction Stop
                }
                catch {
                    Write-Verbose "Cannot read ACL: $($item.FullName)"
                    continue
                }

                $orphans = $acl.Access | Where-Object {
                    $_.IdentityReference.Value -match '^S-1-5-\d+-\d+-\d+-\d+-\d+' -and
                    $_.IsInherited -eq $false
                }

                foreach ($orphan in $orphans) {
                    [PSCustomObject]@{
                        Path              = $item.FullName
                        OrphanedSID       = $orphan.IdentityReference.Value
                        AccessType        = $orphan.AccessControlType
                        Rights            = $orphan.FileSystemRights
                        IsInherited       = $orphan.IsInherited
                    }

                    if ($RemoveOrphans) {
                        if ($PSCmdlet.ShouldProcess($item.FullName, "Remove orphaned SID $($orphan.IdentityReference.Value)")) {
                            $acl.PurgeAccessRules($orphan.IdentityReference)
                            Set-Acl -AclObject $acl -Path $item.FullName
                            Write-Verbose "Removed: $($orphan.IdentityReference.Value) from $($item.FullName)"
                        }
                    }
                }
            }
        }
    }
}