Private/Entra/Checks/Invoke-EntraGovernanceChecks.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
#
# Entra ID Governance dispatcher + checks (entitlement-management access packages).
# Collector: Get-EntraGovernanceData -> $AuditData.Governance. A failed collection is
# Not Assessed via Get-NotAssessedFinding; an empty-but-collected result means
# entitlement management is not in use (nothing to govern) -> PASS, never a false FAIL.
function Invoke-EntraGovernanceChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'EntraGovernanceChecks'
    $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 {
                Write-Warning "Check $($check.id) failed: $($_.Exception.Message)"
            }
        }
    }

    return @($findings)
}

# Shared Not-Assessed guard for the governance checks: a failed collection of the
# assignment policies (or the Governance source as a whole) is Not Assessed.
function Get-GovernanceNaFinding {
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string[]]$SourceKey)
    Get-NotAssessedFinding -CheckDefinition $CheckDefinition `
        -ErrorMap @($AuditData.Errors, $AuditData.Governance.Errors) `
        -SourceKey $SourceKey -Subject 'Entra ID Governance entitlement-management data'
}

# ── EIDGOV-001: Assignment policies require approval ──────────────────────
function Test-InfiltrationEIDGOV001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $na = Get-GovernanceNaFinding -AuditData $AuditData -CheckDefinition $CheckDefinition `
        -SourceKey @('Governance', 'AssignmentPolicies')
    if ($na) { return $na }

    $policies = @($AuditData.Governance.AssignmentPolicies)
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No entitlement-management assignment policies (entitlement management not in use)' `
            -Details @{ PolicyCount = 0 }
    }

    $noApproval = @($policies | Where-Object { $_.requestApprovalSettings.isApprovalRequired -ne $true })
    $status = if ($noApproval.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "All $($policies.Count) access-package assignment policies require approval"
    } else {
        "$($noApproval.Count) of $($policies.Count) assignment policies grant access without approval — review"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            PolicyCount      = $policies.Count
            NoApprovalCount  = $noApproval.Count
            NoApprovalPolicies = @($noApproval | ForEach-Object { $_.displayName })
        }
}

# ── EIDGOV-002: Assignment policies enforce access reviews ────────────────
function Test-InfiltrationEIDGOV002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $na = Get-GovernanceNaFinding -AuditData $AuditData -CheckDefinition $CheckDefinition `
        -SourceKey @('Governance', 'AssignmentPolicies')
    if ($na) { return $na }

    $policies = @($AuditData.Governance.AssignmentPolicies)
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No entitlement-management assignment policies (entitlement management not in use)' `
            -Details @{ PolicyCount = 0 }
    }

    $noReview = @($policies | Where-Object { $_.reviewSettings.isEnabled -ne $true })
    $status = if ($noReview.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "All $($policies.Count) assignment policies have access reviews enabled"
    } else {
        "$($noReview.Count) of $($policies.Count) assignment policies have no access reviews — standing access is never re-justified"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            PolicyCount   = $policies.Count
            NoReviewCount = $noReview.Count
            NoReviewPolicies = @($noReview | ForEach-Object { $_.displayName })
        }
}

# ── EIDGOV-003: Assignments are time-bound ───────────────────────────────
function Test-InfiltrationEIDGOV003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $na = Get-GovernanceNaFinding -AuditData $AuditData -CheckDefinition $CheckDefinition `
        -SourceKey @('Governance', 'AssignmentPolicies')
    if ($na) { return $na }

    $policies = @($AuditData.Governance.AssignmentPolicies)
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No entitlement-management assignment policies (entitlement management not in use)' `
            -Details @{ PolicyCount = 0 }
    }

    # expiration.type 'noExpiration' (or an absent expiration) = perpetual access.
    $perpetual = @($policies | Where-Object {
        $t = $_.expiration.type
        $null -eq $t -or $t -eq 'noExpiration'
    })
    $status = if ($perpetual.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "All $($policies.Count) assignment policies expire assignments (time-bound)"
    } else {
        "$($perpetual.Count) of $($policies.Count) assignment policies grant perpetual (never-expiring) access"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            PolicyCount    = $policies.Count
            PerpetualCount = $perpetual.Count
            PerpetualPolicies = @($perpetual | ForEach-Object { $_.displayName })
        }
}

# ── EIDGOV-004: External eligibility is controlled ───────────────────────
function Test-InfiltrationEIDGOV004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $na = Get-GovernanceNaFinding -AuditData $AuditData -CheckDefinition $CheckDefinition `
        -SourceKey @('Governance', 'AssignmentPolicies')
    if ($na) { return $na }

    $policies = @($AuditData.Governance.AssignmentPolicies)
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No entitlement-management assignment policies (entitlement management not in use)' `
            -Details @{ PolicyCount = 0 }
    }

    # allowedTargetScope values that include outside-the-tenant users.
    $externalScopes = @('allExternalUsers', 'allConfiguredConnectedOrganizationUsers', 'allMemberUsers')
    $external = @($policies | Where-Object { $externalScopes -contains $_.allowedTargetScope })
    # The dangerous case: external eligibility AND no approval gate.
    $externalNoApproval = @($external | Where-Object { $_.requestApprovalSettings.isApprovalRequired -ne $true })

    if ($externalNoApproval.Count -gt 0) {
        $status = 'FAIL'
        $cv = "$($externalNoApproval.Count) of $($policies.Count) assignment policies allow external/all-users eligibility WITHOUT approval"
    } elseif ($external.Count -gt 0) {
        $status = 'WARN'
        $cv = "$($external.Count) of $($policies.Count) assignment policies allow external eligibility (approval-gated) — confirm scope"
    } else {
        $status = 'PASS'
        $cv = "All $($policies.Count) assignment policies are scoped to internal/specific users"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            PolicyCount             = $policies.Count
            ExternalCount           = $external.Count
            ExternalNoApprovalCount = $externalNoApproval.Count
            ExternalNoApprovalPolicies = @($externalNoApproval | ForEach-Object { $_.displayName })
        }
}

# ── EIDGOV-005: Catalog external visibility reviewed ─────────────────────
function Test-InfiltrationEIDGOV005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $na = Get-GovernanceNaFinding -AuditData $AuditData -CheckDefinition $CheckDefinition `
        -SourceKey @('Governance', 'Catalogs')
    if ($na) { return $na }

    $catalogs = @($AuditData.Governance.Catalogs)
    if ($catalogs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No entitlement-management catalogs (entitlement management not in use)' `
            -Details @{ CatalogCount = 0 }
    }

    $externallyVisible = @($catalogs | Where-Object { $_.isExternallyVisible -eq $true })
    $status = if ($externallyVisible.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "None of $($catalogs.Count) catalogs are externally visible"
    } else {
        "$($externallyVisible.Count) of $($catalogs.Count) catalogs are externally visible — confirm each is intended for B2B collaboration"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            CatalogCount            = $catalogs.Count
            ExternallyVisibleCount  = $externallyVisible.Count
            ExternallyVisibleCatalogs = @($externallyVisible | ForEach-Object { $_.displayName })
        }
}