Private/Entra/Checks/Invoke-EntraPIMChecks.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-EntraPIMChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'EntraPIMChecks'
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($check in $checkDefs.checks) {
        $funcName = "Test-Infiltration$($check.id -replace '-', '')"
        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            try {
                $finding = & $funcName -AuditData $AuditData -CheckDefinition $check
                if ($finding) { $findings.Add($finding) }
            } catch {
                $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' `
                    -CurrentValue "Check failed: $_"))
            }
        } else {
            $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' `
                -CurrentValue 'Check not yet implemented'))
        }
    }

    return @($findings)
}

# ── EIDPIM-001: Global Administrator Enumeration ─────────────────────────
function Test-InfiltrationEIDPIM001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $globalAdmins = $AuditData.PIM.GlobalAdmins
    if (-not $globalAdmins) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Global Administrator data not available'
    }

    $count = $globalAdmins.Count
    $status = if ($count -ge 2 -and $count -le 4) { 'PASS' }
              elseif ($count -eq 1 -or $count -eq 5) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$count Global Administrators (recommended: 2-4)" `
        -Details @{
            Count  = $count
            Admins = @($globalAdmins | ForEach-Object {
                @{
                    Id                = $_.id
                    DisplayName       = $_.displayName
                    UserPrincipalName = $_.userPrincipalName
                    UserType          = $_.userType
                    AccountEnabled    = $_.accountEnabled
                }
            })
        }
}

# ── EIDPIM-002: All Privileged Role Assignments ─────────────────────────
function Test-InfiltrationEIDPIM002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $activeAssignments = $AuditData.PIM.RoleAssignments
    $eligibleAssignments = $AuditData.PIM.RoleEligibilitySchedules
    $roleDefinitions = $AuditData.PIM.RoleDefinitions

    $roleLookup = @{}
    foreach ($rd in $roleDefinitions) {
        $roleLookup[$rd.id] = $rd.displayName
    }

    $permanentCount = $activeAssignments.Count
    $eligibleCount = $eligibleAssignments.Count

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$permanentCount active assignments, $eligibleCount eligible assignments" `
        -Details @{
            ActiveCount   = $permanentCount
            EligibleCount = $eligibleCount
            ActiveAssignments = @($activeAssignments | Select-Object -First 100 | ForEach-Object {
                @{
                    PrincipalId    = $_.principalId
                    RoleId         = $_.roleDefinitionId
                    RoleName       = $roleLookup[$_.roleDefinitionId] ?? 'Unknown'
                    DirectoryScope = $_.directoryScopeId
                }
            })
        }
}

# ── EIDPIM-003: Permanent Privileged Assignments ────────────────────────
function Test-InfiltrationEIDPIM003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $activeAssignments = $AuditData.PIM.RoleAssignments
    $eligibleAssignments = $AuditData.PIM.RoleEligibilitySchedules
    $roleDefinitions = $AuditData.PIM.RoleDefinitions

    # Privileged role template IDs
    $privilegedRoleIds = @(
        '62e90394-69f5-4237-9190-012177145e10'  # Global Administrator
        'e8611ab8-c189-46e8-94e1-60213ab1f814'  # Privileged Role Administrator
        '194ae4cb-b126-40b2-bd5b-6091b380977d'  # Security Administrator
        '7be44c8a-adaf-4e2a-84d6-ab2649e08a13'  # Privileged Authentication Admin
        'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9'  # Conditional Access Admin
    )

    $roleLookup = @{}
    foreach ($rd in $roleDefinitions) { $roleLookup[$rd.id] = $rd.displayName }

    # Active (permanent) assignments to privileged roles
    $permanentPrivileged = @($activeAssignments | Where-Object {
        $_.roleDefinitionId -in $privilegedRoleIds
    })

    # Eligible assignments to same roles
    $eligibleIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($e in $eligibleAssignments) {
        if ($e.roleDefinitionId -in $privilegedRoleIds) {
            [void]$eligibleIds.Add("$($e.principalId)|$($e.roleDefinitionId)")
        }
    }

    # Permanent assignments that should be eligible
    $shouldBeEligible = @($permanentPrivileged | Where-Object {
        -not $eligibleIds.Contains("$($_.principalId)|$($_.roleDefinitionId)")
    })

    $status = if ($shouldBeEligible.Count -eq 0) { 'PASS' }
              elseif ($shouldBeEligible.Count -le 2) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($shouldBeEligible.Count) permanent privileged assignments should be converted to eligible (JIT)" `
        -Details @{
            PermanentPrivilegedCount = $permanentPrivileged.Count
            ShouldBeEligibleCount    = $shouldBeEligible.Count
            Assignments              = @($shouldBeEligible | ForEach-Object {
                @{
                    PrincipalId = $_.principalId
                    RoleName    = $roleLookup[$_.roleDefinitionId] ?? $_.roleDefinitionId
                }
            })
        }
}

