Private/Entra/Checks/Invoke-AzureIAMChecks.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-AzureIAMChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'AzureIAMChecks'
    $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)
}

# ── AZIAM-001: Subscription Role Assignment Count ────────────────────────
function Test-InfiltrationAZIAM001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData -or -not $iamData.RoleAssignments) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $assignments = $iamData.RoleAssignments
    $subscriptions = $iamData.Subscriptions
    $totalAssignments = $assignments.Count

    # Group assignments by subscription
    $perSub = @{}
    foreach ($a in $assignments) {
        $subId = $a._subscriptionId ?? 'Unknown'
        if (-not $perSub.ContainsKey($subId)) { $perSub[$subId] = 0 }
        $perSub[$subId]++
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$totalAssignments role assignments across $($subscriptions.Count) subscription(s)" `
        -Details @{
            TotalAssignments = $totalAssignments
            SubscriptionCount = $subscriptions.Count
            AssignmentsPerSubscription = @($perSub.GetEnumerator() | ForEach-Object {
                @{ SubscriptionId = $_.Key; AssignmentCount = $_.Value }
            })
        }
}

# ── AZIAM-002: Direct Resource-Level Assignments ────────────────────────
function Test-InfiltrationAZIAM002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData -or -not $iamData.RoleAssignments) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $assignments = $iamData.RoleAssignments

    # Resource-level assignments have scopes deeper than /subscriptions/{id}
    # i.e., they contain more path segments beyond the subscription
    $resourceLevel = @($assignments | Where-Object {
        $scope = $_.properties.scope ?? ''
        # Subscription scope: /subscriptions/{guid}
        # Resource group scope: /subscriptions/{guid}/resourceGroups/{name}
        # Resource scope: /subscriptions/{guid}/resourceGroups/{name}/providers/...
        $segments = ($scope -split '/') | Where-Object { $_ }
        $segments.Count -gt 2
    })

    $directResourceAssignments = @($resourceLevel | Where-Object {
        $scope = $_.properties.scope ?? ''
        $segments = ($scope -split '/') | Where-Object { $_ }
        # More than 4 segments means it is at the individual resource level (not RG)
        $segments.Count -gt 4
    })

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

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($directResourceAssignments.Count) direct resource-level role assignments found (non-inherited)" `
        -Details @{
            DirectResourceAssignments = $directResourceAssignments.Count
            TotalNonSubscriptionScope = $resourceLevel.Count
            Samples = @($directResourceAssignments | Select-Object -First 20 | ForEach-Object {
                @{
                    PrincipalId = $_.properties.principalId
                    RoleDefinitionId = $_.properties.roleDefinitionId
                    Scope = $_.properties.scope
                }
            })
        }
}

# ── AZIAM-003: Resource Group Level Permissions ─────────────────────────
function Test-InfiltrationAZIAM003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData -or -not $iamData.RoleAssignments) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $assignments = $iamData.RoleAssignments

    # Resource group scoped assignments: /subscriptions/{id}/resourceGroups/{name}
    $rgAssignments = @($assignments | Where-Object {
        $scope = $_.properties.scope ?? ''
        $scope -match '/subscriptions/[^/]+/resourceGroups/[^/]+$'
    })

    # Group by resource group
    $rgGroups = @{}
    foreach ($a in $rgAssignments) {
        $rg = $a.properties.scope
        if (-not $rgGroups.ContainsKey($rg)) { $rgGroups[$rg] = 0 }
        $rgGroups[$rg]++
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($rgAssignments.Count) resource group-level assignments across $($rgGroups.Count) resource groups" `
        -Details @{
            RGAssignmentCount = $rgAssignments.Count
            ResourceGroupCount = $rgGroups.Count
            TopResourceGroups = @($rgGroups.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object {
                @{ ResourceGroup = $_.Key; AssignmentCount = $_.Value }
            })
        }
}

# ── AZIAM-004: Key Vault Access ─────────────────────────────────────────
function Test-InfiltrationAZIAM004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $vaults = $iamData.KeyVaults
    if (-not $vaults -or $vaults.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No Key Vaults found in scanned subscriptions' `
            -Details @{ VaultCount = 0 }
    }

    # Check vault properties
    $rbacEnabled = @($vaults | Where-Object { $_.properties.enableRbacAuthorization -eq $true })
    $softDeleteEnabled = @($vaults | Where-Object { $_.properties.enableSoftDelete -eq $true })
    $purgeProtected = @($vaults | Where-Object { $_.properties.enablePurgeProtection -eq $true })

    $status = if ($rbacEnabled.Count -eq $vaults.Count -and $purgeProtected.Count -eq $vaults.Count) { 'PASS' }
              elseif ($softDeleteEnabled.Count -eq $vaults.Count) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($vaults.Count) Key Vaults: $($rbacEnabled.Count) RBAC, $($softDeleteEnabled.Count) soft-delete, $($purgeProtected.Count) purge-protected" `
        -Details @{
            VaultCount = $vaults.Count
            RbacEnabled = $rbacEnabled.Count
            SoftDeleteEnabled = $softDeleteEnabled.Count
            PurgeProtected = $purgeProtected.Count
            Vaults = @($vaults | ForEach-Object {
                @{
                    Name = $_.name
                    Location = $_.location
                    EnableRbacAuthorization = $_.properties.enableRbacAuthorization
                    EnableSoftDelete = $_.properties.enableSoftDelete
                    EnablePurgeProtection = $_.properties.enablePurgeProtection
                }
            })
        }
}

