tests/Test-Assessment.26885.ps1

<#
.SYNOPSIS
    Validates that metrics are 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 DDoS-protected VNET).
    It verifies that at least one diagnostic setting has metrics enabled with a valid destination
    (Log Analytics workspace, Storage account, or Event Hub) configured.
 
.NOTES
    Test ID: 26885
    Category: Azure Network Security
    Required APIs: Azure Management REST API (public IPs, VNETs, network interfaces, diagnostic settings)
#>


function Test-Assessment-26885 {

    [ZtTest(
        Category = 'Azure Network Security',
        ImplementationCost = 'Low',
        Service = ('Azure'),
        MinimumLicense = ('DDoS_Network_Protection', 'DDoS_IP_Protection'),
        Pillar = 'Network',
        RiskLevel = 'Medium',
        SfiPillar = 'Monitor and detect cyberthreats',
        TenantType = ('Workforce'),
        TestId = 26885,
        Title = 'Metrics are 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
        )

        if ($IpConfigurationId -match '/providers/Microsoft.Network/networkInterfaces/') {
            $nicPath = ($IpConfigurationId -split '/ipConfigurations/')[0] + '?api-version=2023-04-01'
            $nic = Invoke-ZtAzureRequest -Path $nicPath

            $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/') {
            $lbPath = ($IpConfigurationId -split '/frontendIPConfigurations/')[0] + '?api-version=2023-04-01'
            $lb = Invoke-ZtAzureRequest -Path $lbPath

            $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
                    }
                    $frontendHasNoSubnet = $true
                    break
                }
            }

            # External LB: trace through backend pool NIC to find a VNET
            if (-not $frontendHasNoSubnet) { return $null }
            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/') {
            $appGwPath = ($IpConfigurationId -split '/frontendIPConfigurations/')[0] + '?api-version=2023-04-01'
            $appGw = Invoke-ZtAzureRequest -Path $appGwPath

            $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
                }
            }

            foreach ($gwIpConfig in $appGw.properties.gatewayIPConfigurations) {
                if ($gwIpConfig.properties.subnet.id) {
                    return $gwIpConfig.properties.subnet.id
                }
            }
        }
        elseif ($IpConfigurationId -match '/providers/Microsoft.Network/azureFirewalls/') {
            $fwPath = ($IpConfigurationId -split '/azureFirewallIpConfigurations/')[0] + '?api-version=2023-04-01'
            $fw = Invoke-ZtAzureRequest -Path $fwPath
            $ipConfigName = ($IpConfigurationId -split '/azureFirewallIpConfigurations/')[-1]
            foreach ($ipConfig in $fw.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/bastionHosts/') {
            $bastionPath = ($IpConfigurationId -split '/bastionHostIpConfigurations/')[0] + '?api-version=2023-04-01'
            $bastion = Invoke-ZtAzureRequest -Path $bastionPath
            $ipConfigName = ($IpConfigurationId -split '/bastionHostIpConfigurations/')[-1]
            foreach ($ipConfig in $bastion.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/virtualNetworkGateways/') {
            $vngPath = ($IpConfigurationId -split '/ipConfigurations/')[0] + '?api-version=2023-04-01'
            $vng = Invoke-ZtAzureRequest -Path $vngPath
            $ipConfigName = ($IpConfigurationId -split '/ipConfigurations/')[-1]
            foreach ($ipConfig in $vng.properties.ipConfigurations) {
                if (($ipConfig.id -eq $IpConfigurationId) -or ($ipConfig.name -eq $ipConfigName)) {
                    if ($ipConfig.properties.subnet.id) {
                        return $ipConfig.properties.subnet.id
                    }
                    break
                }
            }
        }

        return $null
    }

    #endregion Helper Functions

    #region Data Collection

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

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

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

    # Check 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
    }

    # Query all public IP addresses via 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,
    ResourceGroup=resourceGroup,
    Location=location,
    IpAddress=tostring(properties.ipAddress),
    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
    }

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


    Write-ZtProgress -Activity $activity -Status 'Evaluating DDoS protection status'

    $ddosProtectedIps = @()

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

        # Skip explicitly disabled
        if ($protectionMode -eq 'Disabled') { continue }

        # Directly protected via DDoS IP Protection
        if ($protectionMode -eq 'Enabled') {
            $ddosProtectedIps += [PSCustomObject]@{
                PublicIpName     = $publicIp.PublicIpName
                PublicIpId       = $publicIp.PublicIpId
                ResourceGroup    = $publicIp.ResourceGroup
                Location         = $publicIp.Location
                IpAddress        = $publicIp.IpAddress
                ProtectionType   = 'IP Protection'
                AssociatedResourceType = 'N/A'
                AssociatedVnet   = 'N/A'
                SubscriptionId   = $publicIp.SubscriptionId
                SubscriptionName = $publicIp.SubscriptionName
            }
            continue
        }

        # VirtualNetworkInherited: requires VNET to actually have DDoS Network Protection
        if ($protectionMode -eq 'VirtualNetworkInherited') {
            # Skip orphaned IPs (no associated resource)
            if ([string]::IsNullOrEmpty($ipConfigId)) { continue }

            $vnetId = $null
            $vnetName = $null
            $associatedResourceType = 'Unknown'

            try {
                if ($ipConfigId -match '/providers/Microsoft.Network/networkInterfaces/') {
                    $associatedResourceType = 'Network Interface'
                }
                elseif ($ipConfigId -match '/providers/Microsoft.Network/loadBalancers/') {
                    $associatedResourceType = 'Load Balancer'
                }
                elseif ($ipConfigId -match '/providers/Microsoft.Network/applicationGateways/') {
                    $associatedResourceType = 'Application Gateway'
                }
                elseif ($ipConfigId -match '/providers/Microsoft.Network/azureFirewalls/') {
                    $associatedResourceType = 'Azure Firewall'
                }
                elseif ($ipConfigId -match '/providers/Microsoft.Network/bastionHosts/') {
                    $associatedResourceType = 'Azure Bastion'
                }
                elseif ($ipConfigId -match '/providers/Microsoft.Network/virtualNetworkGateways/') {
                    $associatedResourceType = 'Virtual Network Gateway'
                }

                $subnetId = Get-SubnetIdFromIpConfiguration -IpConfigurationId $ipConfigId
                if ($subnetId -and $subnetId -match '(/subscriptions/.+/resourceGroups/.+/providers/Microsoft.Network/virtualNetworks/[^/]+)') {
                    $vnetId = $Matches[1]
                    $vnetName = ($vnetId -split '/')[-1]
                }
            }
            catch {
                Write-PSFMessage "Error getting subnet for $($publicIp.PublicIpName): $($_.Exception.Message)" -Tag Test -Level Warning
                continue
            }

            if (-not $vnetId) { continue }

            # Verify VNET has DDoS Network Protection enabled with a Protection Plan
            try {
                $vnet = Invoke-ZtAzureRequest -Path ($vnetId + '?api-version=2023-04-01')

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

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

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

    #endregion Data Collection


    #region Assessment Logic

    Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings'

    $evaluationResults = @()

    foreach ($pip in $ddosProtectedIps) {
        $diagPath = $pip.PublicIpId + '/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 $($pip.PublicIpName): $($_.Exception.Message)" -Tag Test -Level Warning
        }

        # Metrics are enabled when at least one diagnostic setting has an enabled metric
        # AND a valid destination is configured.
        $metricsEnabled = $false
        $workspaceName = 'N/A'

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

            $hasDestination = $workspaceId -or $storageAccountId -or $eventHubAuthRuleId
            if (-not $hasDestination) { continue }

            $hasEnabledMetric = $false
            foreach ($metric in $setting.properties.metrics) {
                if ($metric.enabled) { $hasEnabledMetric = $true; break }
            }

            if ($hasEnabledMetric) {
                $metricsEnabled = $true

                if ($workspaceId -and $workspaceName -eq 'N/A') {
                    $workspaceName = ($workspaceId -split '/')[-1]
                }
            }
        }

        $evaluationResults += [PSCustomObject]@{
            SubscriptionId         = $pip.SubscriptionId
            SubscriptionName       = $pip.SubscriptionName
            PublicIpName           = $pip.PublicIpName
            PublicIpId             = $pip.PublicIpId
            ResourceGroup          = $pip.ResourceGroup
            Location               = $pip.Location
            IpAddress              = $pip.IpAddress
            ProtectionType         = $pip.ProtectionType
            AssociatedResourceType = $pip.AssociatedResourceType
            AssociatedVnet         = $pip.AssociatedVnet
            MetricsEnabled         = $metricsEnabled
            LogAnalyticsWorkspace  = $workspaceName
             Status                 = if ($metricsEnabled) { 'Pass' } else { 'Fail' }
        }
    }

    $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 = "✅ Metrics are enabled for all DDoS-protected public IP addresses.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "❌ Metrics are not enabled for one or more DDoS-protected public IP addresses.`n`n%TestResult%"
    }

    #endregion Assessment Logic

    #region Report Generation

    $portalPublicIpBrowseLink = 'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Network%2FpublicIPAddresses'
    $portalResourceBaseLink = 'https://portal.azure.com/#resource'

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

    if ($evaluationResults.Count -gt 0) {
        $tableRows = ''
        $formatTemplate = @'
| Public IP name | Resource Group | Subscription | IP address | Protection type | Associated resource | Associated VNET | Metrics enabled | Log Analytics workspace | Status |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
{0}
 
'@


        $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)"
            $resourceGroup   = Get-SafeMarkdown $result.ResourceGroup
            $subscription    = Get-SafeMarkdown $result.SubscriptionName
            $ipAddress       = Get-SafeMarkdown $result.IpAddress
            $protectionType  = $result.ProtectionType
            $associatedRes   = Get-SafeMarkdown $result.AssociatedResourceType
            $associatedVnet  = Get-SafeMarkdown $result.AssociatedVnet
            $metricsStatus   = if ($result.MetricsEnabled) { '✅ Yes' } else { '❌ No' }
            $workspace       = Get-SafeMarkdown $result.LogAnalyticsWorkspace
            $statusText      = if ($result.Status -eq 'Pass') { '✅ Pass' } else { '❌ Fail' }

            $tableRows += "| $pipLink | $resourceGroup | $subscription | $ipAddress | $protectionType | $associatedRes | $associatedVnet | $metricsStatus | $workspace | $statusText |`n"
        }

        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
    }

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

    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo

    #endregion Report Generation

    $params = @{
        TestId = '26885'
        Title  = 'Metrics are enabled for DDoS-protected public IPs'
        Status = $passed
        Result = $testResultMarkdown
    }

    Add-ZtTestResultDetail @params
}