Checks/Section08-SecurityServices.ps1

# =============================================================================
# Section 8: Security Services - Custom Check Functions
# CIS Microsoft Azure Foundations Benchmark v5.0.0
# =============================================================================
# Custom functions for Defender for Cloud, Key Vault, Bastion, and DDoS
# protection controls. Dispatched via 'Custom' CheckPattern.
# Each function receives -ControlDef (hashtable) and -ResourceCache (hashtable).
# =============================================================================

# Shared helper to cache Get-AzSecurityContact results per scan.
# The cache is stored in a script-scope variable, so it is reset when the
# module is re-imported (i.e., once per scan session).
$script:CISCachedSecurityContact = $null

function Get-CISSecurityContact {
    if ($null -eq $script:CISCachedSecurityContact) {
        $script:CISCachedSecurityContact = Get-AzSecurityContact -ErrorAction Stop
    }
    return $script:CISCachedSecurityContact
}

function Test-CIS8133-EndpointProtection {
    <#
    .SYNOPSIS
        CIS 8.1.3.3 - Ensure 'Endpoint protection' component status is set to 'On'.
    .DESCRIPTION
        Checks if the Defender for Servers plan has the endpoint protection
        extension/sub-plan enabled.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        # Get Defender for Servers plan and check for MDE integration
        $serversPlan = Get-AzSecurityPricing -Name 'VirtualMachines' -ErrorAction Stop

        if ($serversPlan.PricingTier -ne 'Standard') {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "Defender for Servers is not enabled (tier: $($serversPlan.PricingTier)). Endpoint protection requires Defender for Servers to be On." `
                -AffectedResources @("Defender for Servers: $($serversPlan.PricingTier)") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        # Check for MDE (Microsoft Defender for Endpoint) integration
        $hasEndpointProtection = $false

        # Method 1: Check Defender plan extensions for MDE-specific names
        if ($serversPlan.Extension) {
            $mdeExtensionNames = @('MdeDesignatedSubscription', 'MicrosoftDefenderForEndpoint')
            $mdeExtension = $serversPlan.Extension | Where-Object {
                $_.Name -in $mdeExtensionNames
            }
            if ($mdeExtension) {
                $enabledExt = @($mdeExtension | Where-Object { $_.IsEnabled -ne 'False' -and $_.IsEnabled -ne $false })
                if ($enabledExt.Count -gt 0) {
                    $hasEndpointProtection = $true
                }
            }
        }

        # Method 2: Check WDATP (Windows Defender ATP) integration setting
        if (-not $hasEndpointProtection) {
            try {
                $settings = @(Get-AzSecuritySetting -ErrorAction Stop)
                $wdatpSetting = $settings | Where-Object { $_.Name -eq 'WDATP' }
                if ($wdatpSetting -and $wdatpSetting.Enabled -eq $true) {
                    $hasEndpointProtection = $true
                }
            }
            catch {
                Write-Verbose "Could not check WDATP security setting: $($_.Exception.Message)"
            }
        }

        if ($hasEndpointProtection) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Endpoint protection (Microsoft Defender for Endpoint) is enabled in Defender for Servers." `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Endpoint protection component does not appear to be enabled in Defender for Servers. Enable MDE integration." `
            -AffectedResources @("Endpoint Protection: Not enabled") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking endpoint protection: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8110-VMUpdateCheck {
    <#
    .SYNOPSIS
        CIS 8.1.10 - Ensure Defender for Cloud checks VM OS for updates.
    .DESCRIPTION
        Verifies that system update assessment is enabled, either through Defender
        for Cloud recommendations or Azure Update Manager.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        # Check if Defender for Servers is enabled (required for update assessments)
        $serversPlan = Get-AzSecurityPricing -Name 'VirtualMachines' -ErrorAction Stop

        if ($serversPlan.PricingTier -ne 'Standard') {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "Defender for Servers is not enabled (tier: $($serversPlan.PricingTier)). VM update checking requires Defender for Servers." `
                -AffectedResources @("Defender for Servers: $($serversPlan.PricingTier)") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        # Check for Azure Update Manager periodic assessment
        $hasUpdateCheck = $false

        # Check if the sub-plan includes vulnerability assessment
        if ($serversPlan.PSObject -and $serversPlan.PSObject.Properties.Name -contains 'SubPlan') {
            if ($serversPlan.SubPlan -in @('P1', 'P2')) {
                $hasUpdateCheck = $true
            }
        }

        # Check for extensions that indicate update assessment
        if (-not $hasUpdateCheck -and $serversPlan.Extension) {
            $updateExtensions = @('MdeDesignatedSubscription')
            $matchingExt = $serversPlan.Extension | Where-Object {
                $_.Name -in $updateExtensions -and $_.IsEnabled -ne 'False' -and $_.IsEnabled -ne $false
            }
            if ($matchingExt) {
                $hasUpdateCheck = $true
            }
        }

        # Fallback: Check for Azure Policy assignment for update assessment
        if (-not $hasUpdateCheck) {
            try {
                $exactPolicyPatterns = '^(Configure periodic checking for missing system updates|machines should be configured to periodically check for missing system updates)$'
                $policies = @(Get-AzPolicyAssignment -ErrorAction Stop |
                    Where-Object { $_.Properties.DisplayName -match $exactPolicyPatterns })
                if ($policies.Count -gt 0) {
                    $hasUpdateCheck = $true
                }
            }
            catch {
                Write-Verbose "Could not check policy assignments: $($_.Exception.Message)"
            }
        }

        if ($hasUpdateCheck) {
            $subPlanInfo = if ($serversPlan.SubPlan) { " (Sub-plan: $($serversPlan.SubPlan))" } else { '' }
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Defender for Servers is enabled with VM update checking capability.$subPlanInfo" `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "VM update checking does not appear to be properly configured." `
            -AffectedResources @("Update assessment: Not configured") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking VM update configuration: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8112-SecurityContactRoles {
    <#
    .SYNOPSIS
        CIS 8.1.12 - Ensure 'All users with the following roles' is set to 'Owner'.
    .DESCRIPTION
        Checks that the security contact notification settings include the Owner role.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $securityContact = Get-CISSecurityContact

        if (-not $securityContact) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "No security contact configured in Defender for Cloud." `
                -AffectedResources @("Security contact: Not configured") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        # Check if Owner role is included in notification roles
        $notifyByRole = $false
        $roles = @()

        if ($securityContact.NotificationsByRole) {
            $roleState = $securityContact.NotificationsByRole.State
            $roles     = @($securityContact.NotificationsByRole.Roles)

            if ($roleState -eq 'On' -and ('Owner' -in $roles)) {
                $notifyByRole = $true
            }
        }

        if ($notifyByRole) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Security contact is configured to notify users with the Owner role. Roles: $($roles -join ', ')" `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        $currentRoles = if ($roles.Count -gt 0) { $roles -join ', ' } else { 'None configured' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Security contact notifications do not include the Owner role. Current roles: $currentRoles" `
            -AffectedResources @("NotificationsByRole: $currentRoles") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking security contact roles: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8113-SecurityContactEmail {
    <#
    .SYNOPSIS
        CIS 8.1.13 - Ensure 'Additional email addresses' is configured with a security contact email.
    .DESCRIPTION
        Checks that at least one additional email address is configured for security
        contact notifications.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $securityContact = Get-CISSecurityContact

        if (-not $securityContact) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "No security contact configured in Defender for Cloud." `
                -AffectedResources @("Security contact: Not configured") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        $emails = @()
        if ($securityContact.Email) {
            $emails = @($securityContact.Email -split ';' | Where-Object { $_.Trim() -ne '' })
        }

        if ($emails.Count -gt 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Security contact email(s) configured: $($emails -join '; ')" `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "No additional email addresses configured for security contact notifications." `
            -AffectedResources @("Additional email: Not configured") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking security contact email: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8114-AlertNotifications {
    <#
    .SYNOPSIS
        CIS 8.1.14 - Ensure 'Notify about alerts with the following severity (or higher)' is enabled.
    .DESCRIPTION
        Checks that alert notification severity is configured in security contact settings.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $securityContact = Get-CISSecurityContact

        if (-not $securityContact) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "No security contact configured in Defender for Cloud." `
                -AffectedResources @("Security contact: Not configured") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        $alertState        = $null
        $alertSeverity     = $null

        if ($securityContact.AlertNotifications) {
            $alertState    = $securityContact.AlertNotifications.State
            $alertSeverity = $securityContact.AlertNotifications.MinimalSeverity
        }

        if ($alertState -eq 'On' -and $alertSeverity) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Alert notifications are enabled with minimum severity: $alertSeverity" `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        $currentState = if ($alertState) { $alertState } else { 'Not configured' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Alert notifications are not properly configured. State: $currentState, Severity: $(if ($alertSeverity) { $alertSeverity } else { 'Not set' })" `
            -AffectedResources @("Alert notifications: $currentState") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking alert notifications: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8115-AttackPathNotifications {
    <#
    .SYNOPSIS
        CIS 8.1.15 - Ensure 'Notify about attack paths with the following risk level (or higher)' is enabled.
    .DESCRIPTION
        Checks that attack path notification risk level is configured in security contact settings.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $securityContact = Get-CISSecurityContact

        if (-not $securityContact) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details "No security contact configured in Defender for Cloud." `
                -AffectedResources @("Security contact: Not configured") `
                -TotalResources 1 -PassedResources 0 -FailedResources 1
        }

        # Check for attack path notifications in the security contact
        $hasAttackPathNotification = $false
        $riskLevel = $null

        # The NotificationsSources property may contain attack path configuration
        if ($securityContact.NotificationsSources) {
            foreach ($source in $securityContact.NotificationsSources) {
                if ($source.SourceType -eq 'AttackPath') {
                    $hasAttackPathNotification = $true
                    $riskLevel = if ($source.MinimalRiskLevel) { $source.MinimalRiskLevel } else { $source.minimalRiskLevel }
                    break
                }
            }
        }

        # Alternative: check via direct property
        if (-not $hasAttackPathNotification -and $securityContact.AttackPathNotifications) {
            if ($securityContact.AttackPathNotifications.State -eq 'On') {
                $hasAttackPathNotification = $true
                $riskLevel = $securityContact.AttackPathNotifications.MinimalRiskLevel
            }
        }

        if ($hasAttackPathNotification -and $riskLevel) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Attack path notifications are enabled with minimum risk level: $riskLevel" `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Attack path notifications are not configured or risk level is not set." `
            -AffectedResources @("Attack path notifications: Not configured") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking attack path notifications: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS838-KeyVaultPrivateEndpoints {
    <#
    .SYNOPSIS
        CIS 8.3.8 - Ensure Private Endpoints are used to access Azure Key Vault.
    .DESCRIPTION
        Checks each Key Vault for the existence of private endpoint connections.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $keyVaults = @($ResourceCache.KeyVaults)
        if ($keyVaults.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalCount  = $keyVaults.Count
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        # Pre-fetch all private endpoints in the subscription once
        $allPrivateEndpoints = @()
        try {
            $allPrivateEndpoints = @(Get-AzPrivateEndpoint -ErrorAction Stop)
        }
        catch {
            Write-Verbose "Could not retrieve private endpoints: $($_.Exception.Message)"
        }

        foreach ($kv in $keyVaults) {
            try {
                $kvResourceId = $kv.ResourceId
                if (-not $kvResourceId) {
                    $kvResourceId = "/subscriptions/$((Get-AzContext).Subscription.Id)/resourceGroups/$($kv.ResourceGroupName)/providers/Microsoft.KeyVault/vaults/$($kv.VaultName)"
                }

                # Check if any private endpoint targets this Key Vault
                $matchingPEs = @($allPrivateEndpoints | Where-Object {
                    $_.PrivateLinkServiceConnections | Where-Object {
                        $_.PrivateLinkServiceId -eq $kvResourceId
                    }
                })

                # Also check the vault object's PrivateEndpointConnections as a fallback
                if ($matchingPEs.Count -eq 0) {
                    $kvDetail = Get-AzKeyVault -VaultName $kv.VaultName -ResourceGroupName $kv.ResourceGroupName -ErrorAction Stop
                    if ($kvDetail.PrivateEndpointConnections -and @($kvDetail.PrivateEndpointConnections).Count -gt 0) {
                        $matchingPEs = @($kvDetail.PrivateEndpointConnections)
                    }
                }

                if ($matchingPEs.Count -gt 0) {
                    $passedCount++
                }
                else {
                    $failedList.Add("$($kv.VaultName) (no private endpoints)")
                }
            }
            catch {
                $failedList.Add("$($kv.VaultName) [Error: $(Format-CISErrorMessage $_.Exception.Message)]")
            }
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $details = "Found $failedCount of $totalCount Key Vault(s) without private endpoints: $($failedList -join '; ')"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCount `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCount Key Vault(s) have private endpoints configured." `
            -TotalResources $totalCount `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking Key Vault private endpoints: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS839-KeyRotation {
    <#
    .SYNOPSIS
        CIS 8.3.9 - Ensure automatic key rotation is enabled within Azure Key Vault.
    .DESCRIPTION
        Checks each Key Vault for keys and verifies that a rotation policy is
        configured on each key.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $keyVaults = @($ResourceCache.KeyVaults)
        if ($keyVaults.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalKeys   = 0
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        foreach ($kv in $keyVaults) {
            try {
                $keys = @(Get-AzKeyVaultKey -VaultName $kv.VaultName -ErrorAction Stop)

                foreach ($key in $keys) {
                    $totalKeys++

                    try {
                        $rotationPolicy = Get-AzKeyVaultKeyRotationPolicy -VaultName $kv.VaultName -Name $key.Name -ErrorAction Stop

                        if ($rotationPolicy -and $rotationPolicy.LifetimeActions -and $rotationPolicy.LifetimeActions.Count -gt 0) {
                            # Check for an automatic rotate action
                            $hasRotateAction = $rotationPolicy.LifetimeActions | Where-Object {
                                $_.Action -eq 'Rotate' -or $_.Action.Type -eq 'Rotate'
                            }
                            if ($hasRotateAction) {
                                $passedCount++
                            }
                            else {
                                $failedList.Add("$($kv.VaultName)/$($key.Name) (no rotate action in policy)")
                            }
                        }
                        else {
                            $failedList.Add("$($kv.VaultName)/$($key.Name) (no rotation policy)")
                        }
                    }
                    catch {
                        $failedList.Add("$($kv.VaultName)/$($key.Name) [Error: $($_.Exception.Message)]")
                    }
                }
            }
            catch {
                Write-Verbose "Cannot access keys in $($kv.VaultName): $($_.Exception.Message)"
                $failedList.Add("$($kv.VaultName) [Access error: $($_.Exception.Message)]")
            }
        }

        if ($totalKeys -eq 0 -and $failedList.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No keys found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $details = "Found $failedCount key(s) without automatic rotation: $($failedList -join '; ')"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalKeys `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalKeys key(s) across $($keyVaults.Count) Key Vault(s) have automatic rotation configured." `
            -TotalResources $totalKeys `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking key rotation policies: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS8311-CertificateValidity {
    <#
    .SYNOPSIS
        CIS 8.3.11 - Ensure certificate validity period <= 12 months.
    .DESCRIPTION
        Checks certificates in Key Vaults to verify their validity period does
        not exceed 12 months (365 days).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $keyVaults = @($ResourceCache.KeyVaults)
        if ($keyVaults.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalCerts  = 0
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        foreach ($kv in $keyVaults) {
            try {
                $certs = @(Get-AzKeyVaultCertificate -VaultName $kv.VaultName -ErrorAction Stop)

                foreach ($cert in $certs) {
                    $totalCerts++
                    try {
                        # Try the policy first - it has ValidityInMonths and avoids
                        # a second API call to get full certificate details.
                        $validityMonths = $null
                        try {
                            $certPolicy = Get-AzKeyVaultCertificatePolicy -VaultName $kv.VaultName -Name $cert.Name -ErrorAction Stop
                            $validityMonths = $certPolicy.ValidityInMonths
                        }
                        catch {
                            Write-Verbose "Could not retrieve policy for $($kv.VaultName)/$($cert.Name): $($_.Exception.Message)"
                        }

                        if ($validityMonths -and $validityMonths -le 12) {
                            $passedCount++
                        }
                        elseif ($validityMonths) {
                            $failedList.Add("$($kv.VaultName)/$($cert.Name) (validity: $validityMonths months)")
                        }
                        else {
                            # Fall back to certificate dates (the list call already
                            # returns basic cert info; fetch full detail only when needed)
                            $certDetail = Get-AzKeyVaultCertificate -VaultName $kv.VaultName -Name $cert.Name -ErrorAction Stop
                            $notBefore = $certDetail.NotBefore
                            $expires   = $certDetail.Expires
                            if ($notBefore -and $expires) {
                                $durationDays = ($expires - $notBefore).TotalDays
                                if ($durationDays -le 366) {
                                    $passedCount++
                                }
                                else {
                                    $durationMonths = [math]::Ceiling($durationDays / 30)
                                    $failedList.Add("$($kv.VaultName)/$($cert.Name) (validity: ~$durationMonths months)")
                                }
                            }
                            else {
                                $failedList.Add("$($kv.VaultName)/$($cert.Name) (unable to determine validity period)")
                            }
                        }
                    }
                    catch {
                        $failedList.Add("$($kv.VaultName)/$($cert.Name) [Error: $($_.Exception.Message)]")
                    }
                }
            }
            catch {
                $failedList.Add("$($kv.VaultName) [Vault access error: $(Format-CISErrorMessage $_.Exception.Message)]")
            }
        }

        if ($totalCerts -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No certificates found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $details = "Found $failedCount of $totalCerts certificate(s) with validity > 12 months: $($failedList -join '; ')"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCerts `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCerts certificate(s) have validity period <= 12 months." `
            -TotalResources $totalCerts `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking certificate validity periods: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS841-BastionHost {
    <#
    .SYNOPSIS
        CIS 8.4.1 - Ensure an Azure Bastion Host Exists.
    .DESCRIPTION
        Checks if at least one Azure Bastion host resource exists in the subscription.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $bastionHosts = @(Get-AzResource -ResourceType 'Microsoft.Network/bastionHosts' -ErrorAction Stop)

        if ($bastionHosts.Count -gt 0) {
            $names = ($bastionHosts | ForEach-Object { "$($_.Name) ($($_.Location))" }) -join ', '
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Found $($bastionHosts.Count) Azure Bastion host(s): $names" `
                -TotalResources $bastionHosts.Count `
                -PassedResources $bastionHosts.Count `
                -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "No Azure Bastion hosts found in the subscription. Deploy a Bastion host for secure management access." `
            -AffectedResources @("No Bastion host deployed") `
            -TotalResources 0 -PassedResources 0 -FailedResources 1
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking for Bastion hosts: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS85-DDoSProtection {
    <#
    .SYNOPSIS
        CIS 8.5 - Ensure Azure DDoS Network Protection is enabled on virtual networks.
    .DESCRIPTION
        Checks if a DDoS protection plan exists and is associated with VNets.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $vnets = @($ResourceCache.VirtualNetworks)
        if ($vnets.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "N/A - No Virtual Networks found in the subscription. Control not evaluated." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        # Check for DDoS protection plans
        $ddosPlans = @()
        try {
            $ddosPlans = @(Get-AzDdosProtectionPlan -ErrorAction Stop)
        }
        catch {
            Write-Verbose "Error retrieving DDoS plans: $($_.Exception.Message)"
        }

        $totalCount  = $vnets.Count
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        foreach ($vnet in $vnets) {
            $hasDDoS = $false

            if ($vnet.DdosProtectionPlan -and $vnet.DdosProtectionPlan.Id) {
                $hasDDoS = $true
            }
            elseif ($vnet.EnableDdosProtection -eq $true) {
                $hasDDoS = $true
            }

            if ($hasDDoS) {
                $passedCount++
            }
            else {
                $failedList.Add("$($vnet.Name) ($($vnet.Location))")
            }
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $planInfo = if ($ddosPlans.Count -gt 0) {
                "DDoS plan(s) exist but are not associated with all VNets."
            } else {
                "No DDoS protection plans found in the subscription."
            }
            $details = "$failedCount of $totalCount VNet(s) without DDoS protection: $($failedList -join '; '). $planInfo"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCount `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCount VNet(s) have DDoS Network Protection enabled." `
            -TotalResources $totalCount `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking DDoS protection: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}