# ── EIDPIM-004: Guest Users in Privileged Roles ─────────────────────────
function Test-InfiltrationEIDPIM004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $privilegedUsers) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user details not available'
    }

    $guestAdmins = @($privilegedUsers | Where-Object { $_.userType -eq 'Guest' })

    $status = if ($guestAdmins.Count -eq 0) { 'PASS' } else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($guestAdmins.Count) guest/external users have privileged role assignments" `
        -Details @{
            GuestAdminCount = $guestAdmins.Count
            GuestAdmins     = @($guestAdmins | ForEach-Object {
                @{ Id = $_.id; DisplayName = $_.displayName; UserPrincipalName = $_.userPrincipalName }
            })
        }
}

# ── EIDPIM-005: Synced Accounts in Privileged Roles ─────────────────────
function Test-InfiltrationEIDPIM005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $privilegedUsers) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user details not available'
    }

    $syncedAdmins = @($privilegedUsers | Where-Object { $_.onPremisesSyncEnabled -eq $true })

    $status = if ($syncedAdmins.Count -eq 0) { 'PASS' }
              elseif ($syncedAdmins.Count -le 2) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($syncedAdmins.Count) on-premises synced accounts have privileged cloud roles" `
        -Details @{
            SyncedAdminCount = $syncedAdmins.Count
            SyncedAdmins     = @($syncedAdmins | ForEach-Object {
                @{ Id = $_.id; DisplayName = $_.displayName; UserPrincipalName = $_.userPrincipalName }
            })
        }
}

# ── EIDPIM-006: Privileged Users Without MFA ────────────────────────────
function Test-InfiltrationEIDPIM006 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    $registrationDetails = $AuditData.AuthMethods.UserRegistrationDetails
    if (-not $privilegedUsers -or -not $registrationDetails) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user or MFA registration data not available'
    }

    $mfaLookup = @{}
    foreach ($reg in $registrationDetails) {
        if ($reg.id) { $mfaLookup[$reg.id] = $reg }
    }

    $noMfaAdmins = @($privilegedUsers | Where-Object {
        $_.accountEnabled -eq $true -and
        (-not $mfaLookup.ContainsKey($_.id) -or $mfaLookup[$_.id].isMfaRegistered -ne $true)
    })

    $status = if ($noMfaAdmins.Count -eq 0) { 'PASS' } else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($noMfaAdmins.Count) privileged users without MFA registered" `
        -Details @{
            NoMfaAdminCount = $noMfaAdmins.Count
            Users           = @($noMfaAdmins | ForEach-Object {
                @{ Id = $_.id; DisplayName = $_.displayName; UserPrincipalName = $_.userPrincipalName }
            })
        }
}

