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

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

# ── EIDFED-001: Domain Enumeration ───────────────────────────────────────
function Test-InfiltrationEIDFED001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $domains = $AuditData.Federation.Domains
    if (-not $domains -or $domains.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No domain data available' `
            -Details @{ DomainCount = 0 }
    }

    $managed = @($domains | Where-Object { $_.authenticationType -eq 'Managed' })
    $federated = @($domains | Where-Object { $_.authenticationType -eq 'Federated' })
    $verified = @($domains | Where-Object { $_.isVerified -eq $true })
    $unverified = @($domains | Where-Object { $_.isVerified -ne $true })
    $defaultDomain = @($domains | Where-Object { $_.isDefault -eq $true })

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($domains.Count) domains: $($managed.Count) managed, $($federated.Count) federated, $($verified.Count) verified, $($unverified.Count) unverified" `
        -Details @{
            TotalDomains    = $domains.Count
            ManagedCount    = $managed.Count
            FederatedCount  = $federated.Count
            VerifiedCount   = $verified.Count
            UnverifiedCount = $unverified.Count
            DefaultDomain   = if ($defaultDomain.Count -gt 0) { $defaultDomain[0].id } else { 'None' }
            Domains         = @($domains | ForEach-Object {
                @{
                    Id                  = $_.id
                    AuthenticationType  = $_.authenticationType
                    IsVerified          = $_.isVerified
                    IsDefault           = $_.isDefault
                    IsAdminManaged      = $_.isAdminManaged
                    SupportedServices   = @($_.supportedServices ?? @())
                }
            })
        }
}

# ── EIDFED-002: Federation Certificate Validity ─────────────────────────
function Test-InfiltrationEIDFED002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $fedConfigs = $AuditData.Federation.FederationConfigs
    if (-not $fedConfigs -or $fedConfigs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No federated domains configured — no federation certificates to validate' `
            -Details @{ FederatedDomainCount = 0 }
    }

    $now = [datetime]::UtcNow
    $thirtyDaysFromNow = $now.AddDays(30)
    $certIssues = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($fedConfig in $fedConfigs) {
        $config = $fedConfig.Config
        if (-not $config) { continue }

        # Handle both single config and array of configs
        $configs = if ($config -is [array]) { $config } else { @($config) }

        foreach ($cfg in $configs) {
            $signingCert = $cfg.signingCertificate
            if (-not $signingCert) { continue }

            # Try to extract certificate validity from base64 encoded cert
            try {
                $certBytes = [Convert]::FromBase64String($signingCert)
                $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes)
                $notAfter = $cert.NotAfter.ToUniversalTime()
                $notBefore = $cert.NotBefore.ToUniversalTime()

                if ($notAfter -lt $now) {
                    $certIssues.Add(@{
                        Domain   = $fedConfig.DomainName
                        Issue    = 'Expired'
                        NotAfter = $notAfter.ToString('o')
                        Subject  = $cert.Subject
                    })
                } elseif ($notAfter -le $thirtyDaysFromNow) {
                    $certIssues.Add(@{
                        Domain   = $fedConfig.DomainName
                        Issue    = 'ExpiringSoon'
                        NotAfter = $notAfter.ToString('o')
                        DaysLeft = [Math]::Ceiling(($notAfter - $now).TotalDays)
                        Subject  = $cert.Subject
                    })
                }
            } catch {
                $certIssues.Add(@{
                    Domain = $fedConfig.DomainName
                    Issue  = 'ParseError'
                    Error  = $_.Exception.Message
                })
            }
        }
    }

    if ($certIssues.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue "Federation certificates valid across $($fedConfigs.Count) federated domain(s)" `
            -Details @{ FederatedDomainCount = $fedConfigs.Count; CertIssueCount = 0 }
    }

    $expired = @($certIssues | Where-Object { $_.Issue -eq 'Expired' })
    $expiring = @($certIssues | Where-Object { $_.Issue -eq 'ExpiringSoon' })
    $status = if ($expired.Count -gt 0) { 'FAIL' } else { 'WARN' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($expired.Count) expired, $($expiring.Count) expiring soon across $($fedConfigs.Count) federated domain(s)" `
        -Details @{
            FederatedDomainCount = $fedConfigs.Count
            CertIssueCount       = $certIssues.Count
            ExpiredCount         = $expired.Count
            ExpiringCount        = $expiring.Count
            Issues               = @($certIssues)
        }
}

