scripts/Intune/Windows/remove-local-admin/detect.ps1

<#
    Intune remediation - DETECTION half.

    Reports whether a specific account is a member of the local Administrators group.

        exit 0 - the account is NOT a member. Nothing to do.
        exit 1 - the account IS a member. Intune runs remediate.ps1.
        throw - the group could not be read. Intune records a SCRIPT FAILURE and does
                  NOT run the remediation, which is the safe direction: a machine we
                  could not read is not a machine we should start removing admins from.

    CONFIGURE $TargetAccount BELOW BEFORE UPLOADING. Intune remediation scripts take no
    parameters, so the account is baked into the file. Upload one detect/remediate pair
    per account you want gone, and keep the value identical in both halves.

    Intune settings this expects:
        Run this script using the logged-on credentials : No (needs SYSTEM)
        Run script in 64-bit PowerShell : Yes
#>


#-------------------------------------------------------------------------------------
# The account to look for. Any of these forms:
#
# S-1-12-1-... / S-1-5-21-... SID. Nothing can rename it out from under you, so
# prefer it wherever you have it.
# AzureAD\jane@contoso.com Entra account BY UPN - see the note on Entra below.
# jane@contoso.com the bare UPN, same thing.
# CONTOSO\jdoe Active Directory account.
# localadmin local account.
$TargetAccount = 'AzureAD\legacy.admin@contoso.com'
#-------------------------------------------------------------------------------------

$ErrorActionPreference = 'Stop'

# Entra ID's SID authority. Every Entra user or group placed in a local group appears
# under S-1-12-1-, with the object's GUID encoded as four little-endian uint32s.
$EntraSidPrefix = 'S-1-12-1-'