# ── EIDPIM-007: Privileged Users with Weak Auth Methods ─────────────────
function Test-InfiltrationEIDPIM007 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    $registrationDetails = $AuditData.AuthMethods.UserRegistrationDetails
    if (-not $privilegedUsers -or -not $registrationDetails) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user or MFA registration data not available'
    }

    $strongMethods = @('microsoftAuthenticatorPush', 'softwareOneTimePasscode',
        'hardwareOneTimePasscode', 'microsoftAuthenticatorPasswordless',
        'fido2', 'windowsHelloForBusiness', 'passKeyDeviceBound')

    $mfaLookup = @{}
    foreach ($reg in $registrationDetails) {
        if ($reg.id) { $mfaLookup[$reg.id] = $reg }
    }

    $weakAuthAdmins = @($privilegedUsers | Where-Object {
        $_.accountEnabled -eq $true -and
        $mfaLookup.ContainsKey($_.id) -and
        $mfaLookup[$_.id].isMfaRegistered -eq $true -and
        ($mfaLookup[$_.id].methodsRegistered | Where-Object { $_ -in $strongMethods }).Count -eq 0
    })

    $status = if ($weakAuthAdmins.Count -eq 0) { 'PASS' } else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($weakAuthAdmins.Count) privileged users rely only on weak MFA methods (SMS/voice)" `
        -Details @{
            WeakAuthAdminCount = $weakAuthAdmins.Count
            Users              = @($weakAuthAdmins | ForEach-Object {
                @{
                    Id                = $_.id
                    DisplayName       = $_.displayName
                    Methods           = @($mfaLookup[$_.id].methodsRegistered)
                }
            })
        }
}

# ── EIDPIM-008: Disabled Accounts in Privileged Roles ───────────────────
function Test-InfiltrationEIDPIM008 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $privilegedUsers) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user details not available'
    }

    $disabledAdmins = @($privilegedUsers | Where-Object { $_.accountEnabled -eq $false })

    $status = if ($disabledAdmins.Count -eq 0) { 'PASS' } else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($disabledAdmins.Count) disabled accounts still hold privileged role assignments" `
        -Details @{
            DisabledAdminCount = $disabledAdmins.Count
            Users              = @($disabledAdmins | ForEach-Object {
                @{ Id = $_.id; DisplayName = $_.displayName; UserPrincipalName = $_.userPrincipalName }
            })
        }
}

# ── EIDPIM-009: Never-Signed-In Privileged Accounts ─────────────────────
function Test-InfiltrationEIDPIM009 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $privilegedUsers) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user details not available'
    }

    $neverSignedIn = @($privilegedUsers | Where-Object {
        $_.accountEnabled -eq $true -and
        (-not $_.signInActivity -or -not $_.signInActivity.lastSignInDateTime)
    })

    $status = if ($neverSignedIn.Count -eq 0) { 'PASS' }
              elseif ($neverSignedIn.Count -le 2) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($neverSignedIn.Count) privileged accounts have never signed in" `
        -Details @{
            NeverSignedInCount = $neverSignedIn.Count
            Users              = @($neverSignedIn | ForEach-Object {
                @{ Id = $_.id; DisplayName = $_.displayName; CreatedDateTime = $_.createdDateTime }
            })
        }
}

# ── EIDPIM-010: PIM Configuration Audit ─────────────────────────────────
function Test-InfiltrationEIDPIM010 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $roleAssignmentSchedules = $AuditData.PIM.RoleAssignmentSchedules
    $eligibilitySchedules = $AuditData.PIM.RoleEligibilitySchedules

    $hasPIM = ($eligibilitySchedules -and $eligibilitySchedules.Count -gt 0)

    if (-not $hasPIM) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue 'PIM does not appear to be configured — no eligible role assignments found' `
            -Details @{ PIMConfigured = $false }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "PIM configured with $($eligibilitySchedules.Count) eligible role assignments" `
        -Details @{
            PIMConfigured            = $true
            EligibleAssignmentCount  = $eligibilitySchedules.Count
            ActiveScheduleCount      = $roleAssignmentSchedules.Count
        }
}

