From 725163d997e1900c03997b6734983415df840385 Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Mon, 9 Feb 2026 11:23:07 +0530 Subject: [PATCH 01/25] added test --- src/powershell/tests/Test-Assessment.25375.md | 21 ++ .../tests/Test-Assessment.25375.ps1 | 328 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.25375.md create mode 100644 src/powershell/tests/Test-Assessment.25375.ps1 diff --git a/src/powershell/tests/Test-Assessment.25375.md b/src/powershell/tests/Test-Assessment.25375.md new file mode 100644 index 0000000000..839376abaf --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25375.md @@ -0,0 +1,21 @@ +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 +- + +Assign licenses to users through Microsoft Entra admin center +- + +Use group-based licensing for easier management at scale +- + +Monitor license utilization through Microsoft 365 admin center +- + +Review Microsoft Entra Suite as an alternative that includes both Internet Access and Private Access +- + + +%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..69a9cbee52 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -0,0 +1,328 @@ +using module '..\Classes\TestDefinition.psm1' +using module '..\Classes\TestResult.psm1' + +<# + .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 + GSA Service Plan IDs: + - Entra_Premium_Internet_Access: 8d23cb83-ab07-418f-8517-d7aca77307dc + - Entra_Premium_Private_Access: f057aab1-b184-49b2-85c0-881b02a405c5 +#> + +[TestDefinition]@{ + TestId = '25375' + Category = 'Network' + TestName = 'Test-GSALicensesAvailableAndAssigned' + Description = 'Validates that GSA licenses are available in the tenant and assigned to users' + MinimumLicense = 'Entra_Premium_Internet_Access, Entra_Premium_Private_Access' + SupportedClouds = @('Global') + Result = { + [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = $null + Summary = $null + Details = $null + Data = $null + Params = $params + } + } + TestScript = { + param($params) + + # GSA Service Plan IDs + $gsaServicePlanIds = @( + '8d23cb83-ab07-418f-8517-d7aca77307dc', # Entra_Premium_Internet_Access + 'f057aab1-b184-49b2-85c0-881b02a405c5' # Entra_Premium_Private_Access + ) + + $skuCmdletFailed = $false + $userCmdletFailed = $false + $subscribedSkus = @() + $allUsers = @() + + # Query 1: Retrieve tenant licenses with GSA service plans + try { + $subscribedSkus = Get-MgSubscribedSku -ErrorAction Stop + } + catch { + $skuCmdletFailed = $true + } + + # Query 2: Retrieve all users with assigned licenses + try { + $allUsers = Get-MgUser -All -Property id,displayName,userPrincipalName,assignedLicenses -ErrorAction Stop + } + catch { + $userCmdletFailed = $true + } + + # Handle complete failure + if ($skuCmdletFailed -and $userCmdletFailed) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Skipped' + Summary = 'Unable to retrieve GSA license and user data' + Details = "⚠️ Unable to retrieve subscribedSkus and users due to query failure, connection issues, or insufficient permissions." + Data = $null + Params = $params + } + } + + # Handle partial failure - SKU query failed + if ($skuCmdletFailed) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Skipped' + Summary = 'Unable to retrieve GSA license data' + Details = "⚠️ Unable to retrieve subscribedSkus due to query failure, connection issues, or insufficient permissions." + Data = $null + Params = $params + } + } + + # Handle partial failure - User query failed (but we can still check if licenses exist) + if ($userCmdletFailed) { + # Check if GSA licenses exist + $gsaSkus = $subscribedSkus | Where-Object { + $sku = $_ + $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } + } + + if ($gsaSkus.Count -eq 0) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Fail' + Summary = 'GSA licenses are not available in the tenant' + Details = "No subscribed SKUs contain GSA service plans (Entra Premium Internet Access or Entra Premium Private Access)." + Data = $null + Params = $params + } + } + + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Skipped' + Summary = 'Unable to retrieve user assignment data' + Details = "⚠️ GSA licenses exist in tenant but unable to retrieve users due to query failure, connection issues, or insufficient permissions." + Data = $null + Params = $params + } + } + + # Filter SKUs containing GSA service plans + $gsaSkus = $subscribedSkus | Where-Object { + $sku = $_ + $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } + } + + # Check if GSA licenses exist in tenant + if ($gsaSkus.Count -eq 0) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Fail' + Summary = 'GSA licenses are not available in the tenant' + Details = "No subscribed SKUs contain GSA service plans (Entra Premium Internet Access or Entra Premium Private Access).`n`nPurchase GSA licenses: https://learn.microsoft.com/en-us/entra/global-secure-access/overview-what-is-global-secure-access#licensing-overview" + Data = $null + Params = $params + } + } + + # Check if any GSA licenses are enabled + $enabledGsaSkus = $gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' } + if ($enabledGsaSkus.Count -eq 0) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Fail' + Summary = 'GSA licenses are not enabled in the tenant' + Details = "GSA licenses exist but all have capabilityStatus other than 'Enabled' (e.g., Warning, Suspended, Deleted, LockedOut).`n`nReview license status: https://admin.microsoft.com/Adminportal/Home#/licenses" + Data = $null + Params = $params + } + } + + # Build SKU ID to SKU mapping for user license cross-reference + $gsaSkuIds = @{} + foreach ($sku in $enabledGsaSkus) { + $gsaSkuIds[$sku.SkuId] = $sku + } + + # Count users with GSA licenses assigned + $usersWithInternetAccess = @() + $usersWithPrivateAccess = @() + $usersWithAnyGsa = @() + + foreach ($user in $allUsers) { + if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { + continue + } + + $hasInternetAccess = $false + $hasPrivateAccess = $false + + foreach ($license in $user.AssignedLicenses) { + if ($gsaSkuIds.ContainsKey($license.SkuId)) { + $sku = $gsaSkuIds[$license.SkuId] + $disabledPlans = $license.DisabledPlans + + # Check if Internet Access is enabled + $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq '8d23cb83-ab07-418f-8517-d7aca77307dc' } + if ($internetAccessPlan -and $internetAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasInternetAccess = $true + } + + # Check if Private Access is enabled + $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq 'f057aab1-b184-49b2-85c0-881b02a405c5' } + if ($privateAccessPlan -and $privateAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasPrivateAccess = $true + } + } + } + + if ($hasInternetAccess) { + $usersWithInternetAccess += $user + } + if ($hasPrivateAccess) { + $usersWithPrivateAccess += $user + } + if ($hasInternetAccess -or $hasPrivateAccess) { + $usersWithAnyGsa += $user + } + } + + $gsaUserCount = $usersWithAnyGsa.Count + + # Evaluate test result + if ($gsaUserCount -eq 0) { + return [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Fail' + Summary = 'GSA licenses exist but are not assigned to any users' + Details = "GSA licenses are available and enabled in the tenant, but no users have GSA service plans assigned.`n`nAssign licenses to users: https://learn.microsoft.com/en-us/entra/fundamentals/license-users-groups" + Data = $null + Params = $params + } + } + + # Build report data + $report = @() + + # GSA License Summary + $report += "## [Licenses](https://admin.microsoft.com/Adminportal/Home#/licenses)" + $report += "" + $report += "**GSA License Summary:**" + $report += "" + + $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 += [PSCustomObject]@{ + SkuName = $skuName + Status = $status + Available = $available + Assigned = $assigned + } + } + + $report += "| SKU Name | Status | Available | Assigned |" + $report += "| --- | --- | --- | --- |" + foreach ($row in $skuTableRows) { + $report += "| $($row.SkuName) | $($row.Status) | $($row.Available) | $($row.Assigned) |" + } + $report += "" + + # GSA Service Plans Detected + $report += "**GSA Service Plans Detected:**" + $report += "" + + $servicePlanTableRows = @() + foreach ($sku in $enabledGsaSkus) { + $gsaPlans = $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } + foreach ($plan in $gsaPlans) { + $planName = Get-SafeMarkdown -Text $plan.ServicePlanName + $skuName = Get-SafeMarkdown -Text $sku.SkuPartNumber + + $servicePlanTableRows += [PSCustomObject]@{ + ServicePlan = $planName + Sku = $skuName + } + } + } + + $report += "| Service Plan | SKU |" + $report += "| --- | --- |" + foreach ($row in $servicePlanTableRows) { + $report += "| $($row.ServicePlan) | $($row.Sku) |" + } + $report += "" + + # User Assignment Summary + $report += "**User Assignment Summary:**" + $report += "" + $report += "| Metric | Value |" + $report += "| --- | --- |" + $report += "| Users with GSA Internet Access | $($usersWithInternetAccess.Count) |" + $report += "| Users with GSA Private Access | $($usersWithPrivateAccess.Count) |" + $report += "| Total Users with Any GSA License | $gsaUserCount |" + $report += "" + + # User list (truncate at 10) + if ($gsaUserCount -gt 0) { + $report += "**Users with GSA Licenses:**" + $report += "" + + if ($gsaUserCount -gt 10) { + $report += "Showing 10 of $gsaUserCount users." + $report += "" + } + + $report += "| Display Name | User Principal Name | Internet Access | Private Access |" + $report += "| --- | --- | --- | --- |" + + $displayUsers = $usersWithAnyGsa | Select-Object -First 10 + foreach ($user in $displayUsers) { + $displayName = Get-SafeMarkdown -Text $user.DisplayName + $upn = Get-SafeMarkdown -Text $user.UserPrincipalName + $hasInternet = if ($user.Id -in $usersWithInternetAccess.Id) { "✅" } else { "❌" } + $hasPrivate = if ($user.Id -in $usersWithPrivateAccess.Id) { "✅" } else { "❌" } + + $report += "| $displayName | $upn | $hasInternet | $hasPrivate |" + } + + if ($gsaUserCount -gt 10) { + $report += "| ... | | | |" + } + } + + [TestResult]@{ + TestId = '25375' + Tenant = $env:TenantName + Status = 'Pass' + Summary = 'GSA licenses are available and assigned to users' + Details = "✅ GSA licenses are provisioned in the tenant with enabled status and assigned to $gsaUserCount user(s)." + Data = ($report -join "`n") + Params = $params + } + } +} From 7b9a142310f9ca763f82cbbe250c2fa2d677c3dd Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Wed, 11 Feb 2026 15:32:08 +0530 Subject: [PATCH 02/25] added test --- .../tests/Test-Assessment.25375.ps1 | 478 ++++++++---------- 1 file changed, 213 insertions(+), 265 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index 69a9cbee52..48e217c635 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -1,328 +1,276 @@ -using module '..\Classes\TestDefinition.psm1' -using module '..\Classes\TestResult.psm1' - <# - .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 - GSA Service Plan IDs: - - Entra_Premium_Internet_Access: 8d23cb83-ab07-418f-8517-d7aca77307dc - - Entra_Premium_Private_Access: f057aab1-b184-49b2-85c0-881b02a405c5 +.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), users with assignedLicenses (beta) + GSA Service Plan IDs: + - Entra_Premium_Internet_Access: 8d23cb83-ab07-418f-8517-d7aca77307dc + - Entra_Premium_Private_Access: f057aab1-b184-49b2-85c0-881b02a405c5 #> -[TestDefinition]@{ - TestId = '25375' - Category = 'Network' - TestName = 'Test-GSALicensesAvailableAndAssigned' - Description = 'Validates that GSA licenses are available in the tenant and assigned to users' - MinimumLicense = 'Entra_Premium_Internet_Access, Entra_Premium_Private_Access' - SupportedClouds = @('Global') - Result = { - [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = $null - Summary = $null - Details = $null - Data = $null - Params = $params - } +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() + + #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 = @( + '8d23cb83-ab07-418f-8517-d7aca77307dc', # Entra_Premium_Internet_Access + 'f057aab1-b184-49b2-85c0-881b02a405c5' # Entra_Premium_Private_Access + ) + + $skuCmdletFailed = $false + $userCmdletFailed = $false + $subscribedSkus = @() + $allUsers = @() + + # Query 1: Retrieve tenant licenses with GSA service plans + try { + $subscribedSkus = Invoke-ZtGraphRequest ` + -RelativeUri 'subscribedSkus' ` + -ApiVersion beta + } + catch { + $skuCmdletFailed = $true + Write-PSFMessage "Failed to retrieve subscribed SKUs: $_" -Tag Test -Level Warning } - TestScript = { - param($params) - - # GSA Service Plan IDs - $gsaServicePlanIds = @( - '8d23cb83-ab07-418f-8517-d7aca77307dc', # Entra_Premium_Internet_Access - 'f057aab1-b184-49b2-85c0-881b02a405c5' # Entra_Premium_Private_Access - ) - - $skuCmdletFailed = $false - $userCmdletFailed = $false - $subscribedSkus = @() - $allUsers = @() - - # Query 1: Retrieve tenant licenses with GSA service plans - try { - $subscribedSkus = Get-MgSubscribedSku -ErrorAction Stop - } - catch { - $skuCmdletFailed = $true - } - - # Query 2: Retrieve all users with assigned licenses - try { - $allUsers = Get-MgUser -All -Property id,displayName,userPrincipalName,assignedLicenses -ErrorAction Stop - } - catch { - $userCmdletFailed = $true - } - - # Handle complete failure - if ($skuCmdletFailed -and $userCmdletFailed) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Skipped' - Summary = 'Unable to retrieve GSA license and user data' - Details = "⚠️ Unable to retrieve subscribedSkus and users due to query failure, connection issues, or insufficient permissions." - Data = $null - Params = $params - } - } - - # Handle partial failure - SKU query failed - if ($skuCmdletFailed) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Skipped' - Summary = 'Unable to retrieve GSA license data' - Details = "⚠️ Unable to retrieve subscribedSkus due to query failure, connection issues, or insufficient permissions." - Data = $null - Params = $params - } - } - # Handle partial failure - User query failed (but we can still check if licenses exist) - if ($userCmdletFailed) { - # Check if GSA licenses exist - $gsaSkus = $subscribedSkus | Where-Object { - $sku = $_ - $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } - } + Write-ZtProgress -Activity $activity -Status 'Querying user license assignments' - if ($gsaSkus.Count -eq 0) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Fail' - Summary = 'GSA licenses are not available in the tenant' - Details = "No subscribed SKUs contain GSA service plans (Entra Premium Internet Access or Entra Premium Private Access)." - Data = $null - Params = $params - } - } + # Query 2: Retrieve all users with assigned licenses + try { + $allUsers = Invoke-ZtGraphRequest ` + -RelativeUri 'users?$select=id,displayName,userPrincipalName,assignedLicenses&$count=true' ` + -ApiVersion beta + } + catch { + $userCmdletFailed = $true + Write-PSFMessage "Failed to retrieve users: $_" -Tag Test -Level Warning + } + #endregion Data Collection - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Skipped' - Summary = 'Unable to retrieve user assignment data' - Details = "⚠️ GSA licenses exist in tenant but unable to retrieve users due to query failure, connection issues, or insufficient permissions." - Data = $null - Params = $params - } - } + #region Assessment Logic + $testResultMarkdown = '' + $passed = $false + $customStatus = $null - # Filter SKUs containing GSA service plans + # Handle any query failure - cannot determine license status + if ($skuCmdletFailed -or $userCmdletFailed) { + $testResultMarkdown = "⚠️ Unable to determine GSA license status.`n`n%TestResult%" + $customStatus = 'Investigate' + } + # Filter SKUs containing GSA service plans + elseif ($null -ne $subscribedSkus) { $gsaSkus = $subscribedSkus | Where-Object { $sku = $_ $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } } - # Check if GSA licenses exist in tenant - if ($gsaSkus.Count -eq 0) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Fail' - Summary = 'GSA licenses are not available in the tenant' - Details = "No subscribed SKUs contain GSA service plans (Entra Premium Internet Access or Entra Premium Private Access).`n`nPurchase GSA licenses: https://learn.microsoft.com/en-us/entra/global-secure-access/overview-what-is-global-secure-access#licensing-overview" - Data = $null - Params = $params - } - } - - # Check if any GSA licenses are enabled + # Check if GSA licenses exist and are enabled $enabledGsaSkus = $gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' } - if ($enabledGsaSkus.Count -eq 0) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Fail' - Summary = 'GSA licenses are not enabled in the tenant' - Details = "GSA licenses exist but all have capabilityStatus other than 'Enabled' (e.g., Warning, Suspended, Deleted, LockedOut).`n`nReview license status: https://admin.microsoft.com/Adminportal/Home#/licenses" - Data = $null - Params = $params - } - } - # Build SKU ID to SKU mapping for user license cross-reference - $gsaSkuIds = @{} - foreach ($sku in $enabledGsaSkus) { - $gsaSkuIds[$sku.SkuId] = $sku + if ($gsaSkus.Count -eq 0 -or $enabledGsaSkus.Count -eq 0) { + $testResultMarkdown = "❌ GSA licenses are not available or not assigned to users in the tenant.`n`n%TestResult%" } + else { + Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' - # Count users with GSA licenses assigned - $usersWithInternetAccess = @() - $usersWithPrivateAccess = @() - $usersWithAnyGsa = @() - - foreach ($user in $allUsers) { - if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { - continue + # Build SKU ID to SKU mapping for user license cross-reference + $gsaSkuIds = @{} + foreach ($sku in $enabledGsaSkus) { + $gsaSkuIds[$sku.SkuId] = $sku } - $hasInternetAccess = $false - $hasPrivateAccess = $false + # Count users with GSA licenses assigned + $usersWithInternetAccess = @() + $usersWithPrivateAccess = @() + $usersWithAnyGsa = @() - foreach ($license in $user.AssignedLicenses) { - if ($gsaSkuIds.ContainsKey($license.SkuId)) { - $sku = $gsaSkuIds[$license.SkuId] - $disabledPlans = $license.DisabledPlans + foreach ($user in $allUsers) { + if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { + continue + } - # Check if Internet Access is enabled - $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq '8d23cb83-ab07-418f-8517-d7aca77307dc' } - if ($internetAccessPlan -and $internetAccessPlan.ServicePlanId -notin $disabledPlans) { - $hasInternetAccess = $true + $hasInternetAccess = $false + $hasPrivateAccess = $false + + foreach ($license in $user.AssignedLicenses) { + if ($gsaSkuIds.ContainsKey($license.SkuId)) { + $sku = $gsaSkuIds[$license.SkuId] + $disabledPlans = $license.DisabledPlans + + # Check if Internet Access is enabled + $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq '8d23cb83-ab07-418f-8517-d7aca77307dc' } + if ($internetAccessPlan -and $internetAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasInternetAccess = $true + } + + # Check if Private Access is enabled + $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq 'f057aab1-b184-49b2-85c0-881b02a405c5' } + if ($privateAccessPlan -and $privateAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasPrivateAccess = $true + } } + } - # Check if Private Access is enabled - $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq 'f057aab1-b184-49b2-85c0-881b02a405c5' } - if ($privateAccessPlan -and $privateAccessPlan.ServicePlanId -notin $disabledPlans) { - $hasPrivateAccess = $true - } + if ($hasInternetAccess) { + $usersWithInternetAccess += $user + } + if ($hasPrivateAccess) { + $usersWithPrivateAccess += $user + } + if ($hasInternetAccess -or $hasPrivateAccess) { + $usersWithAnyGsa += $user } } - if ($hasInternetAccess) { - $usersWithInternetAccess += $user - } - if ($hasPrivateAccess) { - $usersWithPrivateAccess += $user + $gsaUserCount = $usersWithAnyGsa.Count + + # Evaluate test result + if ($gsaUserCount -eq 0) { + $passed = $false + $testResultMarkdown = "❌ GSA licenses are not available or not assigned to users in the tenant.`n`n%TestResult%" } - if ($hasInternetAccess -or $hasPrivateAccess) { - $usersWithAnyGsa += $user + else { + $passed = $true + $testResultMarkdown = "✅ GSA licenses are available in the tenant and assigned to users.`n`n%TestResult%" } } + } + #endregion Assessment Logic - $gsaUserCount = $usersWithAnyGsa.Count - - # Evaluate test result - if ($gsaUserCount -eq 0) { - return [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Fail' - Summary = 'GSA licenses exist but are not assigned to any users' - Details = "GSA licenses are available and enabled in the tenant, but no users have GSA service plans assigned.`n`nAssign licenses to users: https://learn.microsoft.com/en-us/entra/fundamentals/license-users-groups" - Data = $null - Params = $params - } - } + #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} - # Build report data - $report = @() - - # GSA License Summary - $report += "## [Licenses](https://admin.microsoft.com/Adminportal/Home#/licenses)" - $report += "" - $report += "**GSA License Summary:**" - $report += "" +{5} +'@ - $skuTableRows = @() + # 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 += [PSCustomObject]@{ - SkuName = $skuName - Status = $status - Available = $available - Assigned = $assigned - } - } - $report += "| SKU Name | Status | Available | Assigned |" - $report += "| --- | --- | --- | --- |" - foreach ($row in $skuTableRows) { - $report += "| $($row.SkuName) | $($row.Status) | $($row.Available) | $($row.Assigned) |" + $skuTableRows += "| $skuName | $status | $available | $assigned |`n" } - $report += "" - # GSA Service Plans Detected - $report += "**GSA Service Plans Detected:**" - $report += "" - - $servicePlanTableRows = @() + # Build service plan table + $servicePlanTableRows = '' foreach ($sku in $enabledGsaSkus) { $gsaPlans = $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } foreach ($plan in $gsaPlans) { $planName = Get-SafeMarkdown -Text $plan.ServicePlanName $skuName = Get-SafeMarkdown -Text $sku.SkuPartNumber - - $servicePlanTableRows += [PSCustomObject]@{ - ServicePlan = $planName - Sku = $skuName - } + + $servicePlanTableRows += "| $planName | $skuName |`n" } } - $report += "| Service Plan | SKU |" - $report += "| --- | --- |" - foreach ($row in $servicePlanTableRows) { - $report += "| $($row.ServicePlan) | $($row.Sku) |" - } - $report += "" - - # User Assignment Summary - $report += "**User Assignment Summary:**" - $report += "" - $report += "| Metric | Value |" - $report += "| --- | --- |" - $report += "| Users with GSA Internet Access | $($usersWithInternetAccess.Count) |" - $report += "| Users with GSA Private Access | $($usersWithPrivateAccess.Count) |" - $report += "| Total Users with Any GSA License | $gsaUserCount |" - $report += "" - - # User list (truncate at 10) + # 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) { - $report += "**Users with GSA Licenses:**" - $report += "" - if ($gsaUserCount -gt 10) { - $report += "Showing 10 of $gsaUserCount users." - $report += "" + $userListSection += "**Users with GSA licenses (Showing 10 of $gsaUserCount):**`n`n" + } + else { + $userListSection += "**Users with GSA licenses:**`n`n" } - $report += "| Display Name | User Principal Name | Internet Access | Private Access |" - $report += "| --- | --- | --- | --- |" - + $userListSection += "| Display name | User principal name | Internet access | Private access |`n" + $userListSection += "| :----------- | :------------------ | :-------------- | :------------- |`n" + $displayUsers = $usersWithAnyGsa | Select-Object -First 10 foreach ($user in $displayUsers) { $displayName = Get-SafeMarkdown -Text $user.DisplayName $upn = Get-SafeMarkdown -Text $user.UserPrincipalName - $hasInternet = if ($user.Id -in $usersWithInternetAccess.Id) { "✅" } else { "❌" } - $hasPrivate = if ($user.Id -in $usersWithPrivateAccess.Id) { "✅" } else { "❌" } - - $report += "| $displayName | $upn | $hasInternet | $hasPrivate |" + $hasInternet = if ($user.Id -in $usersWithInternetAccess.Id) { '✅' } else { '❌' } + $hasPrivate = if ($user.Id -in $usersWithPrivateAccess.Id) { '✅' } else { '❌' } + + $userListSection += "| $displayName | $upn | $hasInternet | $hasPrivate |`n" } - + if ($gsaUserCount -gt 10) { - $report += "| ... | | | |" + $userListSection += "| ... | | | |`n`n" + $userListSection += "View all users in [Microsoft 365 admin center - Licenses](https://admin.microsoft.com/Adminportal/Home#/licenses)" } } - [TestResult]@{ - TestId = '25375' - Tenant = $env:TenantName - Status = 'Pass' - Summary = 'GSA licenses are available and assigned to users' - Details = "✅ GSA licenses are provisioned in the tenant with enabled status and assigned to $gsaUserCount user(s)." - Data = ($report -join "`n") - Params = $params - } + $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 } From 450ace14fd345a754d87a670d6e09a3c30d7fd7d Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Wed, 11 Feb 2026 15:58:33 +0530 Subject: [PATCH 03/25] added test --- src/powershell/tests/Test-Assessment.25375.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index 48e217c635..f28d93f54a 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -85,7 +85,7 @@ function Test-Assessment-25375 { # Handle any query failure - cannot determine license status if ($skuCmdletFailed -or $userCmdletFailed) { $testResultMarkdown = "⚠️ Unable to determine GSA license status.`n`n%TestResult%" - $customStatus = 'Investigate' + $customStatus = 'Skipped' } # Filter SKUs containing GSA service plans elseif ($null -ne $subscribedSkus) { From 41d78d49d642d8f60be83f99b2efcd5e3722c83e Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Wed, 11 Feb 2026 19:24:09 +0530 Subject: [PATCH 04/25] resolved copilot comments --- src/powershell/tests/Test-Assessment.25375.md | 19 +++-------- .../tests/Test-Assessment.25375.ps1 | 34 +++++++++---------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.md b/src/powershell/tests/Test-Assessment.25375.md index 839376abaf..b1f381dd09 100644 --- a/src/powershell/tests/Test-Assessment.25375.md +++ b/src/powershell/tests/Test-Assessment.25375.md @@ -2,20 +2,11 @@ Global Secure Access requires specific Microsoft Entra licenses to function, inc **Remediation action** -Review GSA licensing requirements and purchase appropriate licenses -- - -Assign licenses to users through Microsoft Entra admin center -- - -Use group-based licensing for easier management at scale -- - -Monitor license utilization through Microsoft 365 admin center -- - -Review Microsoft Entra Suite as an alternative that includes both Internet Access and Private Access -- +- [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 index f28d93f54a..9d8bbe1911 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -42,10 +42,10 @@ function Test-Assessment-25375 { Write-ZtProgress -Activity $activity -Status 'Querying tenant licenses' # GSA Service Plan IDs - $gsaServicePlanIds = @( - '8d23cb83-ab07-418f-8517-d7aca77307dc', # Entra_Premium_Internet_Access - 'f057aab1-b184-49b2-85c0-881b02a405c5' # Entra_Premium_Private_Access - ) + $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 @@ -89,13 +89,13 @@ function Test-Assessment-25375 { } # Filter SKUs containing GSA service plans elseif ($null -ne $subscribedSkus) { - $gsaSkus = $subscribedSkus | Where-Object { + $gsaSkus = @($subscribedSkus | Where-Object { $sku = $_ - $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } - } + $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds.Values } + }) # Check if GSA licenses exist and are enabled - $enabledGsaSkus = $gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' } + $enabledGsaSkus = @($gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' }) if ($gsaSkus.Count -eq 0 -or $enabledGsaSkus.Count -eq 0) { $testResultMarkdown = "❌ GSA licenses are not available or not assigned to users in the tenant.`n`n%TestResult%" @@ -110,9 +110,9 @@ function Test-Assessment-25375 { } # Count users with GSA licenses assigned - $usersWithInternetAccess = @() - $usersWithPrivateAccess = @() - $usersWithAnyGsa = @() + $usersWithInternetAccess = [System.Collections.Generic.List[object]]::new() + $usersWithPrivateAccess = [System.Collections.Generic.List[object]]::new() + $usersWithAnyGsa = [System.Collections.Generic.List[object]]::new() foreach ($user in $allUsers) { if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { @@ -128,13 +128,13 @@ function Test-Assessment-25375 { $disabledPlans = $license.DisabledPlans # Check if Internet Access is enabled - $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq '8d23cb83-ab07-418f-8517-d7aca77307dc' } + $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } if ($internetAccessPlan -and $internetAccessPlan.ServicePlanId -notin $disabledPlans) { $hasInternetAccess = $true } # Check if Private Access is enabled - $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq 'f057aab1-b184-49b2-85c0-881b02a405c5' } + $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } if ($privateAccessPlan -and $privateAccessPlan.ServicePlanId -notin $disabledPlans) { $hasPrivateAccess = $true } @@ -142,13 +142,13 @@ function Test-Assessment-25375 { } if ($hasInternetAccess) { - $usersWithInternetAccess += $user + $usersWithInternetAccess.Add($user) } if ($hasPrivateAccess) { - $usersWithPrivateAccess += $user + $usersWithPrivateAccess.Add($user) } if ($hasInternetAccess -or $hasPrivateAccess) { - $usersWithAnyGsa += $user + $usersWithAnyGsa.Add($user) } } @@ -214,7 +214,7 @@ function Test-Assessment-25375 { # Build service plan table $servicePlanTableRows = '' foreach ($sku in $enabledGsaSkus) { - $gsaPlans = $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds } + $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 From 04821b09deaf16fd61a81d9a243830d38bb5f24d Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Fri, 13 Feb 2026 09:58:27 +0530 Subject: [PATCH 05/25] updated code --- .../tests/Test-Assessment.25375.ps1 | 145 ++++++++++-------- 1 file changed, 83 insertions(+), 62 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index 9d8bbe1911..f44347841f 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -80,12 +80,11 @@ function Test-Assessment-25375 { #region Assessment Logic $testResultMarkdown = '' $passed = $false - $customStatus = $null # Handle any query failure - cannot determine license status if ($skuCmdletFailed -or $userCmdletFailed) { - $testResultMarkdown = "⚠️ Unable to determine GSA license status.`n`n%TestResult%" - $customStatus = 'Skipped' + Write-PSFMessage "Failed to retrieve GSA license data" -Tag Test -Level Error + return } # Filter SKUs containing GSA service plans elseif ($null -ne $subscribedSkus) { @@ -98,72 +97,93 @@ function Test-Assessment-25375 { $enabledGsaSkus = @($gsaSkus | Where-Object { $_.CapabilityStatus -eq 'Enabled' }) if ($gsaSkus.Count -eq 0 -or $enabledGsaSkus.Count -eq 0) { - $testResultMarkdown = "❌ GSA licenses are not available or not assigned to users in the tenant.`n`n%TestResult%" + # 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 } - else { - Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' - # Build SKU ID to SKU mapping for user license cross-reference - $gsaSkuIds = @{} - foreach ($sku in $enabledGsaSkus) { - $gsaSkuIds[$sku.SkuId] = $sku + Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' + + # Build SKU ID to SKU mapping for user license cross-reference + $gsaSkuIds = @{} + foreach ($sku in $enabledGsaSkus) { + $gsaSkuIds[$sku.SkuId] = $sku + } + + # Pre-filter GSA service plans for performance + $internetAccessPlansBySkuId = @{} + $privateAccessPlansBySkuId = @{} + foreach ($sku in $enabledGsaSkus) { + $internetPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } + if ($internetPlan) { + $internetAccessPlansBySkuId[$sku.SkuId] = $internetPlan + } + $privatePlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } + if ($privatePlan) { + $privateAccessPlansBySkuId[$sku.SkuId] = $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() + # 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() - foreach ($user in $allUsers) { - if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { - continue - } + foreach ($user in $allUsers) { + if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { + continue + } - $hasInternetAccess = $false - $hasPrivateAccess = $false + $hasInternetAccess = $false + $hasPrivateAccess = $false - foreach ($license in $user.AssignedLicenses) { - if ($gsaSkuIds.ContainsKey($license.SkuId)) { - $sku = $gsaSkuIds[$license.SkuId] - $disabledPlans = $license.DisabledPlans + foreach ($license in $user.AssignedLicenses) { + if ($gsaSkuIds.ContainsKey($license.SkuId)) { + $disabledPlans = $license.DisabledPlans - # Check if Internet Access is enabled - $internetAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } - if ($internetAccessPlan -and $internetAccessPlan.ServicePlanId -notin $disabledPlans) { + # Check if Internet Access is enabled + if ($internetAccessPlansBySkuId.ContainsKey($license.SkuId)) { + $internetAccessPlan = $internetAccessPlansBySkuId[$license.SkuId] + if ($internetAccessPlan.ServicePlanId -notin $disabledPlans) { $hasInternetAccess = $true } + } - # Check if Private Access is enabled - $privateAccessPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } - if ($privateAccessPlan -and $privateAccessPlan.ServicePlanId -notin $disabledPlans) { + # Check if Private Access is enabled + if ($privateAccessPlansBySkuId.ContainsKey($license.SkuId)) { + $privateAccessPlan = $privateAccessPlansBySkuId[$license.SkuId] + if ($privateAccessPlan.ServicePlanId -notin $disabledPlans) { $hasPrivateAccess = $true } } } - - if ($hasInternetAccess) { - $usersWithInternetAccess.Add($user) - } - if ($hasPrivateAccess) { - $usersWithPrivateAccess.Add($user) - } - if ($hasInternetAccess -or $hasPrivateAccess) { - $usersWithAnyGsa.Add($user) - } } - $gsaUserCount = $usersWithAnyGsa.Count - - # Evaluate test result - if ($gsaUserCount -eq 0) { - $passed = $false - $testResultMarkdown = "❌ GSA licenses are not available or not assigned to users in the tenant.`n`n%TestResult%" + if ($hasInternetAccess) { + $usersWithInternetAccess.Add($user) } - else { - $passed = $true - $testResultMarkdown = "✅ GSA licenses are available in the tenant and assigned to users.`n`n%TestResult%" + if ($hasPrivateAccess) { + $usersWithPrivateAccess.Add($user) + } + if ($hasInternetAccess -or $hasPrivateAccess) { + $usersWithAnyGsa.Add($user) } } + + $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 @@ -179,19 +199,19 @@ function Test-Assessment-25375 { ## [{0}]({1}) -**GSA license summary:** +**GSA License Summary:** -| Sku name | Status | Available | Assigned | +| SKU Name | Status | Available | Assigned | | :------- | :----- | --------: | -------: | {2} -**GSA service plans detected:** +**GSA Service Plans Detected:** -| Service plan | Sku | +| Service Plan | SKU | | :----------- | :-- | {3} -**User assignment summary:** +**User Assignment Summary:** | Metric | Value | | :----- | ----: | @@ -224,8 +244,8 @@ function Test-Assessment-25375 { } # Build user assignment summary - $assignmentSummary = "| Users with GSA internet access | $($usersWithInternetAccess.Count) |`n" - $assignmentSummary += "| Users with GSA private access | $($usersWithPrivateAccess.Count) |`n" + $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) @@ -238,15 +258,19 @@ function Test-Assessment-25375 { $userListSection += "**Users with GSA licenses:**`n`n" } - $userListSection += "| Display name | User principal name | Internet access | Private access |`n" + $userListSection += "| Display name | User principal name | Internet Access | Private Access |`n" $userListSection += "| :----------- | :------------------ | :-------------- | :------------- |`n" + # Build HashSets for efficient ID lookups + $internetAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithInternetAccess.Id)) + $privateAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithPrivateAccess.Id)) + $displayUsers = $usersWithAnyGsa | Select-Object -First 10 foreach ($user in $displayUsers) { $displayName = Get-SafeMarkdown -Text $user.DisplayName $upn = Get-SafeMarkdown -Text $user.UserPrincipalName - $hasInternet = if ($user.Id -in $usersWithInternetAccess.Id) { '✅' } else { '❌' } - $hasPrivate = if ($user.Id -in $usersWithPrivateAccess.Id) { '✅' } else { '❌' } + $hasInternet = if ($internetAccessIds.Contains($user.Id)) { '✅' } else { '❌' } + $hasPrivate = if ($privateAccessIds.Contains($user.Id)) { '✅' } else { '❌' } $userListSection += "| $displayName | $upn | $hasInternet | $hasPrivate |`n" } @@ -269,8 +293,5 @@ function Test-Assessment-25375 { Status = $passed Result = $testResultMarkdown } - if ($customStatus) { - $params.CustomStatus = $customStatus - } Add-ZtTestResultDetail @params } From de7ed5ab358d26547925656fb3e8b16801774b82 Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Fri, 13 Feb 2026 13:20:02 +0530 Subject: [PATCH 06/25] resolved copilot comments --- .../tests/Test-Assessment.25375.ps1 | 163 +++++++++--------- 1 file changed, 82 insertions(+), 81 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index f44347841f..b486dca724 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -29,7 +29,7 @@ function Test-Assessment-25375 { SfiPillar = 'Protect networks', TenantType = ('Workforce'), TestId = 25375, - Title = 'GSA Licenses are available in the Tenant and assigned to users', + Title = 'GSA Licenses are available in the tenant and assigned to users', UserImpact = 'Low' )] [CmdletBinding()] @@ -84,107 +84,108 @@ function Test-Assessment-25375 { # Handle any query failure - cannot determine license status if ($skuCmdletFailed -or $userCmdletFailed) { Write-PSFMessage "Failed to retrieve GSA license data" -Tag Test -Level Error + Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'Failed to retrieve GSA license data from Microsoft Graph.' return } + # Filter SKUs containing GSA service plans - elseif ($null -ne $subscribedSkus) { - $gsaSkus = @($subscribedSkus | Where-Object { - $sku = $_ - $sku.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 - } + $gsaSkus = @($subscribedSkus | Where-Object { + $sku = $_ + $sku.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' + Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' - # Build SKU ID to SKU mapping for user license cross-reference - $gsaSkuIds = @{} - foreach ($sku in $enabledGsaSkus) { - $gsaSkuIds[$sku.SkuId] = $sku - } + # Build SKU ID to SKU mapping for user license cross-reference + $gsaSkuIds = @{} + foreach ($sku in $enabledGsaSkus) { + $gsaSkuIds[$sku.SkuId] = $sku + } - # Pre-filter GSA service plans for performance - $internetAccessPlansBySkuId = @{} - $privateAccessPlansBySkuId = @{} - foreach ($sku in $enabledGsaSkus) { - $internetPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } - if ($internetPlan) { - $internetAccessPlansBySkuId[$sku.SkuId] = $internetPlan - } - $privatePlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } - if ($privatePlan) { - $privateAccessPlansBySkuId[$sku.SkuId] = $privatePlan - } + # Pre-filter GSA service plans for performance + $internetAccessPlansBySkuId = @{} + $privateAccessPlansBySkuId = @{} + foreach ($sku in $enabledGsaSkus) { + $internetPlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.InternetAccess } + if ($internetPlan) { + $internetAccessPlansBySkuId[$sku.SkuId] = $internetPlan } + $privatePlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } + if ($privatePlan) { + $privateAccessPlansBySkuId[$sku.SkuId] = $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() + # 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() - foreach ($user in $allUsers) { - if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { - continue - } + foreach ($user in $allUsers) { + if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { + continue + } - $hasInternetAccess = $false - $hasPrivateAccess = $false + $hasInternetAccess = $false + $hasPrivateAccess = $false - foreach ($license in $user.AssignedLicenses) { - if ($gsaSkuIds.ContainsKey($license.SkuId)) { - $disabledPlans = $license.DisabledPlans + foreach ($license in $user.AssignedLicenses) { + if ($gsaSkuIds.ContainsKey($license.SkuId)) { + # Defensive: treat null disabledPlans as empty array + $disabledPlans = if ($license.DisabledPlans) { $license.DisabledPlans } else { @() } - # Check if Internet Access is enabled - if ($internetAccessPlansBySkuId.ContainsKey($license.SkuId)) { - $internetAccessPlan = $internetAccessPlansBySkuId[$license.SkuId] - if ($internetAccessPlan.ServicePlanId -notin $disabledPlans) { - $hasInternetAccess = $true - } + # Check if Internet Access is enabled + if ($internetAccessPlansBySkuId.ContainsKey($license.SkuId)) { + $internetAccessPlan = $internetAccessPlansBySkuId[$license.SkuId] + if ($internetAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasInternetAccess = $true } + } - # Check if Private Access is enabled - if ($privateAccessPlansBySkuId.ContainsKey($license.SkuId)) { - $privateAccessPlan = $privateAccessPlansBySkuId[$license.SkuId] - if ($privateAccessPlan.ServicePlanId -notin $disabledPlans) { - $hasPrivateAccess = $true - } + # Check if Private Access is enabled + if ($privateAccessPlansBySkuId.ContainsKey($license.SkuId)) { + $privateAccessPlan = $privateAccessPlansBySkuId[$license.SkuId] + if ($privateAccessPlan.ServicePlanId -notin $disabledPlans) { + $hasPrivateAccess = $true } } } - - if ($hasInternetAccess) { - $usersWithInternetAccess.Add($user) - } - if ($hasPrivateAccess) { - $usersWithPrivateAccess.Add($user) - } - if ($hasInternetAccess -or $hasPrivateAccess) { - $usersWithAnyGsa.Add($user) - } } - $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%" + if ($hasInternetAccess) { + $usersWithInternetAccess.Add($user) + } + if ($hasPrivateAccess) { + $usersWithPrivateAccess.Add($user) } - 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%" + if ($hasInternetAccess -or $hasPrivateAccess) { + $usersWithAnyGsa.Add($user) } } + + $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 @@ -289,7 +290,7 @@ function Test-Assessment-25375 { $params = @{ TestId = '25375' - Title = 'GSA Licenses are available in the Tenant and assigned to users' + Title = 'GSA Licenses are available in the tenant and assigned to users' Status = $passed Result = $testResultMarkdown } From 180eb4f9b84b23561ff5edd97c445f8332e8a37a Mon Sep 17 00:00:00 2001 From: alexandair Date: Fri, 13 Feb 2026 18:25:42 +0000 Subject: [PATCH 07/25] Trigger validation with current main From cefdbc33afdc5f425313a6b9c94442c77ae7e94a Mon Sep 17 00:00:00 2001 From: alexandair Date: Sun, 15 Feb 2026 22:26:09 +0000 Subject: [PATCH 08/25] Remove backticks in Invoke-ZtGraphRequest call for subscribed SKUs --- src/powershell/tests/Test-Assessment.25375.ps1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index b486dca724..ba3b88e32d 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -54,9 +54,7 @@ function Test-Assessment-25375 { # Query 1: Retrieve tenant licenses with GSA service plans try { - $subscribedSkus = Invoke-ZtGraphRequest ` - -RelativeUri 'subscribedSkus' ` - -ApiVersion beta + $subscribedSkus = Invoke-ZtGraphRequest -RelativeUri 'subscribedSkus' -ApiVersion beta } catch { $skuCmdletFailed = $true From 09094299c6f427108e14a08d753dfedb7e72fa22 Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Mon, 16 Feb 2026 10:46:27 +0530 Subject: [PATCH 09/25] used database query --- .../tests/Test-Assessment.25375.ps1 | 101 +++++++++++------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index ba3b88e32d..9cb53912a8 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -13,7 +13,8 @@ .NOTES Test ID: 25375 Category: Global Secure Access - Required API: subscribedSkus (beta), users with assignedLicenses (beta) + 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 @@ -33,7 +34,9 @@ function Test-Assessment-25375 { UserImpact = 'Low' )] [CmdletBinding()] - param() + param( + $Database + ) #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose @@ -50,7 +53,7 @@ function Test-Assessment-25375 { $skuCmdletFailed = $false $userCmdletFailed = $false $subscribedSkus = @() - $allUsers = @() + $userLicenses = @() # Query 1: Retrieve tenant licenses with GSA service plans try { @@ -63,11 +66,22 @@ function Test-Assessment-25375 { Write-ZtProgress -Activity $activity -Status 'Querying user license assignments' - # Query 2: Retrieve all users with assigned licenses + # Query 2: Retrieve all users with assigned licenses from database try { - $allUsers = Invoke-ZtGraphRequest ` - -RelativeUri 'users?$select=id,displayName,userPrincipalName,assignedLicenses&$count=true' ` - -ApiVersion beta + $sqlUsers = @" +SELECT + u.id, + u.displayName, + u.userPrincipalName, + unnest(u.assignedLicenses).skuId as skuId, + unnest(u.assignedLicenses).disabledPlans as disabledPlans +FROM "User" u +WHERE u.assignedLicenses IS NOT NULL + AND u.assignedLicenses != '[]' +"@ + $userLicenses = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlUsers -AsCustomObject) + # Filter out any records with null IDs + $userLicenses = @($userLicenses | Where-Object { $_.id }) } catch { $userCmdletFailed = $true @@ -82,7 +96,7 @@ function Test-Assessment-25375 { # Handle any query failure - cannot determine license status if ($skuCmdletFailed -or $userCmdletFailed) { Write-PSFMessage "Failed to retrieve GSA license data" -Tag Test -Level Error - Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'Failed to retrieve GSA license data from Microsoft Graph.' + Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'Failed to retrieve GSA license data.' return } @@ -104,23 +118,24 @@ function Test-Assessment-25375 { Write-ZtProgress -Activity $activity -Status 'Analyzing user license assignments' - # Build SKU ID to SKU mapping for user license cross-reference + # Build SKU ID to SKU mapping and pre-filter service plans for performance $gsaSkuIds = @{} - foreach ($sku in $enabledGsaSkus) { - $gsaSkuIds[$sku.SkuId] = $sku - } + $internetAccessPlansBySku = @{} + $privateAccessPlansBySku = @{} - # Pre-filter GSA service plans for performance - $internetAccessPlansBySkuId = @{} - $privateAccessPlansBySkuId = @{} 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) { - $internetAccessPlansBySkuId[$sku.SkuId] = $internetPlan + $internetAccessPlansBySku[$skuIdString] = $internetPlan } + $privatePlan = $sku.ServicePlans | Where-Object { $_.ServicePlanId -eq $gsaServicePlanIds.PrivateAccess } if ($privatePlan) { - $privateAccessPlansBySkuId[$sku.SkuId] = $privatePlan + $privateAccessPlansBySku[$skuIdString] = $privatePlan } } @@ -129,45 +144,59 @@ function Test-Assessment-25375 { $usersWithPrivateAccess = [System.Collections.Generic.List[object]]::new() $usersWithAnyGsa = [System.Collections.Generic.List[object]]::new() - foreach ($user in $allUsers) { - if (-not $user.AssignedLicenses -or $user.AssignedLicenses.Count -eq 0) { - continue - } + # 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 ($license in $user.AssignedLicenses) { - if ($gsaSkuIds.ContainsKey($license.SkuId)) { - # Defensive: treat null disabledPlans as empty array - $disabledPlans = if ($license.DisabledPlans) { $license.DisabledPlans } else { @() } + 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 is enabled - if ($internetAccessPlansBySkuId.ContainsKey($license.SkuId)) { - $internetAccessPlan = $internetAccessPlansBySkuId[$license.SkuId] - if ($internetAccessPlan.ServicePlanId -notin $disabledPlans) { + # 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 is enabled - if ($privateAccessPlansBySkuId.ContainsKey($license.SkuId)) { - $privateAccessPlan = $privateAccessPlansBySkuId[$license.SkuId] - if ($privateAccessPlan.ServicePlanId -notin $disabledPlans) { + # 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($user) + $usersWithInternetAccess.Add($userObj) } if ($hasPrivateAccess) { - $usersWithPrivateAccess.Add($user) + $usersWithPrivateAccess.Add($userObj) } if ($hasInternetAccess -or $hasPrivateAccess) { - $usersWithAnyGsa.Add($user) + $usersWithAnyGsa.Add($userObj) } } From e417e774f399d744b708b8a2dd079d5b15716866 Mon Sep 17 00:00:00 2001 From: Afif Ahmed Patel Date: Tue, 17 Feb 2026 13:21:42 +0530 Subject: [PATCH 10/25] Add test files for 26887 --- src/powershell/tests/Test-Assessment.26887.md | 15 + .../tests/Test-Assessment.26887.ps1 | 364 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.26887.md create mode 100644 src/powershell/tests/Test-Assessment.26887.ps1 diff --git a/src/powershell/tests/Test-Assessment.26887.md b/src/powershell/tests/Test-Assessment.26887.md new file mode 100644 index 0000000000..eca1014533 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26887.md @@ -0,0 +1,15 @@ +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..467f1d66cc --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -0,0 +1,364 @@ +<# +.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'), + 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 + } + + # Q1: List all subscriptions + Write-ZtProgress -Activity $activity -Status 'Querying subscriptions' + + $subscriptionsPath = '/subscriptions?api-version=2022-12-01' + $subscriptions = $null + + try { + $result = Invoke-AzRestMethod -Path $subscriptionsPath -ErrorAction Stop + + if ($result.StatusCode -eq 403) { + Write-PSFMessage 'The signed in user does not have access to list subscriptions.' -Level Verbose + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + + if ($result.StatusCode -ge 400) { + throw "Subscriptions request failed with status code $($result.StatusCode)" + } + + # Azure REST list APIs are paginated. + # Handling nextLink is required to avoid missing subscriptions. + $allSubscriptions = @() + $subscriptionsJson = $result.Content | ConvertFrom-Json + + if ($subscriptionsJson.value) { + $allSubscriptions += $subscriptionsJson.value + } + + $nextLink = $subscriptionsJson.nextLink + try { + while ($nextLink) { + $result = Invoke-AzRestMethod -Uri $nextLink -Method GET + $subscriptionsJson = $result.Content | ConvertFrom-Json + if ($subscriptionsJson.value) { + $allSubscriptions += $subscriptionsJson.value + } + $nextLink = $subscriptionsJson.nextLink + } + } + catch { + Write-PSFMessage "Failed to retrieve next page of subscriptions: $_. Continuing with collected data." -Level Warning + } + + $subscriptions = $allSubscriptions | Where-Object { $_.state -eq 'Enabled' } + } + catch { + Write-PSFMessage "Failed to enumerate Azure subscriptions while evaluating Azure Firewall diagnostic logging: $_" -Level Error + throw + } + + if ($null -eq $subscriptions -or $subscriptions.Count -eq 0) { + Write-PSFMessage 'No enabled subscriptions found.' -Level Warning + Add-ZtTestResultDetail -SkippedBecause NotSupported + return + } + + # Collect Azure Firewall resources across all subscriptions + $allFirewalls = @() + $firewallQuerySuccess = $false + + foreach ($subscription in $subscriptions) { + $subscriptionId = $subscription.subscriptionId + + # Q2: List Azure Firewalls + Write-ZtProgress -Activity $activity -Status "Querying Azure Firewalls in subscription $subscriptionId" + + $firewallListPath = "/subscriptions/$subscriptionId/providers/Microsoft.Network/azureFirewalls?api-version=2025-03-01" + + try { + $firewallListResult = Invoke-AzRestMethod -Path $firewallListPath -ErrorAction Stop + + if ($firewallListResult.StatusCode -lt 400) { + $firewallQuerySuccess = $true + # Azure REST list APIs are paginated. + # Handling nextLink is required to avoid missing Azure Firewalls. + $allFirewallsInSub = @() + $firewallJson = $firewallListResult.Content | ConvertFrom-Json + + if ($firewallJson.value) { + $allFirewallsInSub += $firewallJson.value + } + + $nextLink = $firewallJson.nextLink + try { + while ($nextLink) { + $firewallListResult = Invoke-AzRestMethod -Uri $nextLink -Method GET + $firewallJson = $firewallListResult.Content | ConvertFrom-Json + if ($firewallJson.value) { + $allFirewallsInSub += $firewallJson.value + } + $nextLink = $firewallJson.nextLink + } + } + catch { + Write-PSFMessage "Failed to retrieve next page of Azure Firewalls for subscription '$subscriptionId': $_. Continuing with collected data." -Level Warning + } + + # Filter for active firewalls (provisioningState == Succeeded) + $activeFirewalls = $allFirewallsInSub | Where-Object { $_.properties.provisioningState -eq 'Succeeded' } + foreach ($firewall in $activeFirewalls) { + $allFirewalls += [PSCustomObject]@{ + SubscriptionId = $subscriptionId + SubscriptionName = $subscription.displayName + Firewall = $firewall + } + } + } + } + catch { + Write-PSFMessage "Error querying Azure Firewalls in subscription $subscriptionId : $_" -Level Warning + } + } + + # Check if any Azure Firewall resources exist + if ($allFirewalls.Count -eq 0) { + if (-not $firewallQuerySuccess) { + Write-PSFMessage 'Unable to query Azure Firewall resources in any subscription due to access restrictions.' -Level Warning + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + Write-PSFMessage 'No Azure Firewall resources found.' -Tag Test -Level VeryVerbose + Add-ZtTestResultDetail -SkippedBecause NotLicensedOrNotApplicable + return + } + + # Q3: Get diagnostic settings for each Azure Firewall + Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings' + + $evaluationResults = @() + + foreach ($fwItem in $allFirewalls) { + $firewall = $fwItem.Firewall + $firewallId = $firewall.id + $firewallName = $firewall.name + $firewallLocation = $firewall.location + $firewallSku = "$($firewall.properties.sku.name)/$($firewall.properties.sku.tier)" + + # Q3: Query diagnostic settings + $diagPath = $firewallId + '/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview' + + $diagSettings = @() + try { + $diagResult = Invoke-AzRestMethod -Path $diagPath -ErrorAction Stop + + if ($diagResult.StatusCode -lt 400) { + $diagSettings = ($diagResult.Content | ConvertFrom-Json).value + } + } + catch { + Write-PSFMessage "Error querying diagnostic settings for $firewallName : $_" -Level Warning + } + + # Evaluate diagnostic settings + $hasValidDiagSetting = $false + $destinationType = 'None' + $enabledCategories = @() + $diagSettingName = 'None' + + 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 + $diagSettingName = $setting.name + $destinationType = $destTypes -join ', ' + $enabledCategories += $settingEnabledCategories + } + } + } + + # Deduplicate enabled categories (multiple settings may enable same categories) + $enabledCategories = $enabledCategories | Select-Object -Unique + + $status = if ($hasValidDiagSetting) { 'Pass' } else { 'Fail' } + + $evaluationResults += [PSCustomObject]@{ + SubscriptionId = $fwItem.SubscriptionId + SubscriptionName = $fwItem.SubscriptionName + FirewallName = $firewallName + FirewallId = $firewallId + Location = $firewallLocation + Sku = $firewallSku + DiagnosticSettingCount = $diagSettings.Count + DiagnosticSettingName = $diagSettingName + 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 +} From 31d6a86a990c26aad46360c0009a302a30b798dc Mon Sep 17 00:00:00 2001 From: Afif Ahmed Patel Date: Tue, 17 Feb 2026 13:49:43 +0530 Subject: [PATCH 11/25] Made changes as per copilot suggestions --- src/powershell/tests/Test-Assessment.26887.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.26887.ps1 b/src/powershell/tests/Test-Assessment.26887.ps1 index 467f1d66cc..fcd455641c 100644 --- a/src/powershell/tests/Test-Assessment.26887.ps1 +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -76,7 +76,7 @@ function Test-Assessment-26887 { # Azure REST list APIs are paginated. # Handling nextLink is required to avoid missing subscriptions. $allSubscriptions = @() - $subscriptionsJson = $result.Content | ConvertFrom-Json + $subscriptionsJson = $result.Content | ConvertFrom-Json -ErrorAction Stop if ($subscriptionsJson.value) { $allSubscriptions += $subscriptionsJson.value @@ -86,7 +86,7 @@ function Test-Assessment-26887 { try { while ($nextLink) { $result = Invoke-AzRestMethod -Uri $nextLink -Method GET - $subscriptionsJson = $result.Content | ConvertFrom-Json + $subscriptionsJson = $result.Content | ConvertFrom-Json -ErrorAction Stop if ($subscriptionsJson.value) { $allSubscriptions += $subscriptionsJson.value } @@ -130,7 +130,7 @@ function Test-Assessment-26887 { # Azure REST list APIs are paginated. # Handling nextLink is required to avoid missing Azure Firewalls. $allFirewallsInSub = @() - $firewallJson = $firewallListResult.Content | ConvertFrom-Json + $firewallJson = $firewallListResult.Content | ConvertFrom-Json -ErrorAction Stop if ($firewallJson.value) { $allFirewallsInSub += $firewallJson.value @@ -140,7 +140,7 @@ function Test-Assessment-26887 { try { while ($nextLink) { $firewallListResult = Invoke-AzRestMethod -Uri $nextLink -Method GET - $firewallJson = $firewallListResult.Content | ConvertFrom-Json + $firewallJson = $firewallListResult.Content | ConvertFrom-Json -ErrorAction Stop if ($firewallJson.value) { $allFirewallsInSub += $firewallJson.value } @@ -199,7 +199,7 @@ function Test-Assessment-26887 { $diagResult = Invoke-AzRestMethod -Path $diagPath -ErrorAction Stop if ($diagResult.StatusCode -lt 400) { - $diagSettings = ($diagResult.Content | ConvertFrom-Json).value + $diagSettings = ($diagResult.Content | ConvertFrom-Json -ErrorAction Stop).value } } catch { From c1484eb76052831a88fc84c7ecf13a6b39c89e83 Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Wed, 18 Feb 2026 21:54:47 +0530 Subject: [PATCH 12/25] resolved PR comments --- .../tests/Test-Assessment.25375.ps1 | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index 9cb53912a8..19a34dc9c8 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -57,7 +57,7 @@ function Test-Assessment-25375 { # Query 1: Retrieve tenant licenses with GSA service plans try { - $subscribedSkus = Invoke-ZtGraphRequest -RelativeUri 'subscribedSkus' -ApiVersion beta + $subscribedSkus = Invoke-ZtGraphRequest -RelativeUri 'subscribedSkus' -ApiVersion beta -ErrorAction Stop } catch { $skuCmdletFailed = $true @@ -79,7 +79,7 @@ FROM "User" u WHERE u.assignedLicenses IS NOT NULL AND u.assignedLicenses != '[]' "@ - $userLicenses = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlUsers -AsCustomObject) + $userLicenses = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlUsers -AsCustomObject -ErrorAction Stop) # Filter out any records with null IDs $userLicenses = @($userLicenses | Where-Object { $_.id }) } @@ -92,18 +92,21 @@ WHERE u.assignedLicenses IS NOT NULL #region Assessment Logic $testResultMarkdown = '' $passed = $false + $customStatus = $null # Handle any query failure - cannot determine license status if ($skuCmdletFailed -or $userCmdletFailed) { - Write-PSFMessage "Failed to retrieve GSA license data" -Tag Test -Level Error - Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'Failed to retrieve GSA license data.' + 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 { - $sku = $_ - $sku.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds.Values } + $_.ServicePlans | Where-Object { $_.ServicePlanId -in $gsaServicePlanIds.Values } }) # Check if GSA licenses exist and are enabled @@ -290,8 +293,17 @@ WHERE u.assignedLicenses IS NOT NULL $userListSection += "| :----------- | :------------------ | :-------------- | :------------- |`n" # Build HashSets for efficient ID lookups - $internetAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithInternetAccess.Id)) - $privateAccessIds = [System.Collections.Generic.HashSet[string]]::new([string[]]($usersWithPrivateAccess.Id)) + 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) { @@ -321,5 +333,8 @@ WHERE u.assignedLicenses IS NOT NULL Status = $passed Result = $testResultMarkdown } + if ($customStatus) { + $params.CustomStatus = $customStatus + } Add-ZtTestResultDetail @params } From 4e08d8917d8cdd074c5ddf61263a76749fd0a1af Mon Sep 17 00:00:00 2001 From: Manoj K Date: Thu, 19 Feb 2026 14:44:52 +0530 Subject: [PATCH 13/25] Feature-25533 --- src/powershell/tests/Test-Assessment.25533.MD | 16 ++ .../tests/Test-Assessment.25533.ps1 | 263 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.25533.MD create mode 100644 src/powershell/tests/Test-Assessment.25533.ps1 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..391003d449 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -0,0 +1,263 @@ +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 + } + + # Query Network Interfaces to VNET mapping + Write-ZtProgress -Activity $activity -Status 'Querying Network Interface associations' + + $nicVnetCache = @{} + $nicQuery = @" +Resources +| where type =~ 'microsoft.network/networkinterfaces' +| mvexpand ipConfigurations = properties.ipConfigurations +| project + nicId = tolower(id), + subnetId = tolower(ipConfigurations.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct nicId, vnetId +"@ + + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $nicQuery) | ForEach-Object { $nicVnetCache[$_.nicId] = $_.vnetId } + Write-PSFMessage "NIC Query returned $($nicVnetCache.Count) records" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Network Interface query failed: $($_.Exception.Message)" -Tag Test -Level Warning + } + + # Query VNET DDoS protection settings + Write-ZtProgress -Activity $activity -Status 'Querying VNET DDoS settings' + + $vnetDdosCache = @{} + $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 + } + #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 NIC/Resource ID and lookup VNET from cache + $nicId = ($pip.ipConfigId -split '/ipConfigurations/')[0].ToLower() + $vnetId = $nicVnetCache[$nicId] + + 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' + } + } + else { + # VNET not found in cache - 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 { -not $_.IsCompliant }).Count -eq 0 + + if ($passed) { + $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%" + } + 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' } + 'N/A' { 'N/A' } + default { $item.VnetDdosProtection } + } + + # Format overall status + $statusDisplay = if ($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 +} From 6270ead535269d9235feba28437020ca263ec34d Mon Sep 17 00:00:00 2001 From: Afif Ahmed Patel Date: Thu, 19 Feb 2026 16:57:09 +0530 Subject: [PATCH 14/25] Made changes as per Alek's suggestions --- src/powershell/tests/Test-Assessment.26887.md | 21 ++ .../tests/Test-Assessment.26887.ps1 | 288 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.26887.md create mode 100644 src/powershell/tests/Test-Assessment.26887.ps1 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..6e66a18b18 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -0,0 +1,288 @@ +<# +.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 + $destinationType = 'None' + $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 + $destinationType = $destTypes -join ', ' + $enabledCategories += $settingEnabledCategories + } + } + } + + # Deduplicate enabled categories (multiple settings may enable same categories) + $enabledCategories = $enabledCategories | Select-Object -Unique + + $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 +} From 1de217251a4212a9fe7ec2f19cb84c851ef27d75 Mon Sep 17 00:00:00 2001 From: Afif Ahmed Patel Date: Thu, 19 Feb 2026 20:49:00 +0530 Subject: [PATCH 15/25] Made changes as per Copilot suggestions --- src/powershell/tests/Test-Assessment.26887.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.26887.ps1 b/src/powershell/tests/Test-Assessment.26887.ps1 index 6e66a18b18..a7b7648aba 100644 --- a/src/powershell/tests/Test-Assessment.26887.ps1 +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -132,7 +132,7 @@ Resources # Evaluate diagnostic settings $hasValidDiagSetting = $false - $destinationType = 'None' + $allDestinationTypes = @() $enabledCategories = @() $diagSettingNames = @() @@ -168,14 +168,15 @@ Resources if ($settingEnabledCategories.Count -gt 0) { $hasValidDiagSetting = $true $diagSettingNames += $setting.name - $destinationType = $destTypes -join ', ' + $allDestinationTypes += $destTypes $enabledCategories += $settingEnabledCategories } } } - # Deduplicate enabled categories (multiple settings may enable same categories) + # 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' } From ce8a5730a6fc8f9a862c96126008d6491f5d2d88 Mon Sep 17 00:00:00 2001 From: Afif Ahmed Patel Date: Thu, 19 Feb 2026 22:02:41 +0530 Subject: [PATCH 16/25] test-26888 --- src/powershell/tests/Test-Assessment.26888.md | 21 ++ .../tests/Test-Assessment.26888.ps1 | 289 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.26888.md create mode 100644 src/powershell/tests/Test-Assessment.26888.ps1 diff --git a/src/powershell/tests/Test-Assessment.26888.md b/src/powershell/tests/Test-Assessment.26888.md new file mode 100644 index 0000000000..9e296ac4dc --- /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..ec76bd2068 --- /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 +} From 89b51956e82227bf60f53a7af2bd93d1c4f2c770 Mon Sep 17 00:00:00 2001 From: aahmed-spec Date: Thu, 19 Feb 2026 22:06:20 +0530 Subject: [PATCH 17/25] Delete src/powershell/tests/Test-Assessment.26887.ps1 --- .../tests/Test-Assessment.26887.ps1 | 364 ------------------ 1 file changed, 364 deletions(-) delete mode 100644 src/powershell/tests/Test-Assessment.26887.ps1 diff --git a/src/powershell/tests/Test-Assessment.26887.ps1 b/src/powershell/tests/Test-Assessment.26887.ps1 deleted file mode 100644 index fcd455641c..0000000000 --- a/src/powershell/tests/Test-Assessment.26887.ps1 +++ /dev/null @@ -1,364 +0,0 @@ -<# -.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'), - 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 - } - - # Q1: List all subscriptions - Write-ZtProgress -Activity $activity -Status 'Querying subscriptions' - - $subscriptionsPath = '/subscriptions?api-version=2022-12-01' - $subscriptions = $null - - try { - $result = Invoke-AzRestMethod -Path $subscriptionsPath -ErrorAction Stop - - if ($result.StatusCode -eq 403) { - Write-PSFMessage 'The signed in user does not have access to list subscriptions.' -Level Verbose - Add-ZtTestResultDetail -SkippedBecause NoAzureAccess - return - } - - if ($result.StatusCode -ge 400) { - throw "Subscriptions request failed with status code $($result.StatusCode)" - } - - # Azure REST list APIs are paginated. - # Handling nextLink is required to avoid missing subscriptions. - $allSubscriptions = @() - $subscriptionsJson = $result.Content | ConvertFrom-Json -ErrorAction Stop - - if ($subscriptionsJson.value) { - $allSubscriptions += $subscriptionsJson.value - } - - $nextLink = $subscriptionsJson.nextLink - try { - while ($nextLink) { - $result = Invoke-AzRestMethod -Uri $nextLink -Method GET - $subscriptionsJson = $result.Content | ConvertFrom-Json -ErrorAction Stop - if ($subscriptionsJson.value) { - $allSubscriptions += $subscriptionsJson.value - } - $nextLink = $subscriptionsJson.nextLink - } - } - catch { - Write-PSFMessage "Failed to retrieve next page of subscriptions: $_. Continuing with collected data." -Level Warning - } - - $subscriptions = $allSubscriptions | Where-Object { $_.state -eq 'Enabled' } - } - catch { - Write-PSFMessage "Failed to enumerate Azure subscriptions while evaluating Azure Firewall diagnostic logging: $_" -Level Error - throw - } - - if ($null -eq $subscriptions -or $subscriptions.Count -eq 0) { - Write-PSFMessage 'No enabled subscriptions found.' -Level Warning - Add-ZtTestResultDetail -SkippedBecause NotSupported - return - } - - # Collect Azure Firewall resources across all subscriptions - $allFirewalls = @() - $firewallQuerySuccess = $false - - foreach ($subscription in $subscriptions) { - $subscriptionId = $subscription.subscriptionId - - # Q2: List Azure Firewalls - Write-ZtProgress -Activity $activity -Status "Querying Azure Firewalls in subscription $subscriptionId" - - $firewallListPath = "/subscriptions/$subscriptionId/providers/Microsoft.Network/azureFirewalls?api-version=2025-03-01" - - try { - $firewallListResult = Invoke-AzRestMethod -Path $firewallListPath -ErrorAction Stop - - if ($firewallListResult.StatusCode -lt 400) { - $firewallQuerySuccess = $true - # Azure REST list APIs are paginated. - # Handling nextLink is required to avoid missing Azure Firewalls. - $allFirewallsInSub = @() - $firewallJson = $firewallListResult.Content | ConvertFrom-Json -ErrorAction Stop - - if ($firewallJson.value) { - $allFirewallsInSub += $firewallJson.value - } - - $nextLink = $firewallJson.nextLink - try { - while ($nextLink) { - $firewallListResult = Invoke-AzRestMethod -Uri $nextLink -Method GET - $firewallJson = $firewallListResult.Content | ConvertFrom-Json -ErrorAction Stop - if ($firewallJson.value) { - $allFirewallsInSub += $firewallJson.value - } - $nextLink = $firewallJson.nextLink - } - } - catch { - Write-PSFMessage "Failed to retrieve next page of Azure Firewalls for subscription '$subscriptionId': $_. Continuing with collected data." -Level Warning - } - - # Filter for active firewalls (provisioningState == Succeeded) - $activeFirewalls = $allFirewallsInSub | Where-Object { $_.properties.provisioningState -eq 'Succeeded' } - foreach ($firewall in $activeFirewalls) { - $allFirewalls += [PSCustomObject]@{ - SubscriptionId = $subscriptionId - SubscriptionName = $subscription.displayName - Firewall = $firewall - } - } - } - } - catch { - Write-PSFMessage "Error querying Azure Firewalls in subscription $subscriptionId : $_" -Level Warning - } - } - - # Check if any Azure Firewall resources exist - if ($allFirewalls.Count -eq 0) { - if (-not $firewallQuerySuccess) { - Write-PSFMessage 'Unable to query Azure Firewall resources in any subscription due to access restrictions.' -Level Warning - Add-ZtTestResultDetail -SkippedBecause NoAzureAccess - return - } - Write-PSFMessage 'No Azure Firewall resources found.' -Tag Test -Level VeryVerbose - Add-ZtTestResultDetail -SkippedBecause NotLicensedOrNotApplicable - return - } - - # Q3: Get diagnostic settings for each Azure Firewall - Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings' - - $evaluationResults = @() - - foreach ($fwItem in $allFirewalls) { - $firewall = $fwItem.Firewall - $firewallId = $firewall.id - $firewallName = $firewall.name - $firewallLocation = $firewall.location - $firewallSku = "$($firewall.properties.sku.name)/$($firewall.properties.sku.tier)" - - # Q3: Query diagnostic settings - $diagPath = $firewallId + '/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview' - - $diagSettings = @() - try { - $diagResult = Invoke-AzRestMethod -Path $diagPath -ErrorAction Stop - - if ($diagResult.StatusCode -lt 400) { - $diagSettings = ($diagResult.Content | ConvertFrom-Json -ErrorAction Stop).value - } - } - catch { - Write-PSFMessage "Error querying diagnostic settings for $firewallName : $_" -Level Warning - } - - # Evaluate diagnostic settings - $hasValidDiagSetting = $false - $destinationType = 'None' - $enabledCategories = @() - $diagSettingName = 'None' - - 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 - $diagSettingName = $setting.name - $destinationType = $destTypes -join ', ' - $enabledCategories += $settingEnabledCategories - } - } - } - - # Deduplicate enabled categories (multiple settings may enable same categories) - $enabledCategories = $enabledCategories | Select-Object -Unique - - $status = if ($hasValidDiagSetting) { 'Pass' } else { 'Fail' } - - $evaluationResults += [PSCustomObject]@{ - SubscriptionId = $fwItem.SubscriptionId - SubscriptionName = $fwItem.SubscriptionName - FirewallName = $firewallName - FirewallId = $firewallId - Location = $firewallLocation - Sku = $firewallSku - DiagnosticSettingCount = $diagSettings.Count - DiagnosticSettingName = $diagSettingName - 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 -} From 9c2ab11c43282d25065357d81c32637457db6966 Mon Sep 17 00:00:00 2001 From: aahmed-spec Date: Thu, 19 Feb 2026 22:06:42 +0530 Subject: [PATCH 18/25] Delete src/powershell/tests/Test-Assessment.26887.md --- src/powershell/tests/Test-Assessment.26887.md | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/powershell/tests/Test-Assessment.26887.md diff --git a/src/powershell/tests/Test-Assessment.26887.md b/src/powershell/tests/Test-Assessment.26887.md deleted file mode 100644 index eca1014533..0000000000 --- a/src/powershell/tests/Test-Assessment.26887.md +++ /dev/null @@ -1,15 +0,0 @@ -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% From 6203efaefc3ec01ec5988e063b194f337457e6af Mon Sep 17 00:00:00 2001 From: alexandair Date: Thu, 19 Feb 2026 19:05:32 +0000 Subject: [PATCH 19/25] Fix casing in Azure Resource Graph query for consistency --- src/powershell/tests/Test-Assessment.26887.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.26887.ps1 b/src/powershell/tests/Test-Assessment.26887.ps1 index a7b7648aba..8905af7193 100644 --- a/src/powershell/tests/Test-Assessment.26887.ps1 +++ b/src/powershell/tests/Test-Assessment.26887.ps1 @@ -72,11 +72,11 @@ function Test-Assessment-26887 { Write-ZtProgress -Activity $activity -Status 'Querying Azure Firewalls via Resource Graph' $argQuery = @" -Resources +resources | where type =~ 'microsoft.network/azurefirewalls' | where properties.provisioningState =~ 'Succeeded' | join kind=leftouter ( - ResourceContainers + resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionName=name, subscriptionId ) on subscriptionId From 179ea0d6736017fc3b2cdaf6134c47206dc0272b Mon Sep 17 00:00:00 2001 From: alexandair Date: Thu, 19 Feb 2026 19:11:29 +0000 Subject: [PATCH 20/25] Fix formatting of remediation action links in Test-Assessment.26888.md --- src/powershell/tests/Test-Assessment.26888.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.26888.md b/src/powershell/tests/Test-Assessment.26888.md index 9e296ac4dc..55884b9fc2 100644 --- a/src/powershell/tests/Test-Assessment.26888.md +++ b/src/powershell/tests/Test-Assessment.26888.md @@ -3,19 +3,19 @@ Azure Application Gateway Web Application Firewall (WAF) protects web applicatio **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) +- [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) +- [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) +- [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) +- [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) +- [Azure Monitor Workbooks](https://learn.microsoft.com/en-us/azure/azure-monitor/visualize/workbooks-overview) %TestResult% From 611f3da6e24a5913865579529dc5d8e9ed9b3db0 Mon Sep 17 00:00:00 2001 From: alexandair Date: Thu, 19 Feb 2026 19:15:16 +0000 Subject: [PATCH 21/25] Fix casing inconsistencies in resource type and update table header in Test-Assessment.26888.ps1 --- src/powershell/tests/Test-Assessment.26888.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.26888.ps1 b/src/powershell/tests/Test-Assessment.26888.ps1 index ec76bd2068..b85e7e888a 100644 --- a/src/powershell/tests/Test-Assessment.26888.ps1 +++ b/src/powershell/tests/Test-Assessment.26888.ps1 @@ -72,12 +72,12 @@ function Test-Assessment-26888 { Write-ZtProgress -Activity $activity -Status 'Querying Application Gateways via Resource Graph' $argQuery = @" -Resources -| where type =~ 'microsoft.network/applicationGateways' +resources +| where type =~ 'microsoft.network/applicationgateways' | where properties.provisioningState =~ 'Succeeded' | where properties.sku.tier in~ ('WAF', 'WAF_v2') | join kind=leftouter ( - ResourceContainers + resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionName=name, subscriptionId ) on subscriptionId @@ -226,7 +226,7 @@ Resources if ($evaluationResults.Count -gt 0) { $tableRows = "" $formatTemplate = @' -| Subscription | Gateway name | Location | SKU Tier | Diagnostic settings count | Destination configured | Enabled log categories | Status | +| Subscription | Gateway name | Location | SKU tier | Diagnostic settings count | Destination configured | Enabled log categories | Status | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | {0} From ddffa5062fb307d80686f9aa1af0aace88227702 Mon Sep 17 00:00:00 2001 From: Ashwini Karke Date: Fri, 20 Feb 2026 12:52:03 +0530 Subject: [PATCH 22/25] updated db query --- src/powershell/tests/Test-Assessment.25375.ps1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25375.ps1 b/src/powershell/tests/Test-Assessment.25375.ps1 index 19a34dc9c8..12ab82cd14 100644 --- a/src/powershell/tests/Test-Assessment.25375.ps1 +++ b/src/powershell/tests/Test-Assessment.25375.ps1 @@ -73,11 +73,10 @@ SELECT u.id, u.displayName, u.userPrincipalName, - unnest(u.assignedLicenses).skuId as skuId, - unnest(u.assignedLicenses).disabledPlans as disabledPlans + unnest(u.assignedLicenses).skuId::VARCHAR AS skuId, + unnest(u.assignedLicenses).disabledPlans AS disabledPlans FROM "User" u -WHERE u.assignedLicenses IS NOT NULL - AND u.assignedLicenses != '[]' +WHERE len(u.assignedLicenses) > 0 "@ $userLicenses = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlUsers -AsCustomObject -ErrorAction Stop) # Filter out any records with null IDs From d96c0b750d61e85a4cc08fe2860344f51772055c Mon Sep 17 00:00:00 2001 From: Manoj K Date: Thu, 19 Feb 2026 14:44:52 +0530 Subject: [PATCH 23/25] Feature-25533 --- src/powershell/tests/Test-Assessment.25533.MD | 16 ++ .../tests/Test-Assessment.25533.ps1 | 263 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.25533.MD create mode 100644 src/powershell/tests/Test-Assessment.25533.ps1 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..391003d449 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -0,0 +1,263 @@ +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 + } + + # Query Network Interfaces to VNET mapping + Write-ZtProgress -Activity $activity -Status 'Querying Network Interface associations' + + $nicVnetCache = @{} + $nicQuery = @" +Resources +| where type =~ 'microsoft.network/networkinterfaces' +| mvexpand ipConfigurations = properties.ipConfigurations +| project + nicId = tolower(id), + subnetId = tolower(ipConfigurations.properties.subnet.id) +| extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) +| distinct nicId, vnetId +"@ + + try { + @(Invoke-ZtAzureResourceGraphRequest -Query $nicQuery) | ForEach-Object { $nicVnetCache[$_.nicId] = $_.vnetId } + Write-PSFMessage "NIC Query returned $($nicVnetCache.Count) records" -Tag Test -Level VeryVerbose + } + catch { + Write-PSFMessage "Network Interface query failed: $($_.Exception.Message)" -Tag Test -Level Warning + } + + # Query VNET DDoS protection settings + Write-ZtProgress -Activity $activity -Status 'Querying VNET DDoS settings' + + $vnetDdosCache = @{} + $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 + } + #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 NIC/Resource ID and lookup VNET from cache + $nicId = ($pip.ipConfigId -split '/ipConfigurations/')[0].ToLower() + $vnetId = $nicVnetCache[$nicId] + + 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' + } + } + else { + # VNET not found in cache - 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 { -not $_.IsCompliant }).Count -eq 0 + + if ($passed) { + $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%" + } + 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' } + 'N/A' { 'N/A' } + default { $item.VnetDdosProtection } + } + + # Format overall status + $statusDisplay = if ($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 +} From 6e788a1305bd4a3fdcc10afd990d55cef2043689 Mon Sep 17 00:00:00 2001 From: Manoj K Date: Fri, 20 Feb 2026 22:06:59 +0530 Subject: [PATCH 24/25] Feedback Addressed --- .../tests/Test-Assessment.25533.ps1 | 175 ++++++++++++++++-- 1 file changed, 156 insertions(+), 19 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25533.ps1 b/src/powershell/tests/Test-Assessment.25533.ps1 index 391003d449..0d000da1cd 100644 --- a/src/powershell/tests/Test-Assessment.25533.ps1 +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -55,33 +55,148 @@ Resources return } - # Query Network Interfaces to VNET mapping - Write-ZtProgress -Activity $activity -Status 'Querying Network Interface associations' + # 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' - $nicVnetCache = @{} + $resourceVnetCache = @{} + $resourceQueryFailed = $false + + # NICs — subnet in ipConfigurations[].properties.subnet.id $nicQuery = @" Resources | where type =~ 'microsoft.network/networkinterfaces' | mvexpand ipConfigurations = properties.ipConfigurations | project - nicId = tolower(id), + resourceId = tolower(id), subnetId = tolower(ipConfigurations.properties.subnet.id) | extend vnetId = tolower(substring(subnetId, 0, indexof(subnetId, '/subnets/'))) -| distinct nicId, vnetId +| distinct resourceId, vnetId "@ - try { - @(Invoke-ZtAzureResourceGraphRequest -Query $nicQuery) | ForEach-Object { $nicVnetCache[$_.nicId] = $_.vnetId } - Write-PSFMessage "NIC Query returned $($nicVnetCache.Count) records" -Tag Test -Level VeryVerbose + @(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' @@ -91,13 +206,13 @@ Resources 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 @@ -145,9 +260,20 @@ Resources $resourceType = $resourceTypeRaw } - # Extract NIC/Resource ID and lookup VNET from cache - $nicId = ($pip.ipConfigId -split '/ipConfigurations/')[0].ToLower() - $vnetId = $nicVnetCache[$nicId] + # 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 @@ -164,8 +290,14 @@ Resources $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 { - # VNET not found in cache - no DDoS protection + # Queries succeeded but resource not in cache — VNET has no DDoS protection $isCompliant = $false $vnetDdosStatus = 'Disabled' } @@ -190,11 +322,15 @@ Resources } } - $passed = @($findings | Where-Object { -not $_.IsCompliant }).Count -eq 0 + $passed = @($findings | Where-Object { $_.IsCompliant -eq $false }).Count -eq 0 + $unknownCount = @($findings | Where-Object { $null -eq $_.IsCompliant }).Count - if ($passed) { + 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%" } @@ -236,14 +372,15 @@ Resources # Format VNET DDoS status $vnetDdosDisplay = switch ($item.VnetDdosProtection) { - 'Enabled' { '✅ Enabled' } + 'Enabled' { '✅ Enabled' } 'Disabled' { '❌ Disabled' } - 'N/A' { 'N/A' } - default { $item.VnetDdosProtection } + 'Unknown' { '⚠️ Unknown' } + 'N/A' { 'N/A' } + default { $item.VnetDdosProtection } } # Format overall status - $statusDisplay = if ($item.IsCompliant) { '✅ Pass' } else { '❌ Fail' } + $statusDisplay = if ($null -eq $item.IsCompliant) { '⚠️ Unknown' } elseif ($item.IsCompliant) { '✅ Pass' } else { '❌ Fail' } $tableRows += "| $pipMd | $protectionDisplay | $resourceTypeDisplay | $vnetDisplay | $vnetDdosDisplay | $statusDisplay |`n" } From e5c5ca9a3132ffd1cba27efc494472f3ef2580ad Mon Sep 17 00:00:00 2001 From: Manoj Kesana Date: Fri, 20 Feb 2026 22:07:30 +0530 Subject: [PATCH 25/25] Update src/powershell/tests/Test-Assessment.25533.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tests/Test-Assessment.25533.ps1 | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25533.ps1 b/src/powershell/tests/Test-Assessment.25533.ps1 index 391003d449..14ee5d74fe 100644 --- a/src/powershell/tests/Test-Assessment.25533.ps1 +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -145,29 +145,37 @@ Resources $resourceType = $resourceTypeRaw } - # Extract NIC/Resource ID and lookup VNET from cache - $nicId = ($pip.ipConfigId -split '/ipConfigurations/')[0].ToLower() - $vnetId = $nicVnetCache[$nicId] - - 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' + # Only NIC-attached public IPs can inherit VNET DDoS protection via this lookup + if ($resourceTypeRaw.ToLower() -eq 'networkinterfaces') { + # Extract NIC ID and lookup VNET from cache + $nicId = ($pip.ipConfigId -split '/ipConfigurations/')[0].ToLower() + $vnetId = $nicVnetCache[$nicId] + + 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' + } } else { + # VNET not found in cache - no DDoS protection $isCompliant = $false $vnetDdosStatus = 'Disabled' } } else { - # VNET not found in cache - no DDoS protection - $isCompliant = $false - $vnetDdosStatus = 'Disabled' + # Non-NIC associations cannot be evaluated with NIC VNET cache; mark as not applicable + $isCompliant = $null + $vnetDdosStatus = 'N/A' } } else {