-
Notifications
You must be signed in to change notification settings - Fork 121
Network-25535 : Outbound traffic from VNET integrated workloads is routed through Azure Firewall #746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
kshitiz-prog
wants to merge
3
commits into
main
Choose a base branch
from
Feature-25535
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Network-25535 : Outbound traffic from VNET integrated workloads is routed through Azure Firewall #746
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
|
||
| <!--- Results ---> | ||
| %TestResult% | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') { | ||
kshitiz-prog marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.