# ── EIDPIM-011: PIM Activation History ──────────────────────────────────
function Test-InfiltrationEIDPIM011 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $scheduleInstances = $AuditData.PIM.RoleAssignmentSchedules
    if (-not $scheduleInstances) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'PIM activation data not available'
    }

    $activeActivations = @($scheduleInstances | Where-Object {
        $_.assignmentType -eq 'Activated'
    })

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($activeActivations.Count) currently active PIM activations out of $($scheduleInstances.Count) schedule instances" `
        -Details @{
            ActiveActivations    = $activeActivations.Count
            TotalScheduleInstances = $scheduleInstances.Count
        }
}

# ── EIDPIM-012: Break-Glass Account Validation ─────────────────────────
function Test-InfiltrationEIDPIM012 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $globalAdmins = $AuditData.PIM.GlobalAdmins
    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $globalAdmins -or $globalAdmins.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue 'No Global Administrators found — cannot validate break-glass accounts'
    }

    # Heuristic: break-glass accounts are often cloud-only, with specific naming patterns
    $breakGlassPatterns = @('breakglass', 'break-glass', 'emergency', 'bg-', 'bg_')
    $cloudOnlyGAs = @($globalAdmins | Where-Object { $_.onPremisesSyncEnabled -ne $true })
    $potentialBG = @($cloudOnlyGAs | Where-Object {
        $name = ($_.displayName ?? '').ToLower()
        $upn = ($_.userPrincipalName ?? '').ToLower()
        $breakGlassPatterns | Where-Object { $name -match $_ -or $upn -match $_ }
    })

    # Also look for GA accounts excluded from most CA policies (from CA data)
    $status = if ($potentialBG.Count -ge 2) { 'PASS' }
              elseif ($potentialBG.Count -eq 1) { 'WARN' }
              else { 'WARN' }

    $currentValue = if ($potentialBG.Count -ge 2) {
        "$($potentialBG.Count) potential break-glass accounts identified"
    } elseif ($potentialBG.Count -eq 1) {
        '1 potential break-glass account found — recommend at least 2'
    } else {
        'No accounts matching break-glass naming patterns found — verify emergency access accounts exist'
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            PotentialBreakGlassCount = $potentialBG.Count
            CloudOnlyGACount         = $cloudOnlyGAs.Count
            TotalGACount             = $globalAdmins.Count
        }
}

# ── EIDPIM-013: Separate Admin Account Enforcement ─────────────────────
function Test-InfiltrationEIDPIM013 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $privilegedUsers = $AuditData.PIM.PrivilegedUsers
    if (-not $privilegedUsers) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Privileged user details not available'
    }

    # Heuristic: admin accounts should have naming convention (admin, adm, -a, .admin)
    $adminPatterns = @('admin', 'adm-', 'adm_', '-adm', '_adm', '.admin', '-a@', '_a@')
    $adminAccounts = @($privilegedUsers | Where-Object {
        $upn = ($_.userPrincipalName ?? '').ToLower()
        $name = ($_.displayName ?? '').ToLower()
        ($adminPatterns | Where-Object { $upn -match $_ -or $name -match $_ }).Count -gt 0
    })

    $nonAdminPattern = $privilegedUsers.Count - $adminAccounts.Count
    $percentage = if ($privilegedUsers.Count -gt 0) {
        [Math]::Round(($adminAccounts.Count / $privilegedUsers.Count) * 100, 1)
    } else { 0 }

    $status = if ($percentage -ge 80) { 'PASS' }
              elseif ($percentage -ge 50) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($adminAccounts.Count) of $($privilegedUsers.Count) privileged accounts ($percentage%) follow admin naming convention" `
        -Details @{
            AdminPatternCount    = $adminAccounts.Count
            NonAdminPatternCount = $nonAdminPattern
            TotalPrivileged      = $privilegedUsers.Count
            Percentage           = $percentage
        }
}

# ── EIDPIM-014: Role Assignment Notification Settings ───────────────────
function Test-InfiltrationEIDPIM014 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    # PIM notification settings require roleManagement/directory/roleAssignmentApprovals
    # and specific PIM policy endpoints that may need beta API
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
        -CurrentValue 'PIM notification settings audit requires PIM-specific policy endpoints (beta API)'
}