function Get-EntraUpnFromSid {
    <#
        Entra SID -> UPN, from the two IdentityStore caches Windows writes when an Entra
        principal is known to the device.

        THIS IS WHAT MAKES MATCHING BY UPN POSSIBLE AT ALL. ADSI and LSA give an Entra
        member its SAM-COMPATIBLE name - 'AzureAD\JaneDoe' - never the UPN. Matching a
        target written as 'AzureAD\jane@contoso.com' against that fails every time, so
        the account stays an administrator and the detection reports the device clean.

        Returns $null when neither cache has it, which is the normal answer for an Entra
        GROUP (groups have no UPN) and for a user who has never signed in here.
    #>

    param([string] $Sid)

    # Written per signed-in identity. The SID appears twice in the path by design.
    $cache = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\IdentityStore\Cache\$Sid\IdentityCache\$Sid" `
                              -Name 'UserName' -ErrorAction SilentlyContinue
    if ($cache.UserName -and $cache.UserName -like '*@*') { return $cache.UserName }

    # LogonCache is keyed by identity provider GUID, and which provider holds a given
    # account is not fixed, so every provider subkey is tried.
    foreach ($provider in (Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\IdentityStore\LogonCache' -ErrorAction SilentlyContinue)) {
        $entry = Get-ItemProperty -Path "$($provider.PSPath)\Sid2Name\$Sid" `
                                  -Name 'IdentityName' -ErrorAction SilentlyContinue
        if ($entry.IdentityName -and $entry.IdentityName -like '*@*') { return $entry.IdentityName }
    }

    return $null
}

function Get-AdminGroupMember {
    <#
        Every member of the local Administrators group, with the identifiers needed to
        match one.

        THE GROUP IS BOUND BY SID, NEVER BY NAME. 'Administrators' is renamed on localised
        Windows - Administratoren, Administratörer - and a hardcoded English name silently
        finds nothing there, reporting every such machine as clean.

        MEMBERS COME FROM ADSI, NOT Get-LocalGroupMember. That cmdlet raises "A local
        account with the SID '<sid>' was not found" for any member it cannot resolve - an
        Entra user who has never signed in to this device, or an object since deleted from
        Entra. The membership is real; only the name lookup fails. The error is
        NON-TERMINATING, but under $ErrorActionPreference = 'Stop' it becomes terminating,
        so ONE unresolvable member aborts the whole enumeration and a machine full of Entra
        admins looks like a collection failure.
    #>

    $adminSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
    $groupName = $adminSid.Translate([System.Security.Principal.NTAccount]).Value.Split('\')[-1]

    # 'WinNT://./' rather than the computer name: a machine renamed but not yet rebooted
    # still answers to '.', and would not answer to either name reliably.
    $group = [ADSI] "WinNT://./$groupName,group"

    # PSBase.Invoke, because an ADSI object can shadow Invoke with a directory method of
    # the same name.
    foreach ($member in @($group.PSBase.Invoke('Members'))) {
        $sid = $null; $name = $null; $domain = $null

        try {
            # Late binding: these are raw COM objects, so ordinary property access does
            # not apply.
            $bytes = $member.GetType().InvokeMember('objectSid', 'GetProperty', $null, $member, $null)
            $sid = [System.Security.Principal.SecurityIdentifier]::new([byte[]] $bytes, 0).Value
        }
        catch { }

        try {
            $name = $member.GetType().InvokeMember('Name', 'GetProperty', $null, $member, $null)
            $path = $member.GetType().InvokeMember('ADsPath', 'GetProperty', $null, $member, $null)
            $parts = ($path -replace '^WinNT://', '') -split '/'
            if ($parts.Count -ge 2) { $domain = $parts[-2] }
        }
        catch { }

        # The UPN lookup is UNCONDITIONAL for an Entra member, deliberately not skipped
        # when $name already contains an '@'. The SAM-compatible name is truncated at 20
        # characters, which can cut mid-domain and leave 'anton@examp' - that looks like a
        # UPN and is not one. The cached UPN is authoritative either way.
        $upn = if ($sid -and $sid.StartsWith($EntraSidPrefix)) { Get-EntraUpnFromSid -Sid $sid } else { $null }

        [pscustomobject]@{
            Sid       = $sid
            Name      = $name
            Domain    = $domain
            Upn       = $upn
            # Same qualified shape the macOS inventory script emits, so the two platforms
            # line up: AzureAD\<upn> | <DOMAIN>\<name> | <COMPUTER>\<name>.
            Qualified = if ($upn -and $domain) { "$domain\$upn" }
                        elseif ($domain -and $name) { "$domain\$name" }
                        else { $name }
            GroupName = $groupName
        }
    }
}

function Test-TargetMatch {
    param($Member)

    if ($TargetAccount -match '^S-1-') { return ($Member.Sid -and $Member.Sid -eq $TargetAccount) }

    # Every form the same principal can legitimately be written as. The UPN forms are what
    # the IdentityStore lookup above exists to make possible.
    $candidates = @($Member.Name, $Member.Qualified, $Member.Upn)
    if ($Member.Upn -and $Member.Domain) { $candidates += "$($Member.Domain)\$($Member.Upn)" }

    return @($candidates | Where-Object { $_ -and $_ -eq $TargetAccount }).Count -gt 0
}

$members = @(Get-AdminGroupMember)
$groupName = if ($members.Count) { $members[0].GroupName } else { 'Administrators' }
$found = @($members | Where-Object { Test-TargetMatch $_ })

if (-not $found.Count) {
    Write-Output "OK: '$TargetAccount' is not a member of $groupName ($($members.Count) member(s))."
    exit 0
}

# Intune keeps roughly 2 KB of this and shows it in the 'Pre-remediation detection output'
# column, so it names what was found and its SID - which is what makes the removal that
# follows auditable rather than just a green tick.
$detail = ($found | ForEach-Object { "$($_.Qualified) [$($_.Sid)]" }) -join ', '
Write-Output "REMEDIATE: '$TargetAccount' is a member of ${groupName}: $detail"
exit 1