tests/Test-Assessment.26886.ps1

<#
.SYNOPSIS
    Validates that diagnostic logging is enabled for DDoS-protected public IP addresses.
 
.DESCRIPTION
    This test evaluates diagnostic settings for public IP addresses that have Azure DDoS Protection
    enabled (either via DDoS IP Protection or inherited from a protected VNET). It verifies that
    all three required DDoS log categories are enabled with a valid destination configured.
 
.NOTES
    Test ID: 26886
    Category: Azure Network Security
    Required APIs: Azure Management REST API (public IPs, VNETs, network interfaces, diagnostic settings)
#>


function Test-Assessment-26886 {

    [ZtTest(
        Category = 'Azure Network Security',
        ImplementationCost = 'Low',
        MinimumLicense = ('DDoS_Network_Protection', 'DDoS_IP_Protection'),
        Pillar = 'Network',
        RiskLevel = 'Medium',
        SfiPillar = 'Protect networks',
        TenantType = ('Workforce'),
        TestId = 26886,
        Title = 'Diagnostic logging is enabled for DDoS-protected public IPs',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Helper Functions

    function Get-SubnetIdFromIpConfiguration {
        <#
        .SYNOPSIS
            Gets the subnet ID from an IP configuration by determining the parent resource type.
        #>

        param(
            [Parameter(Mandatory)]
            [string]$IpConfigurationId
        )

        # Parse the IP configuration ID to determine the resource type
        # Common patterns:
        # - NIC: /subscriptions/.../resourceGroups/.../providers/Microsoft.Network/networkInterfaces/{nicName}/ipConfigurations/{ipConfigName}
        # - Load Balancer: /subscriptions/.../resourceGroups/.../providers/Microsoft.Network/loadBalancers/{lbName}/frontendIPConfigurations/{ipConfigName}
        # - App Gateway: /subscriptions/.../resourceGroups/.../providers/Microsoft.Network/applicationGateways/{appGwName}/frontendIPConfigurations/{ipConfigName}

        if ($IpConfigurationId -match '/providers/Microsoft.Network/networkInterfaces/') {
            # It's a NIC - extract the NIC resource path and query it
            $nicPath = ($IpConfigurationId -split '/ipConfigurations/')[0] + '?api-version=2023-04-01'
            $nic = Invoke-ZtAzureRequest -Path $nicPath

            # Find the subnet from the matching IP configuration
            $ipConfigName = ($IpConfigurationId -split '/ipConfigurations/')[-1]
            foreach ($ipConfig in $nic.properties.ipConfigurations) {
                if (($ipConfig.id -eq $IpConfigurationId) -or ($ipConfig.name -eq $ipConfigName)) {
                    if ($ipConfig.properties.subnet.id) {
                        return $ipConfig.properties.subnet.id
                    }
                    break
                }
            }
        }
        elseif ($IpConfigurationId -match '/providers/Microsoft.Network/loadBalancers/') {
            # Load Balancer frontend IPs can be associated with a subnet for internal LBs
            # For external LBs, they're not associated with a subnet directly
            # We need to find a backend pool NIC to trace to the VNET
            $lbPath = ($IpConfigurationId -split '/frontendIPConfigurations/')[0] + '?api-version=2023-04-01'
            $lb = Invoke-ZtAzureRequest -Path $lbPath

            # Check the matching frontend IP configuration for subnet (internal LB)
            $frontendIpConfigName = ($IpConfigurationId -split '/frontendIPConfigurations/')[-1]
            $frontendHasNoSubnet = $false
            foreach ($frontendIp in $lb.properties.frontendIPConfigurations) {
                if (($frontendIp.id -eq $IpConfigurationId) -or ($frontendIp.name -eq $frontendIpConfigName)) {
                    if ($frontendIp.properties.subnet.id) {
                        return $frontendIp.properties.subnet.id
                    }
                    # Matched the correct entry but it has no subnet (external LB frontend)
                    $frontendHasNoSubnet = $true
                    break
                }
            }

            # For external LB (matched frontend has no subnet), check backend pool NICs
            if (-not $frontendHasNoSubnet) { return $null }  # No match found at all — bail out
            foreach ($backendPool in $lb.properties.backendAddressPools) {
                foreach ($backendAddress in $backendPool.properties.backendIPConfigurations) {
                    if ($backendAddress.id -match '/providers/Microsoft.Network/networkInterfaces/') {
                        $nicPath = ($backendAddress.id -split '/ipConfigurations/')[0] + '?api-version=2023-04-01'
                        $nic = Invoke-ZtAzureRequest -Path $nicPath
                        $backendIpConfigName = ($backendAddress.id -split '/ipConfigurations/')[-1]
                        foreach ($ipConfig in $nic.properties.ipConfigurations) {
                            if (($ipConfig.id -eq $backendAddress.id) -or ($ipConfig.name -eq $backendIpConfigName)) {
                                if ($ipConfig.properties.subnet.id) {
                                    return $ipConfig.properties.subnet.id
                                }
                                break
                            }
                        }
                    }
                }
            }
        }
        elseif ($IpConfigurationId -match '/providers/Microsoft.Network/applicationGateways/') {
            # Application Gateway - get the gateway's subnet from gateway IP configurations
            $appGwPath = ($IpConfigurationId -split '/frontendIPConfigurations/')[0] + '?api-version=2023-04-01'
            $appGw = Invoke-ZtAzureRequest -Path $appGwPath

            # For private frontend IPs, the matched frontendIPConfiguration carries a subnet directly
            $frontendIpConfigName = ($IpConfigurationId -split '/frontendIPConfigurations/')[-1]
            foreach ($frontendIp in $appGw.properties.frontendIPConfigurations) {
                if (($frontendIp.id -eq $IpConfigurationId) -or ($frontendIp.name -eq $frontendIpConfigName)) {
                    if ($frontendIp.properties.subnet.id) {
                        return $frontendIp.properties.subnet.id
                    }
                    break  # Matched config has no subnet (public frontend); fall through
                }
            }

            # Fall back to gateway IP configurations (subnet where the gateway itself is deployed)
            foreach ($gwIpConfig in $appGw.properties.gatewayIPConfigurations) {
                if ($gwIpConfig.properties.subnet.id) {
                    return $gwIpConfig.properties.subnet.id
                }
            }
        }

        return $null
    }

    #endregion Helper Functions

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Evaluating DDoS Protection diagnostic logging configuration'

    # Check if connected to Azure
    Write-ZtProgress -Activity $activity -Status 'Checking Azure connection'

    $azContext = Get-AzContext -ErrorAction SilentlyContinue
    if (-not $azContext) {
        Write-PSFMessage 'Not connected to Azure.' -Level Warning
        Add-ZtTestResultDetail -SkippedBecause NotConnectedAzure
        return
    }

    # Check the supported environment
    Write-ZtProgress -Activity $activity -Status 'Checking Azure environment'

    if ($azContext.Environment.Name -ne 'AzureCloud') {
        Write-PSFMessage 'This test is only applicable to the AzureCloud environment.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotSupported
        return
    }

    # Check Azure access token
    try {
        $accessToken = Get-AzAccessToken -AsSecureString -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
    }
    catch {
        Write-PSFMessage $_.Exception.Message -Tag Test -Level Error
    }

    if (-not $accessToken) {
        Write-PSFMessage 'Azure authentication token not found.' -Tag Test -Level Warning
        Add-ZtTestResultDetail -SkippedBecause NotConnectedAzure
        return
    }

    # Q1: Query all public IP addresses using Azure Resource Graph
    Write-ZtProgress -Activity $activity -Status 'Querying Public IP Addresses via Resource Graph'

    $argQuery = @"
resources
| where type =~ 'microsoft.network/publicipaddresses'
| where properties.provisioningState =~ 'Succeeded'
| join kind=leftouter (
    resourcecontainers
    | where type =~ 'microsoft.resources/subscriptions'
    | project subscriptionName=name, subscriptionId
) on subscriptionId
| project
    PublicIpName=name,
    PublicIpId=id,
    Location=location,
    ProtectionMode=tostring(properties.ddosSettings.protectionMode),
    IpConfigurationId=tostring(properties.ipConfiguration.id),
    SubscriptionId=subscriptionId,
    SubscriptionName=subscriptionName
"@


    $allPublicIps = @()
    try {
        $allPublicIps = @(Invoke-ZtAzureResourceGraphRequest -Query $argQuery)
        Write-PSFMessage "ARG Query returned $($allPublicIps.Count) Public IP Address(es)" -Tag Test -Level VeryVerbose
    }
    catch {
        Write-PSFMessage "Azure Resource Graph query failed: $($_.Exception.Message)" -Tag Test -Level Warning
        Add-ZtTestResultDetail -SkippedBecause NotSupported
        return
    }

    # Check if any Public IP addresses exist
    if ($allPublicIps.Count -eq 0) {
        Write-PSFMessage 'No Public IP addresses found.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    # Filter and evaluate DDoS-protected public IPs
    Write-ZtProgress -Activity $activity -Status 'Evaluating DDoS protection status'

    $ddosProtectedIps = @()
    $requiredLogCategories = @('DDoSProtectionNotifications', 'DDoSMitigationFlowLogs', 'DDoSMitigationReports')

    foreach ($publicIp in $allPublicIps) {
        $protectionMode = $publicIp.ProtectionMode
        $ipConfigId = $publicIp.IpConfigurationId

        # Skip if DDoS protection is disabled
        if ($protectionMode -eq 'Disabled') {
            continue
        }

        # If protectionMode is "Enabled", it's protected via DDoS IP Protection SKU
        if ($protectionMode -eq 'Enabled') {
            $ddosProtectedIps += [PSCustomObject]@{
                PublicIpName     = $publicIp.PublicIpName
                PublicIpId       = $publicIp.PublicIpId
                Location         = $publicIp.Location
                ProtectionType   = 'IP Protection'
                AssociatedVnet   = 'N/A'
                SubscriptionId   = $publicIp.SubscriptionId
                SubscriptionName = $publicIp.SubscriptionName
            }
            continue
        }

        # If protectionMode is "VirtualNetworkInherited", check if VNET has DDoS protection
        if ($protectionMode -eq 'VirtualNetworkInherited') {
            # Skip orphaned public IPs (not attached to any resource)
            if ([string]::IsNullOrEmpty($ipConfigId)) {
                continue
            }

            # Q2: Get the associated resource to find the subnet/VNET
            $vnetId = $null
            $vnetName = $null

            try {
                # Parse the ipConfiguration to determine resource type and get subnet
                $subnetId = Get-SubnetIdFromIpConfiguration -IpConfigurationId $ipConfigId
                if ($subnetId) {
                    # Extract VNET ID from subnet ID
                    # Subnet ID format: /subscriptions/.../resourceGroups/.../providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName}
                    if ($subnetId -match '(/subscriptions/.+/resourceGroups/.+/providers/Microsoft.Network/virtualNetworks/[^/]+)') {
                        $vnetId = $Matches[1]
                        $vnetName = ($vnetId -split '/')[-1]
                    }
                }
            }
            catch {
                Write-PSFMessage "Error getting subnet for $($publicIp.PublicIpName): $_" -Level Warning
                continue
            }

            # Skip if we couldn't determine the VNET
            if (-not $vnetId) {
                continue
            }

            # Q3: Check if the VNET has DDoS Network Protection enabled
            try {
                $vnetPath = $vnetId + '?api-version=2023-04-01'
                $vnet = Invoke-ZtAzureRequest -Path $vnetPath

                $ddosEnabled = $vnet.properties.enableDdosProtection -eq $true
                $ddosPlanId = $vnet.properties.ddosProtectionPlan.id

                if ($ddosEnabled -and $ddosPlanId) {
                    $ddosProtectedIps += [PSCustomObject]@{
                        PublicIpName     = $publicIp.PublicIpName
                        PublicIpId       = $publicIp.PublicIpId
                        Location         = $publicIp.Location
                        ProtectionType   = 'Network Protection'
                        AssociatedVnet   = $vnetName
                        SubscriptionId   = $publicIp.SubscriptionId
                        SubscriptionName = $publicIp.SubscriptionName
                    }
                }
            }
            catch {
                Write-PSFMessage "Error checking VNET DDoS protection for $($publicIp.PublicIpName): $_" -Level Warning
            }
        }
    }

    # Check if any DDoS-protected public IPs exist
    if ($ddosProtectedIps.Count -eq 0) {
        Write-PSFMessage 'No DDoS-protected Public IP addresses found.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    # Q4: Get diagnostic settings for each DDoS-protected public IP
    Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings'

    $evaluationResults = @()

    foreach ($pip in $ddosProtectedIps) {
        $pipId = $pip.PublicIpId
        $pipName = $pip.PublicIpName

        # Query diagnostic settings
        $diagPath = $pipId + '/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview'

        $diagSettings = @()
        try {
            $diagSettings = @(Invoke-ZtAzureRequest -Path $diagPath)
        }
        catch {
            Write-PSFMessage "Error querying diagnostic settings for $pipName : $_" -Level Warning
        }

        # Evaluate diagnostic settings
        $allDestinationTypes = @()
        $hasValidDiagSetting = $false

        foreach ($setting in $diagSettings) {
            $workspaceId = $setting.properties.workspaceId
            $storageAccountId = $setting.properties.storageAccountId
            $eventHubAuthRuleId = $setting.properties.eventHubAuthorizationRuleId

            # Check if destination is configured
            $hasDestination = $workspaceId -or $storageAccountId -or $eventHubAuthRuleId

            if ($hasDestination) {
                # Determine destination types
                $destTypes = @()
                if ($workspaceId) { $destTypes += 'Log Analytics' }
                if ($storageAccountId) { $destTypes += 'Storage' }
                if ($eventHubAuthRuleId) { $destTypes += 'Event Hub' }
                $allDestinationTypes += $destTypes

                # Collect enabled log categories for this single setting
                # Also detect allLogs/audit category groups which cover all required categories
                $settingEnabledCategories = @()
                $hasAllLogsGroup = $false
                foreach ($log in $setting.properties.logs) {
                    if ($log.enabled) {
                        if ($log.categoryGroup -in @('allLogs', 'audit')) {
                            $hasAllLogsGroup = $true
                        }
                        $categoryName = if ($log.category) { $log.category } else { $log.categoryGroup }
                        if ($categoryName) { $settingEnabledCategories += $categoryName }
                    }
                }

                # Per spec: a single setting must cover all three required categories.
                # allLogs/audit category groups implicitly cover all required categories.
                if ($hasAllLogsGroup) {
                    $hasValidDiagSetting = $true
                } else {
                    $missingInThisSetting = @($requiredLogCategories | Where-Object { $_ -notin $settingEnabledCategories })
                    if ($missingInThisSetting.Count -eq 0) {
                        $hasValidDiagSetting = $true
                    }
                }
            }
        }

        # Deduplicate destination types across settings
        $allDestinationTypes = $allDestinationTypes | Select-Object -Unique

        # Per spec: at least one single diagnostic setting must have all three required log
        # categories enabled with a valid destination (categories must not be split across settings)

        $status = if ($hasValidDiagSetting) { 'Pass' } else { 'Fail' }
        $destinationType = if ($allDestinationTypes.Count -gt 0) { $allDestinationTypes -join ', ' } else { 'None' }

        # Collect all enabled categories across all settings (for display/table purposes only)
        # Also detect allLogs/audit category groups which cover all required categories
        $allEnabledCategories = @()
        $anyAllLogsGroup = $false
        foreach ($setting in $diagSettings) {
            foreach ($log in $setting.properties.logs) {
                if ($log.enabled) {
                    if ($log.categoryGroup -in @('allLogs', 'audit')) {
                        $anyAllLogsGroup = $true
                    }
                    $categoryName = if ($log.category) { $log.category } else { $log.categoryGroup }
                    if ($categoryName) { $allEnabledCategories += $categoryName }
                }
            }
        }
        $allEnabledCategories = $allEnabledCategories | Select-Object -Unique

        # Determine enabled status for each required log category (for table display).
        # If allLogs/audit is present in any setting, all categories are implicitly covered.
        $notificationsEnabled = $anyAllLogsGroup -or ('DDoSProtectionNotifications' -in $allEnabledCategories)
        $flowLogsEnabled = $anyAllLogsGroup -or ('DDoSMitigationFlowLogs' -in $allEnabledCategories)
        $reportsEnabled = $anyAllLogsGroup -or ('DDoSMitigationReports' -in $allEnabledCategories)

        $evaluationResults += [PSCustomObject]@{
            SubscriptionId                = $pip.SubscriptionId
            SubscriptionName              = $pip.SubscriptionName
            PublicIpName                  = $pipName
            PublicIpId                    = $pipId
            Location                      = $pip.Location
            ProtectionType                = $pip.ProtectionType
            AssociatedVnet                = $pip.AssociatedVnet
            DiagnosticsConfigured         = $diagSettings.Count -gt 0
            DDoSProtectionNotifications   = $notificationsEnabled
            DDoSMitigationFlowLogs        = $flowLogsEnabled
            DDoSMitigationReports         = $reportsEnabled
            DestinationType               = $destinationType
            Status                        = $status
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    $passedItems = $evaluationResults | Where-Object { $_.Status -eq 'Pass' }
    $failedItems = $evaluationResults | Where-Object { $_.Status -eq 'Fail' }

    $passed = ($failedItems.Count -eq 0) -and ($passedItems.Count -gt 0)

    if ($passed) {
        $testResultMarkdown = "✅ Diagnostic logging is enabled for all DDoS-protected public IP addresses.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "❌ Diagnostic logging is not enabled for one or more DDoS-protected public IP addresses.`n`n%TestResult%"
    }

    #endregion Assessment Logic

    #region Report Generation

    # Portal link variables
    $portalPublicIpBrowseLink = 'https://portal.azure.com/#browse/Microsoft.Network%2FpublicIPAddresses'
    $portalSubscriptionBaseLink = 'https://portal.azure.com/#resource/subscriptions'
    $portalResourceBaseLink = 'https://portal.azure.com/#resource'

    $mdInfo = "`n## [DDoS-protected Public IP diagnostic logging status]($portalPublicIpBrowseLink)`n`n"

    # Public IP Status table
    if ($evaluationResults.Count -gt 0) {
        $tableRows = ""
        $formatTemplate = @'
| Public IP name | Resource ID | Protection type | Associated VNET | Diag. configured | Notifications | Flow logs | Reports | Destination | Status |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
{0}
 
'@


        # Limit display to first 5 items if there are many public IPs
        $maxItemsToDisplay = 5
        $displayResults = $evaluationResults
        $hasMoreItems = $false
        if ($evaluationResults.Count -gt $maxItemsToDisplay) {
            $displayResults = $evaluationResults | Select-Object -First $maxItemsToDisplay
            $hasMoreItems = $true
        }

        foreach ($result in $displayResults) {
            $pipLink = "[$(Get-SafeMarkdown $result.PublicIpName)]($portalResourceBaseLink$($result.PublicIpId)/diagnostics)"
            $resourceId = Get-SafeMarkdown $result.PublicIpId
            $protectionType = $result.ProtectionType
            $associatedVnet = Get-SafeMarkdown $result.AssociatedVnet
            $diagConfigured = if ($result.DiagnosticsConfigured) { 'Yes' } else { 'No' }
            $notificationsStatus = if ($result.DDoSProtectionNotifications) { '✅' } else { '❌' }
            $flowLogsStatus = if ($result.DDoSMitigationFlowLogs) { '✅' } else { '❌' }
            $reportsStatus = if ($result.DDoSMitigationReports) { '✅' } else { '❌' }
            $destConfigured = if ($result.DestinationType -eq 'None') { 'None' } else { $result.DestinationType }
            $statusText = if ($result.Status -eq 'Pass') { '✅ Pass' } else { '❌ Fail' }

            $tableRows += "| $pipLink | $resourceId | $protectionType | $associatedVnet | $diagConfigured | $notificationsStatus | $flowLogsStatus | $reportsStatus | $destConfigured | $statusText |`n"
        }

        # Add note if more items exist
        if ($hasMoreItems) {
            $remainingCount = $evaluationResults.Count - $maxItemsToDisplay
            $tableRows += "`n... and $remainingCount more. [View all Public IP Addresses in the portal]($portalPublicIpBrowseLink)`n"
        }

        $mdInfo += $formatTemplate -f $tableRows
    }

    # Summary
    $mdInfo += "**Summary:**`n`n"
    $mdInfo += "- Total DDoS-protected Public IPs evaluated: $($evaluationResults.Count)`n"
    $mdInfo += "- Public IPs with complete diagnostic logging: $($passedItems.Count)`n"
    $mdInfo += "- Public IPs missing diagnostic logging: $($failedItems.Count)`n"

    # Replace the placeholder with detailed information
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo

    #endregion Report Generation

    $params = @{
        TestId = '26886'
        Title  = 'Diagnostic logging is enabled for DDoS-protected public IPs'
        Status = $passed
        Result = $testResultMarkdown
    }

    Add-ZtTestResultDetail @params
}