# ── EIDFED-003: Federation Certificate Issuer/Subject Mismatch ──────────
function Test-InfiltrationEIDFED003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $fedConfigs = $AuditData.Federation.FederationConfigs
    if (-not $fedConfigs -or $fedConfigs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No federated domains — certificate issuer check not applicable' `
            -Details @{ FederatedDomainCount = 0 }
    }

    $mismatches = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($fedConfig in $fedConfigs) {
        $config = $fedConfig.Config
        if (-not $config) { continue }

        $configs = if ($config -is [array]) { $config } else { @($config) }

        foreach ($cfg in $configs) {
            $signingCert = $cfg.signingCertificate
            if (-not $signingCert) { continue }

            try {
                $certBytes = [Convert]::FromBase64String($signingCert)
                $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes)

                # Self-signed certs are normal for AD FS; external CA certs may indicate tampering
                $isSelfSigned = $cert.Subject -eq $cert.Issuer

                # Check for suspicious issuers (not typical AD FS self-signed patterns)
                $suspiciousIssuer = -not $isSelfSigned -and
                    $cert.Issuer -notmatch 'ADFS|AD FS|Federation|Microsoft' -and
                    $cert.Issuer -notmatch 'DigiCert|Entrust|GlobalSign|Comodo|Let''s Encrypt'

                if ($suspiciousIssuer) {
                    $mismatches.Add(@{
                        Domain    = $fedConfig.DomainName
                        Subject   = $cert.Subject
                        Issuer    = $cert.Issuer
                        Thumbprint = $cert.Thumbprint
                    })
                }
            } catch {
                # Certificate parse errors are handled in EIDFED-002
            }
        }
    }

    if ($mismatches.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No suspicious federation certificate issuer/subject mismatches found' `
            -Details @{ MismatchCount = 0 }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue "$($mismatches.Count) federation certificate(s) with suspicious issuer — possible token-signing certificate replacement attack" `
        -Details @{
            MismatchCount = $mismatches.Count
            Mismatches    = @($mismatches)
        }
}

# ── EIDFED-004: Federation Metadata Analysis ────────────────────────────
function Test-InfiltrationEIDFED004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $fedConfigs = $AuditData.Federation.FederationConfigs
    if (-not $fedConfigs -or $fedConfigs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No federated domains — federation metadata analysis not applicable'
    }

    $findings = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($fedConfig in $fedConfigs) {
        $config = $fedConfig.Config
        if (-not $config) { continue }

        $configs = if ($config -is [array]) { $config } else { @($config) }

        foreach ($cfg in $configs) {
            $detail = @{
                Domain                 = $fedConfig.DomainName
                IssuerUri              = $cfg.issuerUri
                PassiveSignInUri       = $cfg.passiveSignInUri
                MetadataExchangeUri    = $cfg.metadataExchangeUri
                ActiveSignInUri        = $cfg.activeSignInUri
                SignOutUri             = $cfg.signOutUri
                FederatedIdpMfaBehavior = $cfg.federatedIdpMfaBehavior
                PreferredAuthenticationProtocol = $cfg.preferredAuthenticationProtocol
                PromptLoginBehavior    = $cfg.promptLoginBehavior
            }
            $findings.Add($detail)
        }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "Analyzed federation metadata for $($findings.Count) configuration(s) across $($fedConfigs.Count) domain(s)" `
        -Details @{
            ConfigurationCount = $findings.Count
            DomainCount        = $fedConfigs.Count
            Configurations     = @($findings)
        }
}

# ── EIDFED-005: Azure AD Connect Configuration ──────────────────────────
function Test-InfiltrationEIDFED005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $syncSettings = $AuditData.Federation.OnPremisesSyncSettings
    if (-not $syncSettings) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No on-premises synchronization configured — cloud-only tenant' `
            -Details @{ SyncConfigured = $false }
    }

    # Handle both single object and array (value wrapper)
    $settings = if ($syncSettings.value) { $syncSettings.value } else { @($syncSettings) }
    $config = if ($settings -is [array] -and $settings.Count -gt 0) { $settings[0] } else { $settings }

    $features = $config.features
    $configDetails = @{
        SyncConfigured           = $true
        PasswordHashSyncEnabled  = $features.passwordHashSyncEnabled ?? $false
        PassthroughAuthEnabled   = $features.passThroughAuthenticationEnabled ?? $false
        SeamlessSsoEnabled       = $features.seamlessSingleSignOnEnabled ?? $false
        SyncFrequencyInMinutes   = $config.configuration.synchronizationInterval
        DirectoryExtensionsEnabled = $features.directoryExtensionsEnabled ?? $false
        GroupWritebackEnabled    = $features.groupWriteBackEnabled ?? $false
        UserWritebackEnabled     = $features.userWritebackEnabled ?? $false
        DeviceWritebackEnabled   = $features.deviceWritebackEnabled ?? $false
    }

    $status = 'PASS'
    $issues = [System.Collections.Generic.List[string]]::new()

    if (-not $configDetails.PasswordHashSyncEnabled -and -not $configDetails.PassthroughAuthEnabled) {
        $issues.Add('Neither PHS nor PTA is enabled')
        $status = 'WARN'
    }

    $currentValue = "Azure AD Connect configured. PHS: $($configDetails.PasswordHashSyncEnabled), PTA: $($configDetails.PassthroughAuthEnabled), Seamless SSO: $($configDetails.SeamlessSsoEnabled)"
    if ($issues.Count -gt 0) {
        $currentValue += ". Issues: $($issues -join '; ')"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details $configDetails
}

