tests/Test-Assessment.25535.ps1
|
<#
.SYNOPSIS Test to check if outbound traffic from VNET integrated workloads is routed through Azure Firewall .NOTES Some Azure Firewall documentation links may return 404 errors. This test uses the Azure REST API version 2025-03-01. #> function Test-Assessment-25535 { [ZtTest( Category = 'Azure Network Security', ImplementationCost = 'Medium', MinimumLicense = ('Azure_Firewall_Basic', 'Azure_Firewall_Standard', 'Azure_Firewall_Premium'), Pillar = 'Network', RiskLevel = 'High', SfiPillar = 'Protect networks', TenantType = ('Workforce', 'External'), TestId = 25535, Title = 'Outbound traffic from VNET integrated workloads is routed through Azure Firewall', UserImpact = 'Low' )] [CmdletBinding()] param() #region Helper Functions function Get-FirewallPrivateIP { param([string]$SubscriptionId) $firewalls = @() $fwListUri = "/subscriptions/$SubscriptionId/providers/Microsoft.Network/azureFirewalls?api-version=2025-03-01" try { $fwResp = Invoke-AzRestMethod -Path $fwListUri -Method GET $fwItems = ($fwResp.Content | ConvertFrom-Json).value } catch { Write-PSFMessage "Unable to list Azure Firewalls in subscription $SubscriptionId." -Tag Test -Level Warning return $firewalls } foreach ($fw in $fwItems) { try { $fwDetailResp = Invoke-AzRestMethod -Path "$($fw.id)?api-version=2025-03-01" -Method GET $fwDetail = $fwDetailResp.Content | ConvertFrom-Json foreach ($ipconfig in $fwDetail.properties.ipConfigurations) { if ($ipconfig.properties.privateIPAddress) { $firewalls += [PSCustomObject]@{ FirewallName = $fwDetail.name FirewallId = $fwDetail.id PrivateIP = $ipconfig.properties.privateIPAddress SubscriptionId = $SubscriptionId } } } } catch { # Firewall exists but details could not be read (RBAC or transient issue). # Skipping is intentional to avoid failing the whole test. } } return $firewalls } function Get-WorkloadNicOperation { param( [object]$Subscription, [string]$SubscriptionId ) $asyncOperations = @() $nicListUri = "/subscriptions/$SubscriptionId/providers/Microsoft.Network/networkInterfaces?api-version=2025-03-01" try { # Azure REST list APIs are paginated. # Handling nextLink is required to avoid missing NICs in large subscriptions. $nics = @() $nicResp = Invoke-AzRestMethod -Path $nicListUri -Method GET $nicPage = $nicResp.Content | ConvertFrom-Json if ($nicPage.value) { $nics += $nicPage.value } $nextLink = $nicPage.nextLink while ($nextLink) { $nicResp = Invoke-AzRestMethod -Uri $nextLink -Method GET $nicPage = $nicResp.Content | ConvertFrom-Json if ($nicPage.value) { $nics += $nicPage.value } $nextLink = $nicPage.nextLink } } catch { Write-PSFMessage "Unable to list network interfaces in subscription $($Subscription.Name)." -Tag Test -Level Warning return $asyncOperations } foreach ($nic in $nics) { foreach ($ipconfig in $nic.properties.ipConfigurations) { $subnetId = $ipconfig.properties.subnet?.id if (-not $subnetId) { continue } if ($subnetId -match 'AzureFirewallSubnet|GatewaySubnet|AzureBastionSubnet') { continue } $rg = ($nic.id -split '/')[4] $ertUri = "/subscriptions/$SubscriptionId/resourceGroups/$rg/providers/Microsoft.Network/networkInterfaces/$($nic.name)/effectiveRouteTable?api-version=2025-03-01" try { $ertStart = Invoke-AzRestMethod -Path $ertUri -Method POST $retryAfter = if ($ertStart.Headers.'Retry-After') { [int]$ertStart.Headers.'Retry-After'[0] } else { 5 } # effectiveRouteTable is a documented async ARM operation. # It returns 202 with a Location header. # No defensive null-check is added so unexpected API behavior is visible. $asyncOperations += @{ OperationUri = $ertStart.Headers.Location[0] Nic = $nic RetryAfter = $retryAfter SubnetId = $subnetId SubscriptionId = $Subscription.Id SubscriptionName = $Subscription.Name } } catch { Write-PSFMessage "Failed to initiate effectiveRouteTable request for NIC $($nic.name): $($_.Exception.Message)" -Tag Test -Level Warning } } } return $asyncOperations } function Wait-AsyncOperation { param([array]$Operations) $completedOperations = @() $pendingOperations = $Operations $maxRetries = 120 # ~10 minutes with 5 second intervals while ($pendingOperations.Count -gt 0) { $stillPending = @() foreach ($op in $pendingOperations) { try { $ertPoll = Invoke-AzRestMethod -Uri $op.OperationUri -Method GET # The effectiveRouteTable async API returns 202 while in progress. # Any non-202 response indicates the operation has completed. # This logic is intentionally kept simple and unchanged. if ($ertPoll.StatusCode -ne 202) { $op.Routes = ($ertPoll.Content | ConvertFrom-Json).value $op.Completed = $true $completedOperations += $op } else { $stillPending += $op } } catch { Write-PSFMessage "Error polling operation for NIC $($op.Nic.name): $($_.Exception.Message)" -Tag Test -Level Warning $op.Completed = $true $op.Error = $true $completedOperations += $op } } $pendingOperations = $stillPending if ($pendingOperations.Count -eq 0) { break } $maxRetries-- if ($maxRetries -le 0) { Write-PSFMessage "Timeout polling effectiveRouteTable operations. Processing $($pendingOperations.Count) incomplete operations." -Tag Test -Level Warning $completedOperations += $pendingOperations break } Write-PSFMessage "Polling $($pendingOperations.Count) pending operations..." -Tag Test -Level Verbose Start-Sleep -Seconds ($pendingOperations[0].RetryAfter) } return $completedOperations } function ConvertTo-NicFinding { param( [object]$Operation, [array]$Firewalls ) # Handle error or missing routes if ($Operation.Error -or -not $Operation.Routes) { return [PSCustomObject]@{ NicName = $Operation.Nic.name NicId = $Operation.Nic.id NextHopType = 'Unknown' NextHopIpAddress = '' IsCompliant = $false SubscriptionId = $Operation.SubscriptionId SubscriptionName = $Operation.SubscriptionName SubnetId = $Operation.SubnetId FirewallPrivateIp = 'N/A' } } # addressPrefix can be returned as string or array. # Both checks are kept intentionally to support both shapes. # Find user-defined default route $defaultRoute = $Operation.Routes | Where-Object { $_.state -eq 'Active' -and $_.source -eq 'User' -and $_.nextHopType -eq 'VirtualAppliance' -and (($_.addressPrefix -contains '0.0.0.0/0') -or ($_.addressPrefix -eq '0.0.0.0/0')) } | Select-Object -First 1 if (-not $defaultRoute) { return [PSCustomObject]@{ NicName = $Operation.Nic.name NicId = $Operation.Nic.id NextHopType = 'Internet' NextHopIpAddress = '' IsCompliant = $false SubscriptionId = $Operation.SubscriptionId SubscriptionName = $Operation.SubscriptionName SubnetId = $Operation.SubnetId FirewallPrivateIp = 'N/A' } } # Match next hop to firewall private IP $nextHop = $defaultRoute.nextHopIpAddress # This logic is intentionally NOT changed. # nextHopIpAddress may be string or array. # Using both -eq and -contains keeps backward compatibility # and avoids forcing assumptions about the API response type. $fwMatch = $Firewalls | Where-Object { ($nextHop -eq $_.PrivateIP) -or ($nextHop -contains $_.PrivateIP) } | Select-Object -First 1 return [PSCustomObject]@{ FirewallName = if ($fwMatch) { $fwMatch.FirewallName } else { 'N/A' } FirewallId = if ($fwMatch) { $fwMatch.FirewallId } else { 'N/A' } FirewallPrivateIp = if ($fwMatch) { $fwMatch.PrivateIP } else { 'N/A' } NicName = $Operation.Nic.name NicId = $Operation.Nic.id RouteSource = $defaultRoute.source RouteState = $defaultRoute.state AddressPrefix = ($defaultRoute.addressPrefix -join ',') NextHopType = $defaultRoute.nextHopType NextHopIpAddress = ($defaultRoute.nextHopIpAddress -join ',') IsCompliant = ($null -ne $fwMatch) SubscriptionId = $Operation.SubscriptionId SubscriptionName = $Operation.SubscriptionName SubnetId = $Operation.SubnetId } } #endregion Helper Functions #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose if ((Get-AzContext).Environment.name -ne 'AzureCloud') { Write-PSFMessage "This test is only applicable to the Global environment." -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotSupported return } 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 } $subscriptions = Get-AzSubscription $firewalls = @() $nicFindings = @() foreach ($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.Id | Out-Null # Collect firewall private IPs $firewalls += Get-FirewallPrivateIP -SubscriptionId $sub.Id if ($firewalls.Count -eq 0) { continue } # Launch async operations for workload NICs $asyncOperations = Get-WorkloadNicOperation -Subscription $sub -SubscriptionId $sub.Id if ($asyncOperations.Count -eq 0) { continue } Write-PSFMessage "Launched $($asyncOperations.Count) async effectiveRouteTable requests for subscription $($sub.Name)" -Tag Test -Level Verbose # Wait for all operations to complete $completedOperations = Wait-AsyncOperation -Operations $asyncOperations # Process results into findings foreach ($op in $completedOperations) { $nicFindings += ConvertTo-NicFinding -Operation $op -Firewalls $firewalls } } #endregion Data Collection #region Assessment Logic if ($nicFindings.Count -eq 0) { Write-PSFMessage "No workload NICs found to evaluate." -Tag Test -Level Verbose return } $nonCompliantCount = @($nicFindings | Where-Object { -not $_.IsCompliant }).Count if ($nonCompliantCount -eq 0) { $passed = $true $testResultMarkdown = "✅ Outbound traffic is routed through Azure Firewall.`n`n%TestResult%" } else { $passed = $false $testResultMarkdown = "❌ Outbound traffic is not routed through Azure Firewall.`n`n%TestResult%" } #endregion Assessment Logic #region Report Generation $mdInfo = "## Outbound traffic routing evidence`n`n" $mdInfo += "| Subscription | Network interface | Subnet | Azure firewall private IP | Default route next hop type | Next hop IP address | Result |`n" $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- | :--- |`n" foreach ($item in $nicFindings | Sort-Object SubscriptionName, NicName) { $icon = if ($item.IsCompliant) { '✅' } else { '❌' } $subLink = "https://portal.azure.com/#resource/subscriptions/$($item.SubscriptionId)" $subName = Get-SafeMarkdown -Text $item.SubscriptionName $nicLink = "https://portal.azure.com/#resource$($item.NicId)" $nicName = Get-SafeMarkdown -Text $item.NicName $subnetLink = "https://portal.azure.com/#resource$($item.SubnetId)" $subnetName = Get-SafeMarkdown -Text (($item.SubnetId -split '/')[-1]) $fwIp = if ($item.FirewallPrivateIp) { $item.FirewallPrivateIp } else { 'N/A' } $nextHopType = if ($item.NextHopType) { $item.NextHopType } else { 'None' } $nextHopIp = if ($item.NextHopIpAddress) { $item.NextHopIpAddress } else { '' } $mdInfo += "| [$subName]($subLink) | [$nicName]($nicLink) | [$subnetName]($subnetLink) | $fwIp | $nextHopType | $nextHopIp | $icon |`n" } $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $mdInfo #endregion Report Generation $params = @{ TestId = '25535' Title = 'Outbound traffic from VNET integrated workloads is routed through Azure Firewall' Status = $passed Result = $testResultMarkdown } Add-ZtTestResultDetail @params } |