diff --git a/src/powershell/tests/Test-Assessment.25535.md b/src/powershell/tests/Test-Assessment.25535.md new file mode 100644 index 000000000..72a362434 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25535.md @@ -0,0 +1,9 @@ +Azure Firewall is a cloud-native network security service that provides centralized inspection, logging, and enforcement for network traffic flowing between application workloads and external destinations. Routing outbound traffic through Azure Firewall enables organizations to apply consistent security controls such as threat intelligence filtering, intrusion detection and prevention, TLS inspection, and egress policy enforcement. In a secure network architecture, outbound traffic from workloads hosted in Azure virtual networks should be explicitly routed through Azure Firewall before reaching the internet or external services. VNET integrated workloads include VMs, AKS Node Pools, AKS Pods, App Service (VNet Integration Route All), Functions in VNet. This is typically achieved by configuring routing so that outbound traffic from workload subnets uses Azure Firewall as the next hop. Without this routing in place, outbound traffic may bypass the firewall entirely, reducing visibility and allowing traffic to leave the environment without inspection or policy enforcement.This check verifies that outbound traffic from in-scope workloads is routed through Azure Firewall by validating that the effective network routes direct outbound traffic to the firewall’s private IP address. If outbound traffic is not routed through Azure Firewall, the check fails because traffic may bypass centralized security controls, increasing the risk of data exfiltration, command-and-control communication, and undetected malicious activity. + +**Remediation action** + +- [Deploy and configure Azure Firewall using the Azure portal](https://learn.microsoft.com/en-us/azure/firewall/tutorial-firewall-deploy-portal#configure-routing) +- [Control outbound traffic with Azure Firewall ](https://learn.microsoft.com/en-us/azure/app-service/network-secure-outbound-traffic-azure-firewall) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25535.ps1 b/src/powershell/tests/Test-Assessment.25535.ps1 new file mode 100644 index 000000000..6d2d02180 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25535.ps1 @@ -0,0 +1,290 @@ +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() + + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + if ((Get-MgContext).Environment -ne 'Global') { + Write-PSFMessage "This test is only applicable to the Global environment." -Tag Test -Level VeryVerbose + return + } + + #region Azure Connection Verification + 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 NoAzureAccess + return + } + #endregion + + #region Data Collection + $azAccessToken = $accessToken.Token + $resourceManagementUrl = (Get-AzContext).Environment.ResourceManagerUrl + $subscriptions = Get-AzSubscription + + $firewalls = @() + $nicFindings = @() + + foreach ($sub in $subscriptions) { + + Set-AzContext -SubscriptionId $sub.Id | Out-Null + $subId = $sub.Id + + # Step 1: List Azure Firewalls + $fwListUri = $resourceManagementUrl.TrimEnd('/') + + "/subscriptions/$subId/providers/Microsoft.Network/azureFirewalls?api-version=2025-03-01" + + try { + $fwResp = Invoke-WebRequest -Uri $fwListUri -Authentication Bearer -Token $azAccessToken -ErrorAction Stop + } + catch { + Write-PSFMessage "Unable to list Azure Firewalls in subscription $($sub.Name)." -Tag Test -Level Warning + continue + } + + $fwItems = ($fwResp.Content | ConvertFrom-Json).value + if (-not $fwItems) { continue } + + # Step 2: Resolve Firewall Private IPs + foreach ($fw in $fwItems) { + + $fwDetailUri = $resourceManagementUrl.TrimEnd('/') + + "$($fw.id)?api-version=2025-03-01" + + try { + $fwDetailResp = Invoke-WebRequest -Uri $fwDetailUri -Authentication Bearer -Token $azAccessToken -ErrorAction Stop + $fwDetail = $fwDetailResp.Content | ConvertFrom-Json + } + catch { continue } + + foreach ($ipconfig in $fwDetail.properties.ipConfigurations) { + if ($ipconfig.properties.privateIPAddress) { + $firewalls += [PSCustomObject]@{ + FirewallName = $fwDetail.name + FirewallId = $fwDetail.id + PrivateIP = $ipconfig.properties.privateIPAddress + SubscriptionId = $subId + } + } + } + } + + if ($firewalls.Count -eq 0) { continue } + + # Step 3: List NICs + $nicListUri = $resourceManagementUrl.TrimEnd('/') + + "/subscriptions/$subId/providers/Microsoft.Network/networkInterfaces?api-version=2025-03-01" + + try { + $nicResp = Invoke-WebRequest -Uri $nicListUri -Authentication Bearer -Token $azAccessToken -ErrorAction Stop + } + catch { + Write-PSFMessage "Unable to list network interfaces in subscription $($sub.Name)." -Tag Test -Level Warning + continue + } + + $nics = ($nicResp.Content | ConvertFrom-Json).value + + # Step 4: Stage 1 - Launch all async effectiveRouteTable requests + $asyncOperations = @() + + foreach ($nic in $nics) { + foreach ($ipconfig in $nic.properties.ipConfigurations) { + + if (-not $ipconfig.properties.subnet?.id) { continue } + + $subnetId = $ipconfig.properties.subnet.id + if ($subnetId -match 'AzureFirewallSubnet|GatewaySubnet|AzureBastionSubnet') { + continue + } + + $rg = ($nic.id -split '/')[4] + $nicName = $nic.name + + $ertUri = $resourceManagementUrl.TrimEnd('/') + + "/subscriptions/$subId/resourceGroups/$rg/providers/Microsoft.Network/networkInterfaces/$nicName/effectiveRouteTable?api-version=2025-03-01" + + try { + $ertStart = Invoke-WebRequest -Uri $ertUri -Authentication Bearer -Token $azAccessToken -Method Post -ErrorAction Stop + $operationUri = $ertStart.Headers['Location'][0] + + $retryAfter = if ($ertStart.Headers['Retry-After']) { + [int]$ertStart.Headers['Retry-After'][0] + } else { 5 } + + $asyncOperations += @{ + OperationUri = $operationUri + Nic = $nic + RetryAfter = $retryAfter + Timestamp = Get-Date + } + } + catch { + Write-PSFMessage "Failed to initiate effectiveRouteTable request for NIC $nicName : $($_.Exception.Message)" -Tag Test -Level Warning + } + } + } + + if ($asyncOperations.Count -eq 0) { continue } + + Write-PSFMessage "Launched $($asyncOperations.Count) async effectiveRouteTable requests for subscription $($sub.Name)" -Tag Test -Level Verbose + + # Step 4: Stage 2 - Poll all operations in parallel + $completedOperations = @() + $maxRetries = 120 # ~10 minutes with 5 second intervals + + do { + $stillPending = @() + + foreach ($op in $asyncOperations) { + if ($op.Completed) { + $completedOperations += $op + continue + } + + try { + $ertPoll = Invoke-WebRequest -Uri $op.OperationUri -Authentication Bearer -Token $azAccessToken -ErrorAction Stop + + 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 + } + } + + $asyncOperations = $stillPending + + if ($asyncOperations.Count -gt 0) { + $maxRetries-- + if ($maxRetries -le 0) { + Write-PSFMessage "Timeout polling effectiveRouteTable operations. Processing $($asyncOperations.Count) incomplete operations." -Tag Test -Level Warning + $completedOperations += $asyncOperations + break + } + + $retryAfter = ($asyncOperations | Select-Object -First 1).RetryAfter + Write-PSFMessage "Polling $($asyncOperations.Count) pending operations..." -Tag Test -Level Verbose + Start-Sleep -Seconds $retryAfter + } + + } while ($asyncOperations.Count -gt 0) + + # Step 4: Stage 3 - Process completed operations + foreach ($op in $completedOperations) { + if ($op.Error -or -not $op.Routes) { + $nicFindings += [PSCustomObject]@{ + NicName = $op.Nic.name + NicId = $op.Nic.id + NextHopType = 'Unknown' + NextHopIp = '' + IsCompliant = $false + } + continue + } + + $defaultRoute = $op.Routes | Where-Object { + $_.state -eq 'Active' -and + $_.source -eq 'User' -and + (($_.addressPrefix -eq '0.0.0.0/0') -or ($_.addressPrefix -contains '0.0.0.0/0')) + } | Select-Object -First 1 + + if (-not $defaultRoute) { + $nicFindings += [PSCustomObject]@{ + NicName = $op.Nic.name + NicId = $op.Nic.id + NextHopType = 'Internet' + NextHopIp = '' + IsCompliant = $false + } + continue + } + + $fwMatch = $firewalls | Where-Object { + # Handle nextHopIpAddress being either a string or an array + $nextHop = $defaultRoute.nextHopIpAddress + ( ($nextHop -eq $_.PrivateIP) -or ($nextHop -contains $_.PrivateIP) ) + } | Select-Object -First 1 + + $nicFindings += [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 = $op.Nic.name + NicId = $op.Nic.id + RouteSource = $defaultRoute.source + RouteState = $defaultRoute.state + AddressPrefix = ($defaultRoute.addressPrefix -join ',') + NextHopType = $defaultRoute.nextHopType + NextHopIpAddress = ($defaultRoute.nextHopIpAddress -join ',') + IsCompliant = ($fwMatch -ne $null) + } + } + } + #endregion + + #region Assessment Logic + if ($nicFindings.Count -eq 0) { + Add-ZtTestResultDetail -SkippedBecause NoResults + return + } + + $passed = ($nicFindings | Where-Object { -not $_.IsCompliant }).Count -eq 0 + + $testResultMarkdown = if ($passed) { + "Outbound traffic is routed through Azure Firewall.`n`n%TestResult%" + } else { + "Outbound traffic is not routed through Azure Firewall.`n`n%TestResult%" + } + #endregion + + #region Result Reporting + $mdInfo = "## Outbound traffic routing evidence`n`n" + $mdInfo += "| Azure firewall | Firewall private ip | Network interface | Source | State | Address prefix | Next hop type | Next hop ip | Status |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |`n" + + foreach ($item in $nicFindings | Sort-Object NicName) { + $icon = if ($item.IsCompliant) { '✅' } else { '❌' } + $safeFirewallName = Get-SafeMarkdown -Text ($item.FirewallName -or 'N/A') + $safeName = Get-SafeMarkdown -Text ($item.NicName -or 'N/A') + $fwIp = ($item.FirewallPrivateIp -or 'N/A') + $routeSource = ($item.RouteSource -or '') + $routeState = ($item.RouteState -or '') + $addressPrefix = ($item.AddressPrefix -or '') + $nextHopType = ($item.NextHopType -or '') + $nextHopIp = ($item.NextHopIpAddress -or '') + + $mdInfo += "| $safeFirewallName | $fwIp | [$safeName](https://portal.azure.com/#@/resource$($item.NicId)) | $routeSource | $routeState | $addressPrefix | $nextHopType | $nextHopIp | $icon |`n" + } + + $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $mdInfo + Add-ZtTestResultDetail -TestId '25535' -Status $passed -Result $testResultMarkdown + #endregion +}