# ── AZIAM-005: Storage Account Security ─────────────────────────────────
function Test-InfiltrationAZIAM005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $storageAccounts = $iamData.StorageAccounts
    if (-not $storageAccounts -or $storageAccounts.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No storage accounts found in scanned subscriptions' `
            -Details @{ StorageCount = 0 }
    }

    $httpsOnly = @($storageAccounts | Where-Object { $_.properties.supportsHttpsTrafficOnly -eq $true })
    $publicBlobDisabled = @($storageAccounts | Where-Object { $_.properties.allowBlobPublicAccess -ne $true })
    $tls12 = @($storageAccounts | Where-Object { $_.properties.minimumTlsVersion -eq 'TLS1_2' })

    $issues = [System.Collections.Generic.List[string]]::new()
    if ($httpsOnly.Count -ne $storageAccounts.Count) { $issues.Add("$($storageAccounts.Count - $httpsOnly.Count) allow HTTP traffic") }
    if ($publicBlobDisabled.Count -ne $storageAccounts.Count) { $issues.Add("$($storageAccounts.Count - $publicBlobDisabled.Count) allow public blob access") }
    if ($tls12.Count -ne $storageAccounts.Count) { $issues.Add("$($storageAccounts.Count - $tls12.Count) not requiring TLS 1.2") }

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

    $currentValue = if ($issues.Count -eq 0) {
        "$($storageAccounts.Count) storage accounts all enforce HTTPS, no public blob, TLS 1.2"
    } else {
        "$($storageAccounts.Count) storage accounts: $($issues -join '; ')"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            StorageCount = $storageAccounts.Count
            HttpsOnly = $httpsOnly.Count
            PublicBlobDisabled = $publicBlobDisabled.Count
            Tls12 = $tls12.Count
            Issues = @($issues)
            Accounts = @($storageAccounts | ForEach-Object {
                @{
                    Name = $_.name
                    SupportsHttpsOnly = $_.properties.supportsHttpsTrafficOnly
                    AllowBlobPublicAccess = $_.properties.allowBlobPublicAccess
                    MinimumTlsVersion = $_.properties.minimumTlsVersion
                }
            })
        }
}

# ── AZIAM-006: NSG Overly Permissive Rules ──────────────────────────────
function Test-InfiltrationAZIAM006 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $nsgs = $iamData.NetworkSecurityGroups
    if (-not $nsgs -or $nsgs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No Network Security Groups found in scanned subscriptions' `
            -Details @{ NSGCount = 0 }
    }

    $permissiveRules = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($nsg in $nsgs) {
        $rules = @($nsg.properties.securityRules)
        foreach ($rule in $rules) {
            $props = $rule.properties
            if ($props.access -ne 'Allow' -or $props.direction -ne 'Inbound') { continue }

            $isWideOpen = (
                ($props.sourceAddressPrefix -eq '*' -or $props.sourceAddressPrefix -eq '0.0.0.0/0' -or
                 $props.sourceAddressPrefix -eq 'Internet') -and
                ($props.destinationPortRange -eq '*' -or $props.destinationPortRange -eq '0-65535')
            )

            if ($isWideOpen) {
                $permissiveRules.Add([PSCustomObject]@{
                    NSGName = $nsg.name
                    RuleName = $rule.name
                    SourceAddress = $props.sourceAddressPrefix
                    DestinationPort = $props.destinationPortRange
                    Protocol = $props.protocol
                    Priority = $props.priority
                })
            }
        }
    }

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

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($permissiveRules.Count) overly permissive NSG rules (any source, any port inbound) across $($nsgs.Count) NSGs" `
        -Details @{
            NSGCount = $nsgs.Count
            PermissiveRuleCount = $permissiveRules.Count
            PermissiveRules = @($permissiveRules | ForEach-Object {
                @{
                    NSGName = $_.NSGName
                    RuleName = $_.RuleName
                    SourceAddress = $_.SourceAddress
                    DestinationPort = $_.DestinationPort
                    Protocol = $_.Protocol
                    Priority = $_.Priority
                }
            })
        }
}

