diff --git a/src/powershell/tests/Test-Assessment.25375.md b/src/powershell/tests/Test-Assessment.25375.md new file mode 100644 index 0000000000..b1f381dd09 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25375.md @@ -0,0 +1,12 @@ +Global Secure Access requires specific Microsoft Entra licenses to function, including Microsoft Entra Internet Access and Microsoft Entra Private Access, both of which require Microsoft Entra ID P1 as a prerequisite. Without valid GSA licenses provisioned in the tenant, administrators cannot configure traffic forwarding profiles, security policies, or remote network connections. If licenses exist but are not assigned to users, those users will not have their traffic routed through the Global Secure Access service, leaving them unprotected by the security controls configured in the platform. Threat actors targeting unprotected users can bypass web content filtering, threat protection, and conditional access policies that would otherwise apply through GSA. Additionally, if licenses are assigned but the subscription has expired or been suspended, the entire GSA infrastructure becomes non-functional, creating a sudden security gap where previously protected traffic flows unmonitored. This check verifies that valid GSA licenses exist in the tenant with an enabled capability status and that those licenses are actively assigned to users who require protection through the Global Secure Access service. + +**Remediation action** + +- [Review GSA licensing requirements and purchase appropriate licenses](https://learn.microsoft.com/en-us/entra/global-secure-access/overview-what-is-global-secure-access#licensing-overview) +- [Assign licenses to users through Microsoft Entra admin center](https://learn.microsoft.com/en-us/entra/fundamentals/license-users-groups) +- [Use group-based licensing for easier management at scale](https://learn.microsoft.com/en-us/entra/fundamentals/concept-group-based-licensing) +- [Monitor license utilization through Microsoft 365 admin center](https://admin.microsoft.com/Adminportal/Home#/licenses) +- [Review Microsoft Entra Suite as an alternative that includes both Internet Access and Private Access](https://learn.microsoft.com/en-us/entra/fundamentals/whats-new#microsoft-entra-suite) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 new file mode 100644 index 0000000000..12ab82cd14 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -0,0 +1,339 @@ +<# +.SYNOPSIS + Validates that GSA licenses are available in the tenant and assigned to users. + +.DESCRIPTION + This test checks whether Global Secure Access (GSA) licenses are provisioned in the tenant + and actively assigned to users. It verifies: + - GSA service plans exist in tenant subscribed SKUs + - Licenses have capabilityStatus = "Enabled" + - Licenses are assigned to at least one user + - Service plans are not disabled for assigned users + +.NOTES + Test ID: 25375 + Category: Global Secure Access + Required API: subscribedSkus (beta) + Required Database: User table + GSA Service Plan IDs: + - Entra_Premium_Internet_Access: 8d23cb83-ab07-418f-8517-d7aca77307dc + - Entra_Premium_Private_Access: f057aab1-b184-49b2-85c0-881b02a405c5 +#> + +function Test-Assessment-25375 { + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'Low', + MinimumLicense = ('Entra_Premium_Internet_Access', 'Entra_Premium_Private_Access'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce'), + TestId = 25375, + Title = 'GSA Licenses are available in the tenant and assigned to users', + UserImpact = 'Low' + )] + [CmdletBinding()] + param( + $Database + ) + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking GSA license availability and assignment' + Write-ZtProgress -Activity $activity -Status 'Querying tenant licenses' + + # GSA Service Plan IDs + $gsaServicePlanIds = @{ + InternetAccess = '8d23cb83-ab07-418f-8517-d7aca77307dc' # Entra_Premium_Internet_Access + PrivateAccess = 'f057aab1-b184-49b2-85c0-881b02a405c5' # Entra_Premium_Private_Access + } + + $skuCmdletFailed = $false + $userCmdletFailed = $false + $subscribedSkus = @() + $userLicenses = @() + + # Query 1: Retrieve tenant licenses with GSA service plans + try { + $subscribedSkus = Invoke-ZtGraphRequest -RelativeUri 'subscribedSkus' -ApiVersion beta -ErrorAction Stop + } + catch { + $skuCmdletFailed = $true + Write-PSFMessage "Failed to retrieve subscribed SKUs: $_" -Tag Test -Level Warning + } + + Write-ZtProgress -Activity $activity -Status 'Querying user license assignments' + + # Query 2: Retrieve all users with assigned licenses from database + try { + $sqlUsers = @" +SELECT + u.id, + u.displayName, + u.userPrincipalName, + unnest(u.assignedLicenses).skuId::VARCHAR AS skuId, + unnest(u.assignedLicenses).disabledPlans AS disabledPlans +FROM "User" u +WHERE len(u.assignedLicenses) > 0 +"@ + $userLicenses = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlUsers -AsCustomObject -ErrorAction Stop) + # Filter out any records with null IDs + $userLicenses = @($userLicenses | Where-Object { $_.id }) + } + catch { + $userCmdletFailed = $true + Write-PSFMessage "Failed to retrieve users: $_" -Tag Test -Level Warning + } + #endregion Data Collection + + #region Assessment Logic + $testResultMarkdown = '' + $passed = $false + $customStatus = $null + + # Handle any query failure - cannot determine license status + if ($skuCmdletFailed -or $userCmdletFailed) { + Write-PSFMessage "Unable to retrieve GSA license data due to query failure" -Tag Test -Level Warning + $customStatus = 'Investigate' + $testResultMarkdown = "⚠️ Unable to determine GSA license availability and assignment due to query failure, connection issues, or insufficient permissions.`n`n" + + Add-ZtTestResultDetail -TestId '25375' -Title 'GSA Licenses are available in the tenant and assigned to users' -Status $false -Result $testResultMarkdown -CustomStatus $customStatus + return + } + + # Filter SKUs containing GSA service plans + $gsaSkus = @($subscribedSkus | Where-Object { + $_.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds.Values } + }) + + # Check if GSA licenses exist and are enabled + $enabledGsaSkus = @($gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' }) + + if ($gsaSkus.Count -eq 0 -or $enabledGsaSkus.Count -eq 0) { + # No GSA licenses available or not enabled - skip test + Write-PSFMessage 'No GSA licenses are available in this tenant.' -Tag Test -Level Verbose + Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No GSA licenses are available in this tenant.' + return + } + + Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' + + # Build SKU ID to SKU mapping and pre-filter service plans for performance + $gsaSkuIds = @{} + $internetAccessPlansBySku = @{} + $privateAccessPlansBySku = @{} + + foreach ($sku in $enabledGsaSkus) { + $skuIdString = $sku.SkuId.ToString().ToLower() + $gsaSkuIds[$skuIdString] = $sku + + # Pre-filter service plans to avoid repeated Where-Object calls + $internetPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } + if ($internetPlan) { + $internetAccessPlansBySku[$skuIdString] = $internetPlan + } + + $privatePlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } + if ($privatePlan) { + $privateAccessPlansBySku[$skuIdString] = $privatePlan + } + } + + # Count users with GSA licenses assigned + $usersWithInternetAccess = [System.Collections.Generic.List[object]]::new() + $usersWithPrivateAccess = [System.Collections.Generic.List[object]]::new() + $usersWithAnyGsa = [System.Collections.Generic.List[object]]::new() + + # Group licenses by user (since query returns one row per license) + $userGroups = $userLicenses | Group-Object -Property id + + foreach ($userGroup in $userGroups) { + $userId = $userGroup.Name + $userLicenseRecords = $userGroup.Group + $userDisplayName = $userLicenseRecords[0].displayName + $userPrincipalName = $userLicenseRecords[0].userPrincipalName + + $hasInternetAccess = $false + $hasPrivateAccess = $false + + foreach ($licenseRecord in $userLicenseRecords) { + if (-not $licenseRecord.skuId) { continue } + + $userSkuId = $licenseRecord.skuId.ToString().ToLower() + + if ($gsaSkuIds.ContainsKey($userSkuId)) { + $disabledPlans = if ($licenseRecord.disabledPlans) { $licenseRecord.disabledPlans } else { @() } + + # Check if Internet Access service plan is enabled + if ($internetAccessPlansBySku.ContainsKey($userSkuId)) { + $internetPlan = $internetAccessPlansBySku[$userSkuId] + if ($internetPlan.ServicePlanId -notin $disabledPlans) { + $hasInternetAccess = $true + } + } + + # Check if Private Access service plan is enabled + if ($privateAccessPlansBySku.ContainsKey($userSkuId)) { + $privatePlan = $privateAccessPlansBySku[$userSkuId] + if ($privatePlan.ServicePlanId -notin $disabledPlans) { + $hasPrivateAccess = $true + } + } + } + } + + # Create user object for display + $userObj = [PSCustomObject]@{ + Id = $userId + DisplayName = $userDisplayName + UserPrincipalName = $userPrincipalName + } + + if ($hasInternetAccess) { + $usersWithInternetAccess.Add($userObj) + } + if ($hasPrivateAccess) { + $usersWithPrivateAccess.Add($userObj) + } + if ($hasInternetAccess -or $hasPrivateAccess) { + $usersWithAnyGsa.Add($userObj) + } + } + + $gsaUserCount = $usersWithAnyGsa.Count + + # Evaluate test result + if ($gsaUserCount -eq 0) { + # Licenses exist and enabled but not assigned to any user - fail + $passed = $false + $testResultMarkdown = "❌ GSA licenses are available in the tenant but not assigned to any user.`n`n%TestResult%" + } + else { + # Licenses exist, enabled, and assigned to at least one user - pass + $passed = $true + $testResultMarkdown = "✅ GSA licenses are available and assigned to at least one user.`n`n%TestResult%" + } + #endregion Assessment Logic + + #region Report Generation + # Build detailed information if we have valid license data + $mdInfo = '' + + if ($null -ne $enabledGsaSkus -and $enabledGsaSkus.Count -gt 0) { + $reportTitle = 'Licenses' + $portalLink = 'https://admin.microsoft.com/Adminportal/Home#/licenses' + + $formatTemplate = @' + +## [{0}]({1}) + +**GSA License Summary:** + +| SKU Name | Status | Available | Assigned | +| :------- | :----- | --------: | -------: | +{2} + +**GSA Service Plans Detected:** + +| Service Plan | SKU | +| :----------- | :-- | +{3} + +**User Assignment Summary:** + +| Metric | Value | +| :----- | ----: | +{4} + +{5} +'@ + + # Build SKU table + $skuTableRows = '' + foreach ($sku in $enabledGsaSkus) { + $skuName = Get-SafeMarkdown -Text $sku.SkuPartNumber + $status = Get-SafeMarkdown -Text $sku.CapabilityStatus + $available = $sku.PrepaidUnits.Enabled + $assigned = $sku.ConsumedUnits + + $skuTableRows += "| $skuName | $status | $available | $assigned |`n" + } + + # Build service plan table + $servicePlanTableRows = '' + foreach ($sku in $enabledGsaSkus) { + $gsaPlans = $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds.Values } + foreach ($plan in $gsaPlans) { + $planName = Get-SafeMarkdown -Text $plan.ServicePlanName + $skuName = Get-SafeMarkdown -Text $sku.SkuPartNumber + + $servicePlanTableRows += "| $planName | $skuName |`n" + } + } + + # Build user assignment summary + $assignmentSummary = "| Users with GSA Internet Access | $($usersWithInternetAccess.Count) |`n" + $assignmentSummary += "| Users with GSA Private Access | $($usersWithPrivateAccess.Count) |`n" + $assignmentSummary += "| Total users with any GSA license | $gsaUserCount |`n" + + # Build user list (truncate at 10) + $userListSection = '' + if ($gsaUserCount -gt 0) { + if ($gsaUserCount -gt 10) { + $userListSection += "**Users with GSA licenses (Showing 10 of $gsaUserCount):**`n`n" + } + else { + $userListSection += "**Users with GSA licenses:**`n`n" + } + + $userListSection += "| Display name | User principal name | Internet Access | Private Access |`n" + $userListSection += "| :----------- | :------------------ | :-------------- | :------------- |`n" + + # Build HashSets for efficient ID lookups + if ($usersWithInternetAccess.Count -gt 0) { + $internetAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithInternetAccess.Id)) + } else { + $internetAccessIds = [System.Collections.Generic.HashSet[string]]::new() + } + + if ($usersWithPrivateAccess.Count -gt 0) { + $privateAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithPrivateAccess.Id)) + } else { + $privateAccessIds = [System.Collections.Generic.HashSet[string]]::new() + } + + $displayUsers = $usersWithAnyGsa | Select-Object -First 10 + foreach ($user in $displayUsers) { + $displayName = Get-SafeMarkdown -Text $user.DisplayName + $upn = Get-SafeMarkdown -Text $user.UserPrincipalName + $hasInternet = if ($internetAccessIds.Contains($user.Id)) { '✅' } else { '❌' } + $hasPrivate = if ($privateAccessIds.Contains($user.Id)) { '✅' } else { '❌' } + + $userListSection += "| $displayName | $upn | $hasInternet | $hasPrivate |`n" + } + + if ($gsaUserCount -gt 10) { + $userListSection += "| ... | | | |`n`n" + $userListSection += "View all users in [Microsoft 365 admin center - Licenses](https://admin.microsoft.com/Adminportal/Home#/licenses)" + } + } + + $mdInfo = $formatTemplate -f $reportTitle, $portalLink, $skuTableRows, $servicePlanTableRows, $assignmentSummary, $userListSection + } + + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + $params = @{ + TestId = '25375' + Title = 'GSA Licenses are available in the tenant and assigned to users' + Status = $passed + Result = $testResultMarkdown + } + if ($customStatus) { + $params.CustomStatus = $customStatus + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.25533.MD b/src/powershell/tests/Test-Assessment.25533.MD new file mode 100644 index 0000000000..49acaf249a --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.MD @@ -0,0 +1,16 @@ +DDoS attacks remain a major security and availability risk for customers with cloud-hosted applications. These attacks aim to overwhelm an application's compute, network, or memory resources, rendering it inaccessible to legitimate users. Any public-facing endpoint exposed to the internet can be a potential target for a DDoS attack. Azure DDoS Protection provides always-on monitoring and automatic mitigation against DDoS attacks targeting public-facing workloads. Without Azure DDoS Protection (Network Protection or IP Protection), public IP addresses for services such as Application Gateways, Load Balancers, Azure Firewalls, Azure Bastion, Virtual Network Gateways, or virtual machines remain exposed to DDoS attacks that can overwhelm network bandwidth, exhaust system resources, and cause complete service unavailability. These attacks can disrupt access for legitimate users, degrade performance, and create cascading outages across dependent services. Azure DDoS Protection can be enabled in two ways: +DDoS IP Protection — Protection is explicitly enabled on individual public IP addresses by setting ddosSettings.protectionMode to Enabled. +DDoS Network Protection — Protection is enabled at the VNET level through a DDoS Protection Plan. Public IP addresses associated with resources in that VNET inherit the protection when ddosSettings.protectionMode is set to VirtualNetworkInherited. However, a public IP address with VirtualNetworkInherited is not protected unless the VNET actually has a DDoS Protection Plan associated and enableDdosProtection set to true. +This check verifies that every public IP address is actually covered by DDoS protection, either through DDoS IP Protection enabled directly on the public IP, or through DDoS Network Protection enabled on the VNET that the public IP's associated resource resides in. If this check does not pass, your workloads remain significantly more vulnerable to downtime, customer impact, and operational disruption during an attack. + +**Remediation action** + +To enable DDoS Protection for public IP addresses, refer to the following Microsoft Learn documentation: + +- [Azure DDoS Protection overview](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview) +- [Quickstart: Create and configure Azure DDoS Network Protection using Azure portal](https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-protection) +- [Quickstart: Create and configure Azure DDoS IP Protection using Azure portal](https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-ip-protection-portal) +- [Azure DDoS Protection SKU comparison](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-sku-comparison) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25533.ps1 b/src/powershell/tests/Test-Assessment.25533.ps1 new file mode 100644 index 0000000000..0d000da1cd --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -0,0 +1,400 @@ +function Test-Assessment-25533 { + [ZtTest( + Category = 'Azure Network Security', + ImplementationCost = 'Low', + MinimumLicense = ('DDoS_Network_Protection', 'DDoS_IP_Protection'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce', 'External'), + TestId = 25533, + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking DDoS Protection is enabled for all Public IP Addresses in VNETs' + + # 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 + } + + Write-ZtProgress -Activity $activity -Status 'Querying Azure Resource Graph' + + # Query all Public IP addresses with their DDoS protection settings + $argQuery = @" +Resources +| where type =~ 'microsoft.network/publicipaddresses' | join kind=leftouter (ResourceContainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionName=name, subscriptionId ) on subscriptionId | project PublicIpName = name, PublicIpId = id, SubscriptionName = subscriptionName, SubscriptionId = subscriptionId, Location = location, ProtectionMode = tostring(properties.ddosSettings.protectionMode), ipConfigId = tolower(properties.ipConfiguration.id) +"@ + + $publicIps = @() + try { + $publicIps = @(Invoke-ZtAzureResourceGraphRequest -Query $argQuery) + Write-PSFMessage "ARG Query returned $($publicIps.Count) records" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Azure Resource Graph query failed: $($_.Exception.Message)" -Tag Test -Level Warning + Add-ZtTestResultDetail -SkippedBecause NotSupported + return + } + + # Skip if no public IPs found + if ($publicIps.Count -eq 0) { + Write-PSFMessage 'No Public IP addresses found.' -Tag Test -Level Verbose + Add-ZtTestResultDetail -SkippedBecause NotApplicable + return + } + + # Build unified resource-to-VNET mapping cache for all supported resource types. + # Each query populates the same hashtable keyed by resource ID (lowercase). + # $resourceQueryFailed tracks whether any prerequisite query failed so we can + # avoid marking affected IPs as non-compliant due to transient ARG/RBAC issues. + Write-ZtProgress -Activity $activity -Status 'Querying resource-to-VNET associations' + + $resourceVnetCache = @{} + $resourceQueryFailed = $false + + # NICs — subnet in ipConfigurations[].properties.subnet.id + $nicQuery = @" +Resources +| where type =~ 'microsoft.network/networkinterfaces' +| mvexpand ipConfigurations = properties.ipConfigurations +| project + resourceId = tolower(id), + subnetId = tolower(ipConfigurations.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct resourceId, vnetId +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $nicQuery) | ForEach-Object { $resourceVnetCache[$_.resourceId] = $_.vnetId } + Write-PSFMessage "NIC query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Network Interface query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Application Gateways — subnet in gatewayIPConfigurations[].properties.subnet.id + $appGwQuery = @" +Resources +| where type =~ 'microsoft.network/applicationgateways' +| mvexpand gwIpConfig = properties.gatewayIPConfigurations +| project + resourceId = tolower(id), + subnetId = tolower(gwIpConfig.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct resourceId, vnetId +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $appGwQuery) | ForEach-Object { $resourceVnetCache[$_.resourceId] = $_.vnetId } + Write-PSFMessage "Application Gateway query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Application Gateway query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Azure Firewalls — subnet in ipConfigurations[].properties.subnet.id + $firewallQuery = @" +Resources +| where type =~ 'microsoft.network/azurefirewalls' +| mvexpand ipConfig = properties.ipConfigurations +| project + resourceId = tolower(id), + subnetId = tolower(ipConfig.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct resourceId, vnetId +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $firewallQuery) | ForEach-Object { $resourceVnetCache[$_.resourceId] = $_.vnetId } + Write-PSFMessage "Azure Firewall query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Azure Firewall query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Bastion Hosts — subnet in ipConfigurations[].properties.subnet.id + $bastionQuery = @" +Resources +| where type =~ 'microsoft.network/bastionhosts' +| mvexpand ipConfig = properties.ipConfigurations +| project + resourceId = tolower(id), + subnetId = tolower(ipConfig.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct resourceId, vnetId +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $bastionQuery) | ForEach-Object { $resourceVnetCache[$_.resourceId] = $_.vnetId } + Write-PSFMessage "Bastion Host query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Bastion Host query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Virtual Network Gateways — subnet in ipConfigurations[].properties.subnet.id + $vnetGwQuery = @" +Resources +| where type =~ 'microsoft.network/virtualnetworkgateways' +| mvexpand ipConfig = properties.ipConfigurations +| project + resourceId = tolower(id), + subnetId = tolower(ipConfig.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct resourceId, vnetId +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $vnetGwQuery) | ForEach-Object { $resourceVnetCache[$_.resourceId] = $_.vnetId } + Write-PSFMessage "VNet Gateway query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Virtual Network Gateway query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Load Balancers — public LBs have no subnet on frontendIPConfigurations; + # resolve VNET by tracing backend pool NICs (already cached above). + $lbQuery = @" +Resources +| where type =~ 'microsoft.network/loadbalancers' +| mvexpand backendPool = properties.backendAddressPools +| mvexpand backendIpConfig = backendPool.properties.backendIPConfigurations +| project + lbId = tolower(id), + nicIpConfigId = tolower(backendIpConfig.id) +| extend nicId = tolower(substring(nicIpConfigId, 0, indexof(nicIpConfigId, '/ipconfigurations/'))) +| distinct lbId, nicId +"@ + try { + $lbNicMappings = @(Invoke-ZtAzureResourceGraphRequest -Query $lbQuery) + foreach ($mapping in $lbNicMappings) { + $nicVnet = $resourceVnetCache[$mapping.nicId] + if ($nicVnet -and -not $resourceVnetCache.ContainsKey($mapping.lbId)) { + $resourceVnetCache[$mapping.lbId] = $nicVnet + } + } + Write-PSFMessage "Load Balancer query: $($resourceVnetCache.Count) records in cache" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Load Balancer query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $resourceQueryFailed = $true + } + + # Query VNET DDoS protection settings + Write-ZtProgress -Activity $activity -Status 'Querying VNET DDoS settings' + + $vnetDdosCache = @{} + $vnetQueryFailed = $false + $vnetQuery = @" +Resources +| where type =~ 'microsoft.network/virtualnetworks' +| project + vnetId = tolower(id), + vnetName = name, + isDdosEnabled = (properties.enableDdosProtection == true), + hasDdosPlan = isnotempty(properties.ddosProtectionPlan.id) +"@ + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $vnetQuery) | ForEach-Object { $vnetDdosCache[$_.vnetId] = $_ } + Write-PSFMessage "VNET Query returned $($vnetDdosCache.Count) records" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "VNET DDoS query failed: $($_.Exception.Message)" -Tag Test -Level Warning + $vnetQueryFailed = $true + } + #endregion Data Collection + + #region Assessment Logic + $findings = @() + + # Evaluate each public IP for DDoS compliance + foreach ($pip in $publicIps) { + $protectionMode = if ([string]::IsNullOrWhiteSpace($pip.ProtectionMode)) { 'Disabled' } else { $pip.ProtectionMode } + $resourceType = 'N/A' + $vnetName = 'N/A' + $vnetDdosStatus = 'N/A' + $isCompliant = $false + + if ($protectionMode -eq 'Enabled') { + # Rule: If protectionMode is "Enabled" → Pass (DDoS IP Protection is directly enabled) + $isCompliant = $true + } + elseif ($protectionMode -eq 'Disabled') { + # Rule: If protectionMode is "Disabled" → Fail (no protection) + $isCompliant = $false + } + elseif ($protectionMode -eq 'VirtualNetworkInherited') { + # Rule: If protectionMode is "VirtualNetworkInherited" + if ([string]::IsNullOrWhiteSpace($pip.ipConfigId)) { + # Rule: If ipConfiguration is missing or null → Fail (unattached, cannot inherit protection) + $isCompliant = $false + $vnetDdosStatus = 'N/A' + } + else { + # Rule: If ipConfiguration.id exists + if ($pip.ipConfigId -match '/providers/microsoft\.network/([^/]+)/') { + # Parse the resource type from ipConfiguration.id + $resourceTypeRaw = $matches[1] + $typeMap = @{ + 'networkinterfaces' = 'Network Interface' + 'applicationgateways' = 'Application Gateway' + 'loadbalancers' = 'Load Balancer' + 'azurefirewalls' = 'Azure Firewall' + 'bastionhosts' = 'Azure Bastion' + 'virtualnetworkgateways' = 'Virtual Network Gateway' + } + $resourceType = $typeMap[$resourceTypeRaw.ToLower()] + if (-not $resourceType) { + $resourceType = $resourceTypeRaw + } + + # Extract the parent resource ID from ipConfiguration.id. + # Handles all three config segment names used across resource types: + # /ipConfigurations/ — NICs, Firewalls, Bastion, VNet Gateways + # /frontendIPConfigurations/ — Load Balancers, Application Gateways (public IP side) + # /gatewayIPConfigurations/ — Application Gateways (subnet side) + if ($pip.ipConfigId -match '(/subscriptions/[^/]+/resourcegroups/[^/]+/providers/microsoft\.network/[^/]+/[^/]+)/(ipconfigurations|frontendipconfigurations|gatewayipconfigurations)/') { + $parentResourceId = $matches[1].ToLower() + } + else { + # Fallback split for any unrecognised pattern + $parentResourceId = ($pip.ipConfigId -split '/(ipconfigurations|frontendipconfigurations|gatewayipconfigurations)/')[0].ToLower() + } + + $vnetId = $resourceVnetCache[$parentResourceId] + + if ($vnetId -and $vnetDdosCache.ContainsKey($vnetId)) { + # Rule: If properties.enableDdosProtection == true AND properties.ddosProtectionPlan.id exists → Pass + # Rule: If properties.enableDdosProtection == false OR properties.ddosProtectionPlan.id is missing → Fail + $vnet = $vnetDdosCache[$vnetId] + $vnetName = $vnet.vnetName + + if ($vnet.isDdosEnabled -eq $true -and $vnet.hasDdosPlan -eq $true) { + $isCompliant = $true + $vnetDdosStatus = 'Enabled' + } + else { + $isCompliant = $false + $vnetDdosStatus = 'Disabled' + } + } + elseif ($resourceQueryFailed -or $vnetQueryFailed) { + # A prerequisite query failed — mark Unknown to avoid a false non-compliance + # result caused by a transient ARG or RBAC error. + $isCompliant = $null + $vnetDdosStatus = 'Unknown' + } + else { + # Queries succeeded but resource not in cache — VNET has no DDoS protection + $isCompliant = $false + $vnetDdosStatus = 'Disabled' + } + } + else { + # Could not parse resource type + $isCompliant = $false + } + } + } + + $findings += [PSCustomObject]@{ + PublicIpName = $pip.PublicIpName + PublicIpId = $pip.PublicIpId + SubscriptionName = $pip.SubscriptionName + SubscriptionId = $pip.SubscriptionId + ProtectionMode = $protectionMode + AssociatedResourceType = $resourceType + AssociatedVnetName = $vnetName + VnetDdosProtection = $vnetDdosStatus + IsCompliant = $isCompliant + } + } + + $passed = @($findings | Where-Object { $_.IsCompliant -eq $false }).Count -eq 0 + $unknownCount = @($findings | Where-Object { $null -eq $_.IsCompliant }).Count + + if ($passed -and $unknownCount -eq 0) { + $testResultMarkdown = "✅ DDoS Protection is enabled for all Public IP addresses, either through DDoS IP Protection enabled directly on the public IP or through DDoS Network Protection enabled on the associated VNET.`n`n%TestResult%" + } + elseif ($passed -and $unknownCount -gt 0) { + $testResultMarkdown = "✅ DDoS Protection is enabled for all resolvable Public IP addresses. $unknownCount public IP(s) could not be fully evaluated due to query failures and require manual verification.`n`n%TestResult%" + } + else { + $testResultMarkdown = "❌ DDoS Protection is not enabled for one or more Public IP addresses. This includes public IPs with DDoS protection explicitly disabled, and public IPs that inherit from a VNET that does not have a DDoS Protection Plan enabled.`n`n%TestResult%" + } + #endregion Assessment Logic + + #region Report Generation + $formatTemplate = @' + +## [{0}]({1}) + +| Public IP name | DDoS protection mode | Resource type | Associated VNET | VNET DDoS protection | Status | +| :--- | :--- | :--- | :--- | :--- | :---: | +{2} + +'@ + + $reportTitle = 'Public IP addresses DDoS protection status' + $portalLink = 'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Network%2FpublicIPAddresses' + + # Prepare table rows + $tableRows = '' + foreach ($item in $findings | Sort-Object @{Expression = 'IsCompliant'; Descending = $false}, 'PublicIpName') { + $pipLink = "https://portal.azure.com/#resource$($item.PublicIpId)" + $pipMd = "[$(Get-SafeMarkdown $item.PublicIpName)]($pipLink)" + + # Format protection mode + $protectionDisplay = switch ($item.ProtectionMode) { + 'Enabled' { '✅ Enabled' } + 'VirtualNetworkInherited' { 'VirtualNetworkInherited' } + 'Disabled' { '❌ Disabled' } + default { $item.ProtectionMode } + } + + # Format resource type + $resourceTypeDisplay = if ($item.AssociatedResourceType -eq 'N/A') { 'N/A' } else { $item.AssociatedResourceType } + + # Format VNET name + $vnetDisplay = if ($item.AssociatedVnetName -eq 'N/A' -or [string]::IsNullOrWhiteSpace($item.AssociatedVnetName)) { 'N/A' } else { Get-SafeMarkdown $item.AssociatedVnetName } + + # Format VNET DDoS status + $vnetDdosDisplay = switch ($item.VnetDdosProtection) { + 'Enabled' { '✅ Enabled' } + 'Disabled' { '❌ Disabled' } + 'Unknown' { '⚠️ Unknown' } + 'N/A' { 'N/A' } + default { $item.VnetDdosProtection } + } + + # Format overall status + $statusDisplay = if ($null -eq $item.IsCompliant) { '⚠️ Unknown' } elseif ($item.IsCompliant) { '✅ Pass' } else { '❌ Fail' } + + $tableRows += "| $pipMd | $protectionDisplay | $resourceTypeDisplay | $vnetDisplay | $vnetDdosDisplay | $statusDisplay |`n" + } + + $mdInfo = $formatTemplate -f $reportTitle, $portalLink, $tableRows + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + $params = @{ + TestId = '25533' + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs' + Status = $passed + Result = $testResultMarkdown + } + + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.26887.md b/src/powershell/tests/Test-Assessment.26887.md new file mode 100644 index 0000000000..1ad5063cbf --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26887.md @@ -0,0 +1,21 @@ +Azure Firewall processes all inbound and outbound network traffic for protected workloads, making it a critical control point for network security monitoring. When diagnostic logging is not enabled, security operations teams lose visibility into traffic patterns, denied connection attempts, threat intelligence matches, and IDPS signature detections. A threat actor who gains initial access to an environment can move laterally through the network without detection because no firewall logs are being captured or analyzed. The absence of logging prevents correlation of network events with other security telemetry, eliminating the ability to construct attack timelines during incident investigations. Furthermore, compliance frameworks such as PCI-DSS, HIPAA, and SOC 2 require organizations to maintain audit logs of network security events, and the lack of firewall diagnostic logging creates audit failures. Azure Firewall provides multiple log categories including application rule logs, network rule logs, NAT rule logs, threat intelligence logs, IDPS signature logs, and DNS proxy logs, all of which must be routed to a destination such as Log Analytics, Storage Account, or Event Hub to enable security monitoring and forensic analysis. + +**Remediation action** + +Create a Log Analytics workspace for storing Azure Firewall logs +- [Create a Log Analytics workspace](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/quick-create-workspace) + +Configure diagnostic settings for Azure Firewall to enable log collection +- [Create diagnostic settings in Azure Monitor](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/create-diagnostic-settings) + +Enable structured logs (resource-specific mode) for improved query performance and cost optimization +- [Azure Firewall structured logs](https://learn.microsoft.com/en-us/azure/firewall/monitor-firewall#structured-azure-firewall-logs) + +Use Azure Firewall Workbook for visualizing and analyzing firewall logs +- [Azure Firewall Workbook](https://learn.microsoft.com/en-us/azure/firewall/firewall-workbook) + +Monitor Azure Firewall metrics and logs for security operations +- [Monitor Azure Firewall](https://learn.microsoft.com/en-us/azure/firewall/monitor-firewall) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.26887.ps1 b/src/powershell/tests/Test-Assessment.26887.ps1 new file mode 100644 index 0000000000..8905af7193 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -0,0 +1,289 @@ +<# +.SYNOPSIS + Validates that diagnostic logging is enabled for Azure Firewall. + +.DESCRIPTION + This test evaluates diagnostic settings for Azure Firewall resources to ensure + log categories are enabled with a valid destination configured (Log Analytics, + Storage Account, or Event Hub). + +.NOTES + Test ID: 26887 + Category: Azure Network Security + Required APIs: Azure Management REST API (subscriptions, firewalls, diagnostic settings) +#> + +function Test-Assessment-26887 { + + [ZtTest( + Category = 'Azure Network Security', + ImplementationCost = 'Low', + MinimumLicense = ('Azure_Firewall_Standard', 'Azure_Firewall_Premium'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Monitor and detect cyberthreats', + TenantType = ('Workforce'), + TestId = 26887, + Title = 'Diagnostic logging is enabled in Azure Firewall', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Evaluating Azure Firewall 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 & Q2: Query Azure Firewalls using Azure Resource Graph + Write-ZtProgress -Activity $activity -Status 'Querying Azure Firewalls via Resource Graph' + + $argQuery = @" +resources +| where type =~ 'microsoft.network/azurefirewalls' +| where properties.provisioningState =~ 'Succeeded' +| join kind=leftouter ( + resourcecontainers + | where type =~ 'microsoft.resources/subscriptions' + | project subscriptionName=name, subscriptionId +) on subscriptionId +| project + FirewallName=name, + FirewallId=id, + Location=location, + SkuName=tostring(properties.sku.name), + SkuTier=tostring(properties.sku.tier), + SubscriptionId=subscriptionId, + SubscriptionName=subscriptionName +"@ + + $allFirewalls = @() + try { + $allFirewalls = @(Invoke-ZtAzureResourceGraphRequest -Query $argQuery) + Write-PSFMessage "ARG Query returned $($allFirewalls.Count) Azure Firewall(s)" -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 Azure Firewall resources exist + if ($allFirewalls.Count -eq 0) { + Write-PSFMessage 'No Azure Firewall resources found.' -Tag Test -Level VeryVerbose + Add-ZtTestResultDetail -SkippedBecause NotApplicable + return + } + + # Q3: Get diagnostic settings for each Azure Firewall + Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings' + + $evaluationResults = @() + + foreach ($firewall in $allFirewalls) { + $firewallId = $firewall.FirewallId + $firewallName = $firewall.FirewallName + $firewallLocation = $firewall.Location + $firewallSku = "$($firewall.SkuName)/$($firewall.SkuTier)" + + # Q3: Query diagnostic settings using Invoke-ZtAzureRequest + $diagPath = $firewallId + '/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 $firewallName : $_" -Level Warning + } + + # Evaluate diagnostic settings + $hasValidDiagSetting = $false + $allDestinationTypes = @() + $enabledCategories = @() + $diagSettingNames = @() + + 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 type + $destTypes = @() + if ($workspaceId) { $destTypes += 'Log Analytics' } + if ($storageAccountId) { $destTypes += 'Storage' } + if ($eventHubAuthRuleId) { $destTypes += 'Event Hub' } + + # Collect all enabled log categories from this setting + $logs = $setting.properties.logs + $settingEnabledCategories = @() + foreach ($log in $logs) { + if ($log.enabled) { + # Handle both category and categoryGroup (per spec) + $categoryName = if ($log.category) { $log.category } else { $log.categoryGroup } + if ($categoryName) { + $settingEnabledCategories += $categoryName + } + } + } + + # If this setting has destination AND enabled logs, it's valid + if ($settingEnabledCategories.Count -gt 0) { + $hasValidDiagSetting = $true + $diagSettingNames += $setting.name + $allDestinationTypes += $destTypes + $enabledCategories += $settingEnabledCategories + } + } + } + + # Deduplicate enabled categories and destination types (multiple settings may have same values) + $enabledCategories = $enabledCategories | Select-Object -Unique + $destinationType = if ($allDestinationTypes.Count -gt 0) { ($allDestinationTypes | Select-Object -Unique) -join ', ' } else { 'None' } + + $status = if ($hasValidDiagSetting) { 'Pass' } else { 'Fail' } + + $evaluationResults += [PSCustomObject]@{ + SubscriptionId = $firewall.SubscriptionId + SubscriptionName = $firewall.SubscriptionName + FirewallName = $firewallName + FirewallId = $firewallId + Location = $firewallLocation + Sku = $firewallSku + DiagnosticSettingCount = $diagSettings.Count + DiagnosticSettingName = ($diagSettingNames | Select-Object -Unique) -join ', ' + DestinationType = $destinationType + EnabledCategories = $enabledCategories -join ', ' + 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 Azure Firewall with active log collection configured.`n`n%TestResult%" + } + else { + $testResultMarkdown = "❌ Diagnostic logging is not enabled for Azure Firewall, preventing security monitoring and threat detection.`n`n%TestResult%" + } + + #endregion Assessment Logic + + #region Report Generation + + # Portal link variables + $portalFirewallBrowseLink = 'https://portal.azure.com/#browse/Microsoft.Network%2FazureFirewalls' + $portalSubscriptionBaseLink = 'https://portal.azure.com/#resource/subscriptions' + $portalResourceBaseLink = 'https://portal.azure.com/#resource' + + $mdInfo = "`n## [Azure Firewall diagnostic logging status]($portalFirewallBrowseLink)`n`n" + + # Azure Firewall Status table + if ($evaluationResults.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +| Subscription | Firewall name | Location | Diagnostic settings count | Destination configured | Enabled log categories | Status | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +{0} + +'@ + + # Limit display to first 5 items if there are many firewalls + $maxItemsToDisplay = 5 + $displayResults = $evaluationResults + $hasMoreItems = $false + if ($evaluationResults.Count -gt $maxItemsToDisplay) { + $displayResults = $evaluationResults | Select-Object -First $maxItemsToDisplay + $hasMoreItems = $true + } + + foreach ($result in $displayResults) { + $subscriptionLink = "[$(Get-SafeMarkdown $result.SubscriptionName)]($portalSubscriptionBaseLink/$($result.SubscriptionId)/overview)" + $firewallLink = "[$(Get-SafeMarkdown $result.FirewallName)]($portalResourceBaseLink$($result.FirewallId)/diagnostics)" + $diagCount = $result.DiagnosticSettingCount + $destConfigured = if ($result.DestinationType -eq 'None') { 'No' } else { 'Yes' } + $enabledCategories = if ($result.DiagnosticSettingCount -eq 0) { + 'No diagnostic settings' + } elseif ($result.EnabledCategories) { + $result.EnabledCategories + } else { + 'None' + } + $statusText = if ($result.Status -eq 'Pass') { '✅ Pass' } else { '❌ Fail' } + + $tableRows += "| $subscriptionLink | $firewallLink | $($result.Location) | $diagCount | $destConfigured | $enabledCategories | $statusText |`n" + } + + # Add note if more items exist + if ($hasMoreItems) { + $remainingCount = $evaluationResults.Count - $maxItemsToDisplay + $tableRows += "`n... and $remainingCount more. [View all Azure Firewalls in the portal]($portalFirewallBrowseLink)`n" + } + + $mdInfo += $formatTemplate -f $tableRows + } + + # Summary + $mdInfo += "**Summary:**`n`n" + $mdInfo += "- Total Azure Firewalls evaluated: $($evaluationResults.Count)`n" + $mdInfo += "- Firewalls with diagnostic logging enabled: $($passedItems.Count)`n" + $mdInfo += "- Firewalls without diagnostic logging: $($failedItems.Count)`n" + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + #endregion Report Generation + + $params = @{ + TestId = '26887' + Title = 'Diagnostic logging is enabled in Azure Firewall' + Status = $passed + Result = $testResultMarkdown + } + + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.26888.md b/src/powershell/tests/Test-Assessment.26888.md new file mode 100644 index 0000000000..55884b9fc2 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26888.md @@ -0,0 +1,21 @@ +Azure Application Gateway Web Application Firewall (WAF) protects web applications from common exploits and vulnerabilities such as SQL injection, cross-site scripting, and other OWASP Top 10 threats. When diagnostic logging is not enabled, security operations teams lose visibility into blocked attacks, rule matches, access patterns, and firewall events. A threat actor attempting to exploit web application vulnerabilities would go undetected because no WAF logs are being captured or analyzed. The absence of logging prevents correlation of WAF events with other security telemetry, eliminating the ability to construct attack timelines during incident investigations. Furthermore, compliance frameworks such as PCI-DSS, HIPAA, and SOC 2 require organizations to maintain audit logs of web application security events, and the lack of WAF diagnostic logging creates audit failures. Azure Application Gateway WAF provides multiple log categories including Application Gateway Access Logs, Performance Logs, and Firewall Logs, all of which must be routed to a destination such as Log Analytics, Storage Account, or Event Hub to enable security monitoring and forensic analysis. + +**Remediation action** + +Create a Log Analytics workspace for storing Application Gateway WAF logs +- [Create a Log Analytics workspace](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/quick-create-workspace) + +Configure diagnostic settings for Application Gateway to enable log collection +- [Create diagnostic settings in Azure Monitor](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/create-diagnostic-settings) + +Enable WAF logging to capture firewall events and rule matches +- [Application Gateway WAF logs and metrics](https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-waf-metrics) + +Monitor Application Gateway using diagnostic logs and metrics +- [Monitor Azure Application Gateway](https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-diagnostics) + +Use Azure Monitor Workbooks for visualizing and analyzing WAF logs +- [Azure Monitor Workbooks](https://learn.microsoft.com/en-us/azure/azure-monitor/visualize/workbooks-overview) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.26888.ps1 b/src/powershell/tests/Test-Assessment.26888.ps1 new file mode 100644 index 0000000000..b85e7e888a --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26888.ps1 @@ -0,0 +1,289 @@ +<# +.SYNOPSIS + Validates that diagnostic logging is enabled for Application Gateway WAF. + +.DESCRIPTION + This test evaluates diagnostic settings for Azure Application Gateway resources with WAF SKU + to ensure log categories are enabled with a valid destination configured (Log Analytics, + Storage Account, or Event Hub). + +.NOTES + Test ID: 26888 + Category: Azure Network Security + Required APIs: Azure Management REST API (subscriptions, application gateways, diagnostic settings) +#> + +function Test-Assessment-26888 { + + [ZtTest( + Category = 'Azure Network Security', + ImplementationCost = 'Low', + MinimumLicense = ('Azure_Application_Gateway_WAF'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Monitor and detect cyberthreats', + TenantType = ('Workforce'), + TestId = 26888, + Title = 'Diagnostic logging is enabled in Application Gateway WAF', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Evaluating Application Gateway WAF 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 & Q2: Query Application Gateways with WAF SKU using Azure Resource Graph + Write-ZtProgress -Activity $activity -Status 'Querying Application Gateways via Resource Graph' + + $argQuery = @" +resources +| where type =~ 'microsoft.network/applicationgateways' +| where properties.provisioningState =~ 'Succeeded' +| where properties.sku.tier in~ ('WAF', 'WAF_v2') +| join kind=leftouter ( + resourcecontainers + | where type =~ 'microsoft.resources/subscriptions' + | project subscriptionName=name, subscriptionId +) on subscriptionId +| project + GatewayName=name, + GatewayId=id, + Location=location, + SkuTier=tostring(properties.sku.tier), + SubscriptionId=subscriptionId, + SubscriptionName=subscriptionName +"@ + + $allAppGateways = @() + try { + $allAppGateways = @(Invoke-ZtAzureResourceGraphRequest -Query $argQuery) + Write-PSFMessage "ARG Query returned $($allAppGateways.Count) Application Gateway WAF(s)" -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 Application Gateway WAF resources exist + if ($allAppGateways.Count -eq 0) { + Write-PSFMessage 'No Application Gateway WAF resources found.' -Tag Test -Level VeryVerbose + Add-ZtTestResultDetail -SkippedBecause NotApplicable + return + } + + # Q3: Get diagnostic settings for each Application Gateway WAF + Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings' + + $evaluationResults = @() + + foreach ($appGateway in $allAppGateways) { + $appGatewayId = $appGateway.GatewayId + $appGatewayName = $appGateway.GatewayName + $appGatewayLocation = $appGateway.Location + $appGatewaySkuTier = $appGateway.SkuTier + + # Q3: Query diagnostic settings using Invoke-ZtAzureRequest + $diagPath = $appGatewayId + '/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 $appGatewayName : $_" -Level Warning + } + + # Evaluate diagnostic settings + $hasValidDiagSetting = $false + $allDestinationTypes = @() + $enabledCategories = @() + $diagSettingNames = @() + + 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 type + $destTypes = @() + if ($workspaceId) { $destTypes += 'Log Analytics' } + if ($storageAccountId) { $destTypes += 'Storage' } + if ($eventHubAuthRuleId) { $destTypes += 'Event Hub' } + + # Collect all enabled log categories from this setting + $logs = $setting.properties.logs + $settingEnabledCategories = @() + foreach ($log in $logs) { + if ($log.enabled) { + # Handle both category and categoryGroup (per spec) + $categoryName = if ($log.category) { $log.category } else { $log.categoryGroup } + if ($categoryName) { + $settingEnabledCategories += $categoryName + } + } + } + + # If this setting has destination AND enabled logs, it's valid + if ($settingEnabledCategories.Count -gt 0) { + $hasValidDiagSetting = $true + $diagSettingNames += $setting.name + $allDestinationTypes += $destTypes + $enabledCategories += $settingEnabledCategories + } + } + } + + # Deduplicate enabled categories and destination types (multiple settings may have same values) + $enabledCategories = $enabledCategories | Select-Object -Unique + $destinationType = if ($allDestinationTypes.Count -gt 0) { ($allDestinationTypes | Select-Object -Unique) -join ', ' } else { 'None' } + + $status = if ($hasValidDiagSetting) { 'Pass' } else { 'Fail' } + + $evaluationResults += [PSCustomObject]@{ + SubscriptionId = $appGateway.SubscriptionId + SubscriptionName = $appGateway.SubscriptionName + GatewayName = $appGatewayName + GatewayId = $appGatewayId + Location = $appGatewayLocation + SkuTier = $appGatewaySkuTier + DiagnosticSettingCount = $diagSettings.Count + DiagnosticSettingName = ($diagSettingNames | Select-Object -Unique) -join ', ' + DestinationType = $destinationType + EnabledCategories = $enabledCategories -join ', ' + 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 Application Gateway WAF with active log collection configured.`n`n%TestResult%" + } + else { + $testResultMarkdown = "❌ Diagnostic logging is not enabled for Application Gateway WAF, preventing security monitoring and threat detection.`n`n%TestResult%" + } + + #endregion Assessment Logic + + #region Report Generation + + # Portal link variables + $portalAppGatewayBrowseLink = 'https://portal.azure.com/#browse/Microsoft.Network%2FapplicationGateways' + $portalSubscriptionBaseLink = 'https://portal.azure.com/#resource/subscriptions' + $portalResourceBaseLink = 'https://portal.azure.com/#resource' + + $mdInfo = "`n## [Application Gateway WAF diagnostic logging status]($portalAppGatewayBrowseLink)`n`n" + + # Application Gateway WAF Status table + if ($evaluationResults.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +| Subscription | Gateway name | Location | SKU tier | Diagnostic settings count | Destination configured | Enabled log categories | Status | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +{0} + +'@ + + # Limit display to first 5 items if there are many gateways + $maxItemsToDisplay = 5 + $displayResults = $evaluationResults + $hasMoreItems = $false + if ($evaluationResults.Count -gt $maxItemsToDisplay) { + $displayResults = $evaluationResults | Select-Object -First $maxItemsToDisplay + $hasMoreItems = $true + } + + foreach ($result in $displayResults) { + $subscriptionLink = "[$(Get-SafeMarkdown $result.SubscriptionName)]($portalSubscriptionBaseLink/$($result.SubscriptionId)/overview)" + $gatewayLink = "[$(Get-SafeMarkdown $result.GatewayName)]($portalResourceBaseLink$($result.GatewayId)/diagnostics)" + $diagCount = $result.DiagnosticSettingCount + $destConfigured = if ($result.DestinationType -eq 'None') { 'No' } else { 'Yes' } + $enabledCategories = if ($result.DiagnosticSettingCount -eq 0) { + 'No diagnostic settings' + } elseif ($result.EnabledCategories) { + $result.EnabledCategories + } else { + 'None' + } + $statusText = if ($result.Status -eq 'Pass') { '✅ Pass' } else { '❌ Fail' } + + $tableRows += "| $subscriptionLink | $gatewayLink | $($result.Location) | $($result.SkuTier) | $diagCount | $destConfigured | $enabledCategories | $statusText |`n" + } + + # Add note if more items exist + if ($hasMoreItems) { + $remainingCount = $evaluationResults.Count - $maxItemsToDisplay + $tableRows += "`n... and $remainingCount more. [View all Application Gateways in the portal]($portalAppGatewayBrowseLink)`n" + } + + $mdInfo += $formatTemplate -f $tableRows + } + + # Summary + $mdInfo += "**Summary:**`n`n" + $mdInfo += "- Total Application Gateways with WAF evaluated: $($evaluationResults.Count)`n" + $mdInfo += "- Gateways with diagnostic logging enabled: $($passedItems.Count)`n" + $mdInfo += "- Gateways without diagnostic logging: $($failedItems.Count)`n" + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + #endregion Report Generation + + $params = @{ + TestId = '26888' + Title = 'Diagnostic logging is enabled in Application Gateway WAF' + Status = $passed + Result = $testResultMarkdown + } + + Add-ZtTestResultDetail @params +}