Public/Convert-MsecEntraSid.ps1

function Convert-MsecEntraSid {
    <#
    .SYNOPSIS
        Converts an Entra ID SID (S-1-12-1-...) to the object's GUID objectId, and
        back again.

    .DESCRIPTION
        Cloud-only Entra accounts get a synthetic SID of the form

            S-1-12-1-<a>-<b>-<c>-<d>

        where a..d are four unsigned 32-bit integers holding, little-endian, the
        16 bytes of the object's Entra objectId. The mapping is pure arithmetic:
        no directory lookup is involved, and it is exactly reversible. This is the
        SID that shows up in Windows event logs, `whoami /user`, local group
        membership, Intune detection scripts and NTFS ACLs on Entra-joined
        devices - where the objectId you can actually search for in Entra is
        nowhere in sight.

        Both directions are supported: -Sid yields the objectId, -ObjectId yields
        the SID. Both parameters accept arrays and pipeline input, so a column of
        SIDs from a log export can be piped straight in.

        -Resolve additionally asks Graph what the object is (display name, UPN,
        type), which turns the objectId into something human-readable. That is
        the only part of this command that needs a session and network access;
        without -Resolve it works fully offline.

        Expect roles, not just people. Every directory object gets a SID of this
        shape, including directoryRole objects - and on an Entra-joined device
        that is how role-based local admin works: the local Administrators group
        contains the SIDs of the Global Administrator and Azure AD Joined Device
        Local Administrator roles rather than the SIDs of the people holding
        them. So a SID harvested from local group membership resolving to
        ObjectType 'directoryRole' is the normal case, not a conversion error,
        and it means "everyone currently in that role" - a set that changes
        without the device changing. Raw.roleTemplateId is the stable, tenant-
        independent identifier for such a row; join on it to expand the role to
        its actual holders with Get-MsecEntraRoleHolder.

        Two caveats on role identity, both of which make roleTemplateId the only
        field worth branching on:

        First, the objectId a role SID decodes to is the per-tenant directoryRole
        instance id, which differs between tenants, whereas roleTemplateId is the
        same GUID everywhere. Never hard-code a role SID from one tenant into a
        detection script expected to run against another.

        Second, DisplayName is whatever the directory says, which is not always
        the name the portal shows. Graph still returns Global Administrator under
        its legacy name 'Company Administrator' (roleTemplateId
        62e90394-69f5-4237-9190-012177145e10) - so a resolved row reading
        'Company Administrator' IS Global Administrator. Matching role names as
        strings will miss it; matching roleTemplateId will not.

        Note the boundary: only S-1-12-1 SIDs encode an objectId. An on-premises
        or local SID - S-1-5-21-<domain>-<rid>, the shape you get from AD DS or
        from a local account - carries no GUID at all and cannot be converted;
        those are rejected with an explanatory error rather than silently
        producing a meaningless GUID. Hybrid users have both kinds of SID, and
        their on-prem one maps to Entra only via the onPremisesSecurityIdentifier
        property in the directory.

    .PARAMETER Sid
        One or more Entra SIDs to convert to objectIds. Must be of the form
        S-1-12-1-<a>-<b>-<c>-<d>, case-insensitive.

    .PARAMETER ObjectId
        One or more Entra objectIds (GUIDs) to convert to SIDs.

    .PARAMETER Resolve
        Look the objectId up in Graph (/v1.0/directoryObjects/{id}) and add
        DisplayName, UserPrincipalName and ObjectType to each row. Requires a
        Connect-Msec session and the 'Directory.Read.All' application permission.
        An object that no longer exists - a deleted account whose SID survives in
        an old log - resolves to ObjectType 'NotFound' rather than terminating
        the whole batch.

    .EXAMPLE
        Convert-MsecEntraSid -Sid 'S-1-12-1-2640853384-1293864314-2707107988-2394433369'

        ObjectId 9d683988-cd7a-4d1e-9430-5ba15927b88e - paste that into the Entra
        portal search, or into a Graph /users/<id> call.

    .EXAMPLE
        # Who is this, actually?
        Convert-MsecEntraSid -Sid 'S-1-12-1-2640853384-1293864314-2707107988-2394433369' -Resolve

    .EXAMPLE
        # A column of SIDs harvested from local Administrators membership.
        Get-Content .\admin-sids.txt | Convert-MsecEntraSid -Resolve |
            Format-Table Sid, ObjectId, UserPrincipalName, ObjectType

    .EXAMPLE
        # The other direction: what SID will this user appear as on a device?
        Convert-MsecEntraSid -ObjectId 9d683988-cd7a-4d1e-9430-5ba15927b88e

    .EXAMPLE
        # The SID turned out to be a directory role. Expand it to the people it
        # actually grants - that is who has local admin, not the role itself.
        $row = Convert-MsecEntraSid -Sid 'S-1-12-1-...' -Resolve
        Get-MsecEntraRoleHolder |
            Where-Object RoleTemplateId -eq $row.Raw.roleTemplateId

    .EXAMPLE
        # Cross-reference a pile of SIDs against the privileged population. PrincipalId
        # is the assignee - the object the SID names - so it is populated on every row,
        # including the group assignments where EffectiveId is $null. Match EffectiveId
        # instead to catch SIDs belonging to people who inherited a role via a group.
        $sids = Get-Content .\event-4624-sids.txt | Convert-MsecEntraSid
        Get-MsecEntraRoleHolder | Where-Object PrincipalId -in $sids.ObjectId

    .OUTPUTS
        PSCustomObject per input value, with PSTypeName 'MsecEntraSid'.

    .NOTES
        Row shape:
          Sid - the S-1-12-1 form (input or computed)
          ObjectId - [guid], the Entra objectId
          Direction - 'SidToObjectId' or 'ObjectIdToSid', which of the two the
                      caller asked for

        With -Resolve, four more:
          DisplayName - object's displayName, if any
          UserPrincipalName - for users; $null for groups, roles, service
                              principals, and devices
          ObjectType - 'user', 'group', 'directoryRole',
                              'servicePrincipal', 'device', ... derived from
                              Graph's @odata.type; 'NotFound' when the objectId
                              does not resolve
          Raw - the directoryObject verbatim, which is where the
                              type-specific fields live: roleTemplateId for a
                              directoryRole, deviceId for a device, appId for a
                              service principal. Kept out of the default table
                              view rather than flattened, so the row shape stays
                              the same whatever the SID turns out to point at.

        The encoding is documented by Microsoft as part of Entra-joined device
        behaviour, and the arithmetic is stable: [BitConverter] writes each
        sub-authority little-endian and [guid]::new([byte[]]) reads the first
        three GUID fields little-endian from the same bytes, so the two cancel out
        and a round-trip is byte-exact.
    #>

    [CmdletBinding(DefaultParameterSetName = 'FromSid')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'FromSid',
                   ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]] $Sid,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'FromObjectId',
                   ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [guid[]] $ObjectId,

        [Parameter()]
        [switch] $Resolve
    )

    begin {
        if ($Resolve) { Assert-MsecSession }

        # An Entra SID is S-1-12-1 (authority 12 = 'Azure AD') plus exactly four
        # sub-authorities, each a uint32. Anything else is a different kind of SID
        # and must not be run through the arithmetic.
        $entraSidPattern = '^S-1-12-1-(\d+)-(\d+)-(\d+)-(\d+)$'

        $sidToGuid = {
            param([string] $Value)

            $trimmed = $Value.Trim()
            $match = [regex]::Match($trimmed, $entraSidPattern, 'IgnoreCase')
            if (-not $match.Success) {
                # Name the likely case rather than just rejecting the string: a
                # S-1-5-21 SID is the single most common thing to try here, and
                # "it is the wrong sort of SID" is more actionable than "invalid".
                $hint = if ($trimmed -match '^S-1-5-21-') {
                    " That is an on-premises or local account SID (S-1-5-21-...), which does not encode an Entra objectId. Only cloud-only Entra SIDs (S-1-12-1-...) can be converted; for a hybrid user, match it against the directory's onPremisesSecurityIdentifier property instead."
                }
                else {
                    " Expected the form S-1-12-1-<a>-<b>-<c>-<d>, four unsigned 32-bit sub-authorities."
                }
                throw "'$Value' is not an Entra ID SID.$hint"
            }

            $bytes = [System.Collections.Generic.List[byte]]::new()
            foreach ($group in $match.Groups[1..4]) {
                # [uint32] on an out-of-range literal throws a cast error whose
                # message does not mention the SID, so range-check it here.
                $number = [decimal] $group.Value
                if ($number -gt [uint32]::MaxValue) {
                    throw "'$Value' is not an Entra ID SID: the sub-authority '$($group.Value)' exceeds the 32-bit maximum ($([uint32]::MaxValue))."
                }
                $bytes.AddRange([BitConverter]::GetBytes([uint32] $number))
            }

            [guid]::new($bytes.ToArray())
        }

        $guidToSid = {
            param([guid] $Value)

            $bytes = $Value.ToByteArray()
            $parts = 0..3 | ForEach-Object { [BitConverter]::ToUInt32($bytes, $_ * 4) }
            'S-1-12-1-' + ($parts -join '-')
        }

        # /directoryObjects/{id} answers for any object type - user, group, service
        # principal, device - which is what we want, because a SID from a log does
        # not say which of those it is. A 404 becomes a row, not an exception: a
        # batch of SIDs from historical logs will legitimately contain deleted
        # accounts, and losing the whole batch over one of them is useless.
        $resolveObject = {
            param([guid] $Id)

            try {
                $obj = Invoke-MsecGraphRequest -Path "/v1.0/directoryObjects/$Id"
                $type = if ($obj.'@odata.type') {
                    ($obj.'@odata.type' -replace '^#?microsoft\.graph\.', '')
                }
                else {
                    'unknown'
                }

                [PSCustomObject]@{
                    DisplayName       = $obj.displayName
                    UserPrincipalName = $obj.userPrincipalName
                    ObjectType        = $type
                    Raw               = $obj
                }
            }
            catch {
                if ($_.Exception.Message -match '404|Not Found') {
                    Write-Verbose "objectId $Id not found in the directory (deleted object, or a SID from another tenant)."
                    return [PSCustomObject]@{
                        DisplayName       = $null
                        UserPrincipalName = $null
                        ObjectType        = 'NotFound'
                        Raw               = $null
                    }
                }
                if ($_.Exception.Message -match '403|Forbidden') {
                    throw "Forbidden when calling /directoryObjects. -Resolve needs the 'Directory.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it, or drop -Resolve to convert offline. Original error: $($_.Exception.Message)"
                }
                throw
            }
        }

        $emit = {
            param([string] $SidValue, [guid] $IdValue, [string] $Direction)

            $row = [ordered]@{
                PSTypeName = 'MsecEntraSid'

                Sid        = $SidValue
                ObjectId   = $IdValue
                Direction  = $Direction
            }

            if (-not $Resolve) {
                return [PSCustomObject] $row
            }

            $info = & $resolveObject $IdValue
            $row['DisplayName']       = $info.DisplayName
            $row['UserPrincipalName'] = $info.UserPrincipalName
            $row['ObjectType']        = $info.ObjectType
            $row['Raw']               = $info.Raw

            $obj = [PSCustomObject] $row
            # Front of the type list wins the format view, so a resolved row shows
            # its resolved columns; 'MsecEntraSid' is still on the object behind it.
            $obj.PSObject.TypeNames.Insert(0, 'MsecEntraSidResolved')
            $obj
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'FromSid') {
            foreach ($value in $Sid) {
                $id = & $sidToGuid $value
                & $emit $value.Trim() $id 'SidToObjectId'
            }
        }
        else {
            foreach ($value in $ObjectId) {
                & $emit (& $guidToSid $value) $value 'ObjectIdToSid'
            }
        }
    }
}