# ── EIDFED-006: Synchronization Scope ────────────────────────────────────
function Test-InfiltrationEIDFED006 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    # Detailed sync scope (OU filtering, connector details) requires
    # direct access to Azure AD Connect server configuration or advanced API calls
    $syncSettings = $AuditData.Federation.OnPremisesSyncSettings
    if (-not $syncSettings) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No on-premises synchronization configured — sync scope check not applicable'
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
        -CurrentValue 'Sync scope analysis requires detailed connector configuration data from the Azure AD Connect server'
}

# ── EIDFED-007: Password Hash Synchronization Status ─────────────────────
function Test-InfiltrationEIDFED007 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $syncSettings = $AuditData.Federation.OnPremisesSyncSettings
    if (-not $syncSettings) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No on-premises synchronization configured — PHS check not applicable'
    }

    $settings = if ($syncSettings.value) { $syncSettings.value } else { @($syncSettings) }
    $config = if ($settings -is [array] -and $settings.Count -gt 0) { $settings[0] } else { $settings }

    $phsEnabled = $config.features.passwordHashSyncEnabled ?? $false

    if ($phsEnabled) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'Password hash synchronization is enabled — provides backup authentication and leaked credential detection' `
            -Details @{ PasswordHashSyncEnabled = $true }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue 'Password hash synchronization is disabled — no backup authentication if federation fails, and no leaked credential detection' `
        -Details @{ PasswordHashSyncEnabled = $false }
}

# ── EIDFED-008: Pass-Through Authentication Agent Status ────────────────
function Test-InfiltrationEIDFED008 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $syncSettings = $AuditData.Federation.OnPremisesSyncSettings
    if (-not $syncSettings) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No on-premises synchronization configured — PTA check not applicable'
    }

    $settings = if ($syncSettings.value) { $syncSettings.value } else { @($syncSettings) }
    $config = if ($settings -is [array] -and $settings.Count -gt 0) { $settings[0] } else { $settings }

    $ptaEnabled = $config.features.passThroughAuthenticationEnabled ?? $false

    if (-not $ptaEnabled) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'Pass-through authentication is not enabled (PHS or federation in use)' `
            -Details @{ PassThroughAuthEnabled = $false }
    }

    # PTA is enabled; we can note it but detailed agent health requires additional API calls
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue 'Pass-through authentication is enabled — verify multiple PTA agents are deployed for redundancy' `
        -Details @{
            PassThroughAuthEnabled = $true
            Note                   = 'PTA agent health and count verification requires publishingProfiles API or Azure Portal check'
        }
}

# ── EIDFED-009: AD FS Configuration Assessment ──────────────────────────
function Test-InfiltrationEIDFED009 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $fedConfigs = $AuditData.Federation.FederationConfigs
    if (-not $fedConfigs -or $fedConfigs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No federated domains — AD FS assessment not applicable'
    }

    $issues = [System.Collections.Generic.List[string]]::new()
    $domainDetails = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($fedConfig in $fedConfigs) {
        $config = $fedConfig.Config
        if (-not $config) { continue }

        $configs = if ($config -is [array]) { $config } else { @($config) }

        foreach ($cfg in $configs) {
            $detail = @{ Domain = $fedConfig.DomainName }

            # Check if MFA behavior is properly configured
            if ($cfg.federatedIdpMfaBehavior -eq 'acceptIfMfaDoneByFederatedIdp') {
                $detail['MfaBehavior'] = $cfg.federatedIdpMfaBehavior
            } elseif (-not $cfg.federatedIdpMfaBehavior) {
                $issues.Add("$($fedConfig.DomainName): No federated IdP MFA behavior configured")
                $detail['MfaBehavior'] = 'Not configured'
            } else {
                $detail['MfaBehavior'] = $cfg.federatedIdpMfaBehavior
            }

            # Check preferred protocol
            $detail['PreferredProtocol'] = $cfg.preferredAuthenticationProtocol ?? 'Not specified'

            # Check prompt login behavior
            $detail['PromptLoginBehavior'] = $cfg.promptLoginBehavior ?? 'Not specified'

            $domainDetails.Add($detail)
        }
    }

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

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($fedConfigs.Count) federated domain(s) assessed, $($issues.Count) configuration issue(s)" `
        -Details @{
            FederatedDomainCount = $fedConfigs.Count
            IssueCount           = $issues.Count
            Issues               = @($issues)
            DomainDetails        = @($domainDetails)
        }
}