# ── AZIAM-007: Azure Policy Compliance ──────────────────────────────────
function Test-InfiltrationAZIAM007 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $policyStates = $iamData.PolicyStates
    if (-not $policyStates -or $policyStates.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No Azure Policy compliance data available' `
            -Details @{ PolicyDataAvailable = $false }
    }

    $totalNonCompliant = 0
    $totalResources = 0
    $summaries = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($ps in $policyStates) {
        $summary = $ps.Summary
        if ($summary -and $summary.results) {
            $nonCompliant = $summary.results.nonCompliantResources ?? 0
            $total = $summary.results.totalResources ?? 0
            $totalNonCompliant += $nonCompliant
            $totalResources += $total
            $summaries.Add(@{
                SubscriptionId = $ps.SubscriptionId
                NonCompliantResources = $nonCompliant
                TotalResources = $total
            })
        }
    }

    $percentage = if ($totalResources -gt 0) { [Math]::Round(($totalNonCompliant / $totalResources) * 100, 1) } else { 0 }

    $status = if ($totalNonCompliant -eq 0) { 'PASS' }
              elseif ($percentage -le 10) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$totalNonCompliant non-compliant resources out of $totalResources total ($percentage%)" `
        -Details @{
            TotalNonCompliant = $totalNonCompliant
            TotalResources = $totalResources
            NonCompliancePercentage = $percentage
            PerSubscription = @($summaries)
        }
}

# ── AZIAM-008: Management Group Structure ───────────────────────────────
function Test-InfiltrationAZIAM008 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $mgGroups = $iamData.ManagementGroups
    if (-not $mgGroups -or $mgGroups.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No management groups found or accessible' `
            -Details @{ ManagementGroupCount = 0 }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($mgGroups.Count) management group(s) in hierarchy" `
        -Details @{
            ManagementGroupCount = $mgGroups.Count
            Groups = @($mgGroups | ForEach-Object {
                @{
                    Id = $_.id
                    Name = $_.name
                    DisplayName = $_.properties.displayName
                    TenantId = $_.properties.tenantId
                }
            })
        }
}

# ── AZIAM-009: Custom RBAC Role Definitions ─────────────────────────────
function Test-InfiltrationAZIAM009 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $customRoles = $iamData.RoleDefinitions
    if (-not $customRoles -or $customRoles.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No custom RBAC role definitions found' `
            -Details @{ CustomRoleCount = 0 }
    }

    # Check for overly broad custom roles (wildcard actions)
    $broadRoles = @($customRoles | Where-Object {
        $actions = @($_.properties.permissions | ForEach-Object { $_.actions }) | ForEach-Object { $_ }
        $actions -contains '*'
    })

    $status = if ($broadRoles.Count -gt 0) { 'WARN' }
              else { 'PASS' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($customRoles.Count) custom RBAC roles ($($broadRoles.Count) with wildcard actions)" `
        -Details @{
            CustomRoleCount = $customRoles.Count
            BroadRoleCount = $broadRoles.Count
            Roles = @($customRoles | ForEach-Object {
                @{
                    Id = $_.id
                    RoleName = $_.properties.roleName
                    Description = $_.properties.description
                    AssignableScopes = @($_.properties.assignableScopes)
                }
            })
        }
}

# ── AZIAM-010: Resource Locks ───────────────────────────────────────────
function Test-InfiltrationAZIAM010 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $iamData = $AuditData.AzureIAM
    if (-not $iamData) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Azure IAM data not available'
    }

    $locks = $iamData.ResourceLocks
    $subscriptions = $iamData.Subscriptions

    if (-not $locks -or $locks.Count -eq 0) {
        $status = if ($subscriptions.Count -gt 0) { 'WARN' } else { 'SKIP' }
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
            -CurrentValue 'No resource locks deployed across scanned subscriptions' `
            -Details @{
                LockCount = 0
                SubscriptionCount = $subscriptions.Count
            }
    }

    $deleteLocks = @($locks | Where-Object { $_.properties.level -eq 'CanNotDelete' })
    $readOnlyLocks = @($locks | Where-Object { $_.properties.level -eq 'ReadOnly' })

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($locks.Count) resource locks deployed ($($deleteLocks.Count) delete, $($readOnlyLocks.Count) read-only)" `
        -Details @{
            TotalLocks = $locks.Count
            DeleteLocks = $deleteLocks.Count
            ReadOnlyLocks = $readOnlyLocks.Count
            Locks = @($locks | ForEach-Object {
                @{
                    Id = $_.id
                    Name = $_.name
                    Level = $_.properties.level
                    Notes = $_.properties.notes
                }
            })
        }
}