scripts/Intune/Windows/entra-local-admins/detect.ps1
|
<# Inventory the Microsoft Entra principals in the local Administrators group. Output contract (unchanged): a single line, semicolon-separated, or 'None'. Exit 0 = the inventory ran; exit 1 = it could not run at all. WHY NOT Get-LocalGroupMember ---------------------------- Get-LocalGroupMember raises "A local account with the SID '<sid>' was not found." for any member it cannot resolve to a principal - 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. That error is NON-TERMINATING, so the cmdlet still returns the members it could resolve. But under $ErrorActionPreference = 'Stop' it becomes terminating, one bad member aborts the enumeration, and the script reports ERROR while a machine full of Entra admins looks like a collection failure. So membership is read through the ADSI WinNT provider, which yields each member's raw objectSid and never fails on an unresolvable one. UNRESOLVABLE MEMBERS ARE OMITTED from the output line, on purpose: a raw SID is an unstable inventory value that means nothing to whatever consumes this. They are counted and written to the VERBOSE stream with the object id decoded, which Intune does not capture, so `-Verbose` on the device lists them without ever polluting the inventory. The trade-off to be aware of: on a device where EVERY Entra admin is unresolvable this prints 'None', which reads as "no Entra local admins" when there are some. The exit code is still 0 because the collection itself worked. If that distinction ever matters, the count in $skipped is the hook to surface it. #> $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. This # prefix is the reliable way to tell an Entra principal from a local or domain one: # PrincipalSource requires the member to have resolved, which is exactly what fails here. $entraSidPrefix = 'S-1-12-1-' function ConvertFrom-EntraSid { <# S-1-12-1-a-b-c-d -> object GUID. Each of the four values is one little-endian uint32 of the GUID's 16 bytes, so the GUID is the concatenation in order. Emitting it turns an unresolvable SID into something you can look up in Entra directly. #> param([string] $Sid) # Guarded rather than trusting the caller: a local or domain SID such as # S-1-5-21-a-b-c-rid also has eight parts, and would decode into a plausible-looking # GUID that means nothing at all. if (-not $Sid.StartsWith($entraSidPrefix)) { return $null } $parts = $Sid.Split('-') if ($parts.Count -ne 8) { return $null } try { $bytes = foreach ($p in $parts[4..7]) { [BitConverter]::GetBytes([uint32] $p) } return [guid][byte[]] $bytes } catch { return $null } } function Get-EntraUpnFromSid { <# Entra SID -> UPN, from the two IdentityStore caches Windows writes when an Entra principal is known to the device. Returns $null when neither 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. Both lookups are best-effort: a missing key is an expected outcome, not an error, hence -ErrorAction SilentlyContinue rather than letting $ErrorActionPreference turn an absent path into a terminating failure. #> 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. $providers = Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\IdentityStore\LogonCache' ` -ErrorAction SilentlyContinue foreach ($provider in $providers) { $entry = Get-ItemProperty -Path "$($provider.PSPath)\Sid2Name\$Sid" ` -Name 'IdentityName' -ErrorAction SilentlyContinue if ($entry.IdentityName -and $entry.IdentityName -like '*@*') { return $entry.IdentityName } } return $null } try { # Bind by well-known SID, not by the name 'Administrators': the group is renamed on # localised Windows ('Administratoren', 'Administratörer'), and a hardcoded English # name silently finds nothing on those machines. $adminSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $adminGroupName = $adminSid.Translate([System.Security.Principal.NTAccount]).Value.Split('\')[-1] $group = [ADSI] "WinNT://./$adminGroupName,group" $members = @($group.PSBase.Invoke('Members')) $entraMembers = [System.Collections.Generic.List[string]]::new() $skipped = 0 foreach ($member in $members) { # Read objectSid via late binding: these are raw COM objects, so the usual # property access does not apply. $sidValue = $null try { $sidBytes = $member.GetType().InvokeMember('objectSid', 'GetProperty', $null, $member, $null) $sidValue = [System.Security.Principal.SecurityIdentifier]::new([byte[]] $sidBytes, 0).Value } catch { $skipped++ Write-Verbose "Skipped a group member whose SID could not be read: $($_.Exception.Message)" continue } if (-not $sidValue.StartsWith($entraSidPrefix)) { continue } # Try for a friendly name. This gives 'AzureAD\<sam-compatible name>'; it throws # IdentityNotMappedException for the object this script exists to survive, so the # failure is per-member and expected. $name = $null try { $name = [System.Security.Principal.SecurityIdentifier]::new($sidValue). Translate([System.Security.Principal.NTAccount]).Value } catch { $name = $null } # Replace the SAM-compatible account part with the UPN, keeping whatever domain # prefix LSA gave ('AzureAD' here, but not hardcoded - it is the authority's # answer, not ours to assert). Only the part after the LAST backslash is swapped; # a UPN never contains one, so the split is unambiguous. # The lookup is unconditional, NOT skipped when the translated name already # contains an '@': the 20-character SAM truncation can cut mid-domain and leave # 'anton@examp', which looks like a UPN and is not one. The cached UPN is # authoritative either way, so there is nothing to gain by guessing first. if ($name) { $upn = Get-EntraUpnFromSid -Sid $sidValue if ($upn) { $prefix = $name.Substring(0, $name.LastIndexOf('\') + 1) $name = "$prefix$upn" } else { # Left as the SAM-compatible name rather than dropped: it is still a real # member, and this is the expected shape for an Entra *group*, which has # no UPN to find. Write-Verbose "No UPN cached for $sidValue; reporting '$name' as translated." } } if ([string]::IsNullOrWhiteSpace($name)) { # Deliberately excluded from the output: a raw SID is an unstable inventory # value that tells whatever consumes this line nothing useful. It still goes # to the verbose stream, with the object id decoded, so a manual run can # chase it - and that stream never reaches Intune's captured output. $skipped++ $guid = ConvertFrom-EntraSid -Sid $sidValue Write-Verbose $(if ($guid) { "Skipped unresolvable Entra member $sidValue (object $guid)" } else { "Skipped unresolvable Entra member $sidValue" }) continue } $entraMembers.Add($name) } if ($skipped) { Write-Verbose "$skipped Entra member(s) of '$adminGroupName' could not be resolved and were omitted. Re-run with -Verbose on the device to list them." } if ($entraMembers.Count) { Write-Output (($entraMembers | Sort-Object -Unique) -join ';') } else { Write-Output 'None' } exit 0 } catch { # Only a failure to enumerate the group at all reaches here now - not a single # member that would not resolve. Write-Output "ERROR: $($_.Exception.Message)" exit 1 } |