# ── EIDFED-010: AD FS Extranet Lockout Settings ─────────────────────────
function Test-InfiltrationEIDFED010 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $fedConfigs = $AuditData.Federation.FederationConfigs
    if (-not $fedConfigs -or $fedConfigs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No federated domains — extranet lockout check not applicable'
    }

    # Extranet lockout settings are typically configured directly on AD FS servers
    # and are not exposed through the Graph API federation configuration endpoints.
    # We can flag this as a review item for federated tenants.

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue "$($fedConfigs.Count) federated domain(s) detected — verify AD FS extranet lockout is configured on AD FS servers" `
        -Details @{
            FederatedDomainCount = $fedConfigs.Count
            Domains              = @($fedConfigs | ForEach-Object { $_.DomainName })
            Note                 = 'Extranet lockout settings must be verified directly on AD FS servers (Get-AdfsProperties). Recommend Extranet Smart Lockout be enabled.'
        }
}

# ── EIDFED-011: Hybrid Join Assessment ───────────────────────────────────
function Test-InfiltrationEIDFED011 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $syncSettings = $AuditData.Federation.OnPremisesSyncSettings
    if (-not $syncSettings) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No on-premises synchronization configured — hybrid join check not applicable'
    }

    $settings = if ($syncSettings.value) { $syncSettings.value } else { @($syncSettings) }
    $config = if ($settings -is [array] -and $settings.Count -gt 0) { $settings[0] } else { $settings }

    $deviceWriteback = $config.features.deviceWritebackEnabled ?? $false

    $status = if ($deviceWriteback) { 'PASS' } else { 'WARN' }
    $currentValue = if ($deviceWriteback) {
        'Device writeback is enabled — hybrid Azure AD join is likely configured'
    } else {
        'Device writeback is not enabled — hybrid Azure AD join may not be fully configured'
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            DeviceWritebackEnabled = $deviceWriteback
            Note                   = 'Full hybrid join validation requires device registration data and Azure AD Connect configuration review'
        }
}

# ── EIDFED-012: Cloud vs Synced User Analysis ───────────────────────────
function Test-InfiltrationEIDFED012 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $users = $AuditData.Federation.Users
    if (-not $users) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'User count data not available'
    }

    $cloudOnlyCount = $users.CloudOnlyCount
    $syncedCount = $users.SyncedCount

    # If counts returned -1, data collection failed
    if ($cloudOnlyCount -eq -1 -or $syncedCount -eq -1) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'User count data collection failed'
    }

    $totalCount = $cloudOnlyCount + $syncedCount
    $syncedPercentage = if ($totalCount -gt 0) { [Math]::Round(($syncedCount / $totalCount) * 100, 1) } else { 0 }
    $cloudPercentage = if ($totalCount -gt 0) { [Math]::Round(($cloudOnlyCount / $totalCount) * 100, 1) } else { 0 }

    # Determine identity posture
    $identityPosture = if ($syncedCount -eq 0) { 'Cloud-Only' }
                       elseif ($cloudOnlyCount -eq 0) { 'Fully Synced' }
                       else { 'Hybrid' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$totalCount total users: $cloudOnlyCount cloud-only ($cloudPercentage%), $syncedCount synced ($syncedPercentage%) — $identityPosture identity model" `
        -Details @{
            TotalUsers       = $totalCount
            CloudOnlyCount   = $cloudOnlyCount
            SyncedCount      = $syncedCount
            CloudPercentage  = $cloudPercentage
            SyncedPercentage = $syncedPercentage
            IdentityPosture  = $identityPosture
        }
}