From 617638ae86ab9689bb6a2a6c167827f4f925cbec Mon Sep 17 00:00:00 2001 From: Manoj K Date: Fri, 6 Feb 2026 12:04:45 +0530 Subject: [PATCH 1/4] Feature-25398 --- src/powershell/tests/Test-Assessment.25398.md | 16 + .../tests/Test-Assessment.25398.ps1 | 433 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/powershell/tests/Test-Assessment.25398.md create mode 100644 src/powershell/tests/Test-Assessment.25398.ps1 diff --git a/src/powershell/tests/Test-Assessment.25398.md b/src/powershell/tests/Test-Assessment.25398.md new file mode 100644 index 000000000..4a155581b --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25398.md @@ -0,0 +1,16 @@ +When administrators use Microsoft Entra Private Access to reach domain controllers via Remote Desktop Protocol (RDP), they authenticate through Microsoft Entra ID before the Global Secure Access client tunnels their connection to the on-premises network. If this authentication step relies solely on password-based or standard multifactor authentication, threat actors can intercept credentials during phishing campaigns or adversary-in-the-middle attacks, replay stolen session tokens, and establish persistent RDP connections to domain controllers. + +Once connected, the threat actor can execute DCSync attacks to harvest all password hashes in the domain, create golden tickets for indefinite domain persistence, modify Group Policy Objects to deploy ransomware or backdoors across all domain-joined machines, and extract DPAPI master keys that decrypt enterprise secrets. Domain controllers hold the cryptographic keys to the entire Active Directory forest; compromising one domain controller typically means compromising every identity and resource in the organization. + +By requiring phishing-resistant authentication—FIDO2 security keys, Windows Hello for Business, or certificate-based multifactor authentication—organizations ensure that even if users are successfully phished, threat actors cannot replay credentials because these methods require cryptographic proof of possession that is bound to the legitimate sign-in session and cannot be intercepted or forwarded. + +**Remediation action** + +- [Create a Private Access application for domain controller RDP access with appropriate application segments](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-per-app-access) +- [Configure authentication strength policies to require phishing-resistant MFA](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-strengths) +- [Create a Conditional Access policy targeting Private Access applications with authentication strength grant control](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant#require-authentication-strength) +- [Deploy FIDO2 security keys or configure Windows Hello for Business for administrators](https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-passwordless-security-key) +- [Configure certificate-based authentication for phishing-resistant access](https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-certificate-based-authentication) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25398.ps1 b/src/powershell/tests/Test-Assessment.25398.ps1 new file mode 100644 index 000000000..76d165164 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25398.ps1 @@ -0,0 +1,433 @@ +<# +.SYNOPSIS + Domain controller RDP access is protected by phishing-resistant authentication through Global Secure Access +.DESCRIPTION + Verifies that Private Access applications providing RDP access to domain controllers require phishing-resistant + MFA (FIDO2, Windows Hello for Business, or certificate-based authentication) via Conditional Access policies. +#> + +function Test-Assessment-25398 { + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'Medium', + MinimumLicense = ('AAD_PREMIUM', 'Entra_Premium_Private_Access'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce', 'External'), + TestId = 25398, + Title = 'Domain controller RDP access is protected by phishing-resistant authentication through Global Secure Access', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + Write-PSFMessage '🟦 Start Global Secure Access DC RDP protection evaluation' -Tag Test -Level VeryVerbose + + $activity = 'Checking domain controller RDP access protection' + Write-ZtProgress -Activity $activity -Status 'Checking Microsoft Graph connection' + + #region Data Collection + + # Q1: Get all Private Access apps + Write-ZtProgress -Activity $activity -Status 'Retrieving Private Access applications' + + $privateAccessApps = Invoke-ZtGraphRequest -RelativeUri 'applications' -QueryParameters @{ + '$filter' = "tags/any(t:t eq 'PrivateAccessNonWebApplication')" + '$count' = 'true' + '$select' = 'id,appId,displayName,tags' + } -ConsistencyLevel 'eventual' + + if (-not $privateAccessApps) { + Write-PSFMessage "No Private Access applications found." -Tag Test -Level VeryVerbose + Add-ZtTestResultDetail -SkippedBecause NotSupported -Result "No Private Access applications configured in this tenant." + return + } + + Write-PSFMessage "Found $($privateAccessApps.Count) Private Access application(s)" -Tag Test -Level VeryVerbose + + # Collect DC hosts and RDP apps + $dcHosts = @{} # Key: destinationHost, Value: @{SourceApp, Ports} + $allAppSegments = @{} # Key: appId, Value: segments array + + # Q2A: Get segments for each app and identify DC hosts + Write-ZtProgress -Activity $activity -Status 'Analyzing application segments for DC indicators' + + foreach ($app in $privateAccessApps) { + Write-ZtProgress -Activity $activity -Status "Checking segments for $($app.displayName)" + + try { + $segmentsUri = "applications/$($app.id)/onPremisesPublishing/segmentsConfiguration/microsoft.graph.ipSegmentConfiguration/applicationSegments" + $segments = Invoke-ZtGraphRequest -RelativeUri $segmentsUri -ApiVersion beta + + if ($segments) { + $allAppSegments[$app.appId] = @{ + App = $app + Segments = $segments + } + + # Check for DC indicators (ports 88 AND 389 as discrete values) + $has88 = $false + $has389 = $false + $hostsWith88 = @() + $hostsWith389 = @() + + foreach ($segment in $segments) { + $ports = $segment.port + + # Check if port 88 is explicitly configured (not in a range) + if ($ports -contains '88') { + $has88 = $true + $hostsWith88 += $segment.destinationHost + } + + # Check if port 389 is explicitly configured (not in a range) + if ($ports -contains '389') { + $has389 = $true + $hostsWith389 += $segment.destinationHost + } + } + + # If both ports found, mark hosts as likely DCs + if ($has88 -and $has389) { + $commonHosts = $hostsWith88 | Where-Object { $hostsWith389 -contains $_ } + foreach ($host in $commonHosts) { + if (-not $dcHosts.ContainsKey($host)) { + $dcHosts[$host] = @{ + SourceApp = $app.displayName + Ports = '88, 389' + RdpAppFound = $false + RdpAppName = 'None' + } + } + } + } + } + } + catch { + Write-PSFMessage "Unable to retrieve segments for $($app.displayName): $_" -Tag Test -Level Warning + } + } + + Write-PSFMessage "Identified $($dcHosts.Count) likely DC host(s)" -Tag Test -Level VeryVerbose + + # Identify RDP apps + $rdpApps = @() + $appType = '' + + if ($dcHosts.Count -gt 0) { + # Look for RDP apps targeting DC hosts + Write-ZtProgress -Activity $activity -Status 'Searching for RDP apps targeting DC hosts' + + foreach ($appId in $allAppSegments.Keys) { + $appData = $allAppSegments[$appId] + + foreach ($segment in $appData.Segments) { + $destinationHost = $segment.destinationHost + $ports = $segment.port + $protocol = $segment.protocol + + # Check if this targets a DC host and has RDP (port 3389) + if ($dcHosts.ContainsKey($destinationHost) -and $protocol -eq 'tcp') { + $hasRdp = $false + + # Check for port 3389 (discrete or in range) + foreach ($portValue in $ports) { + if ($portValue -eq '3389') { + $hasRdp = $true + break + } + # Check if it's a port range containing 3389 + if ($portValue -match '^(\d+)-(\d+)$') { + $start = [int]$Matches[1] + $end = [int]$Matches[2] + if (3389 -ge $start -and 3389 -le $end) { + $hasRdp = $true + break + } + } + } + + if ($hasRdp) { + $rdpApps += [PSCustomObject]@{ + AppId = $appData.App.appId + AppName = $appData.App.displayName + DestinationHost = $destinationHost + AppType = 'DC RDP App' + } + + # Update DC host info + $dcHosts[$destinationHost].RdpAppFound = $true + $dcHosts[$destinationHost].RdpAppName = $appData.App.displayName + } + } + } + } + + $appType = 'DC RDP' + } + else { + # Q2B: Fallback - look for any general RDP apps + Write-ZtProgress -Activity $activity -Status 'No DC hosts found, searching for general RDP apps' + + foreach ($appId in $allAppSegments.Keys) { + $appData = $allAppSegments[$appId] + + foreach ($segment in $appData.Segments) { + $ports = $segment.port + $protocol = $segment.protocol + + if ($protocol -eq 'tcp') { + $hasRdp = $false + + foreach ($portValue in $ports) { + if ($portValue -eq '3389') { + $hasRdp = $true + break + } + if ($portValue -match '^(\d+)-(\d+)$') { + $start = [int]$Matches[1] + $end = [int]$Matches[2] + if (3389 -ge $start -and 3389 -le $end) { + $hasRdp = $true + break + } + } + } + + if ($hasRdp) { + $rdpApps += [PSCustomObject]@{ + AppId = $appData.App.appId + AppName = $appData.App.displayName + DestinationHost = $segment.destinationHost + AppType = 'General RDP App' + } + } + } + } + } + + $appType = 'General RDP' + } + + # Remove duplicates + $rdpApps = $rdpApps | Sort-Object AppId -Unique + + Write-PSFMessage "Found $($rdpApps.Count) RDP application(s)" -Tag Test -Level VeryVerbose + + if ($rdpApps.Count -eq 0) { + Write-PSFMessage "No RDP applications found" -Tag Test -Level VeryVerbose + Add-ZtTestResultDetail -SkippedBecause NotSupported -Result "No Private Access applications with RDP access (port 3389) were found." + return + } + + # Q3: Get phishing-resistant MFA authentication strength + Write-ZtProgress -Activity $activity -Status 'Retrieving phishing-resistant MFA authentication strength' + + $authStrength = Invoke-ZtGraphRequest -RelativeUri 'policies/authenticationStrengthPolicies' -QueryParameters @{ + '$filter' = "policyType eq 'builtIn' and displayName eq 'Phishing-resistant MFA'" + } -ApiVersion 'beta' + + if (-not $authStrength -or $authStrength.Count -eq 0) { + Write-PSFMessage "Phishing-resistant MFA authentication strength not found" -Tag Test -Level Warning + Add-ZtTestResultDetail -SkippedBecause NotSupported -Result "Phishing-resistant MFA authentication strength policy not found." + return + } + + $authStrengthId = $authStrength[0].id + + # Q4: Get CA policies using this authentication strength + Write-ZtProgress -Activity $activity -Status 'Checking Conditional Access policies' + + $caPolicies = Invoke-ZtGraphRequest -RelativeUri "policies/authenticationStrengthPolicies/$authStrengthId/usage" -ApiVersion 'beta' + + # Filter for enabled policies only + $enabledPolicies = $caPolicies | Where-Object { $_.state -eq 'enabled' } + + Write-PSFMessage "Found $($enabledPolicies.Count) enabled CA policy/policies with phishing-resistant MFA" -Tag Test -Level VeryVerbose + + #endregion Data Collection + + #region Assessment Logic + + # Check each RDP app for CA policy protection + $results = @() + + foreach ($rdpApp in $rdpApps) { + $protected = $false + $protectedBy = 'None' + $authStrengthName = 'N/A' + $status = 'Fail' + $targetingMethod = 'None' + + foreach ($policy in $enabledPolicies) { + $includeApps = $policy.conditions.applications.includeApplications + $appFilter = $policy.conditions.applications.applicationFilter + + # Check if policy targets this app + if ($includeApps -contains $rdpApp.AppId -or $includeApps -contains 'All') { + $protected = $true + $protectedBy = $policy.displayName + $authStrengthName = 'Phishing-resistant MFA' + $status = 'Pass' + $targetingMethod = if ($includeApps -contains 'All') { 'All Apps' } else { 'Direct' } + break + } + elseif ($appFilter) { + # Policy uses custom security attributes + $protected = $true + $protectedBy = $policy.displayName + $authStrengthName = 'Phishing-resistant MFA' + $status = 'Investigate' + $targetingMethod = 'Filter (Custom Security Attributes)' + break + } + } + + # If it's a general RDP app and not protected, mark as Investigate instead of Fail + if (-not $protected -and $rdpApp.AppType -eq 'General RDP App') { + $status = 'Investigate' + } + + $results += [PSCustomObject]@{ + AppName = $rdpApp.AppName + AppId = $rdpApp.AppId + DestinationHost = $rdpApp.DestinationHost + AppType = $rdpApp.AppType + ProtectedBy = $protectedBy + AuthStrength = $authStrengthName + Status = $status + TargetingMethod = $targetingMethod + PolicyId = if ($protected) { ($enabledPolicies | Where-Object { $_.displayName -eq $protectedBy }).id } else { $null } + } + } + + # Determine overall pass/fail + $passed = $false + $testResultMarkdown = '' + + if ($appType -eq 'DC RDP') { + # DC RDP apps found + $passedApps = $results | Where-Object { $_.Status -eq 'Pass' } + $investigateApps = $results | Where-Object { $_.Status -eq 'Investigate' } + $failedApps = $results | Where-Object { $_.Status -eq 'Fail' } + + if ($passedApps.Count -gt 0 -and $failedApps.Count -eq 0 -and $investigateApps.Count -eq 0) { + $passed = $true + $testResultMarkdown = "✅ RDP access (port 3389) to identified domain controller hosts is protected by a Conditional Access policy requiring phishing-resistant authentication (FIDO2, Windows Hello for Business, or Certificate-based MFA).`n`n%TestResult%" + } + elseif ($investigateApps.Count -gt 0) { + $testResultMarkdown = "⚠️ A Conditional Access policy requiring phishing-resistant authentication targets applications via custom security attributes - manual verification required to confirm the domain controller RDP application has the required attribute assigned (Global Admin cannot read custom security attributes by default).`n`n%TestResult%" + } + else { + $testResultMarkdown = "❌ RDP access (port 3389) to identified domain controller hosts is not protected by a Conditional Access policy requiring phishing-resistant authentication.`n`n%TestResult%" + } + } + else { + # General RDP apps only + $testResultMarkdown = "⚠️ No domain controller hosts identified, but RDP-enabled Private Access applications (port 3389) were found - manual verification recommended to confirm these are not domain controllers and to ensure appropriate protection.`n`n%TestResult%" + } + + #endregion Assessment Logic + + #region Report Generation + + $mdInfo = '' + + # Table 1: Identified DC Hosts (if any) + if ($dcHosts.Count -gt 0) { + $mdInfo += "`n## [Identified domain controller hosts](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateApplications.ReactView)`n`n" + $mdInfo += "| DC host (FQDN/IP) | Source application | Ports configured | RDP app found | RDP app name |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- |`n" + + foreach ($host in $dcHosts.Keys) { + $info = $dcHosts[$host] + $rdpFound = if ($info.RdpAppFound) { 'Yes' } else { 'No' } + $hostSafe = Get-SafeMarkdown -Text $host + $sourceSafe = Get-SafeMarkdown -Text $info.SourceApp + $rdpAppSafe = Get-SafeMarkdown -Text $info.RdpAppName + + $mdInfo += "| $hostSafe | $sourceSafe | $($info.Ports) | $rdpFound | $rdpAppSafe |`n" + } + } + + # Table 2: RDP Applications + $mdInfo += "`n## [Private Access RDP applications requiring protection](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateApplications.ReactView)`n`n" + $mdInfo += "| Application name | App ID | Target host | App type | Protected by CA policy | Authentication strength | Status |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- | :--- |`n" + + foreach ($result in $results) { + $appNameSafe = Get-SafeMarkdown -Text $result.AppName + $appIdSafe = Get-SafeMarkdown -Text $result.AppId + $hostSafe = Get-SafeMarkdown -Text $result.DestinationHost + $appTypeSafe = Get-SafeMarkdown -Text $result.AppType + + $policyLink = if ($result.ProtectedBy -ne 'None' -and $result.PolicyId) { + "[$($result.ProtectedBy)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($result.PolicyId))" + } else { + $result.ProtectedBy + } + + $statusIcon = switch ($result.Status) { + 'Pass' { '✅' } + 'Fail' { '❌' } + 'Investigate' { '⚠️' } + default { '' } + } + + $mdInfo += "| $appNameSafe | $appIdSafe | $hostSafe | $appTypeSafe | $policyLink | $($result.AuthStrength) | $statusIcon $($result.Status) |`n" + } + + # Table 3: CA Policies + if ($enabledPolicies.Count -gt 0) { + $mdInfo += "`n## [Conditional Access policies requiring phishing-resistant MFA](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Policies)`n`n" + $mdInfo += "| Policy name | State | Target applications | Targeting method |`n" + $mdInfo += "| :--- | :--- | :--- | :--- |`n" + + foreach ($policy in $enabledPolicies) { + $policyNameLink = "[$($policy.displayName)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($policy.id))" + + $includeApps = $policy.conditions.applications.includeApplications + $appFilter = $policy.conditions.applications.applicationFilter + + $targetApps = '' + $targetingMethod = '' + + if ($includeApps -contains 'All') { + $targetApps = 'All applications' + $targetingMethod = 'All Apps' + } + elseif ($appFilter) { + $targetApps = 'Via custom security attributes' + $targetingMethod = 'Filter' + } + else { + $appNames = @() + foreach ($appId in $includeApps) { + $matchedApp = $results | Where-Object { $_.AppId -eq $appId } | Select-Object -First 1 + if ($matchedApp) { + $appNames += $matchedApp.AppName + } + } + $targetApps = if ($appNames.Count -gt 0) { ($appNames | Sort-Object -Unique) -join ', ' } else { "$($includeApps.Count) application(s)" } + $targetingMethod = 'Direct' + } + + $targetAppsSafe = Get-SafeMarkdown -Text $targetApps + + $mdInfo += "| $policyNameLink | $($policy.state) | $targetAppsSafe | $targetingMethod |`n" + } + } + + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + #endregion Report Generation + + $params = @{ + TestId = '25398' + Status = $passed + Result = $testResultMarkdown + } + + Add-ZtTestResultDetail @params +} From 651686391f1487f02cbfdebf1a75ffe25aff8c8c Mon Sep 17 00:00:00 2001 From: Manoj K Date: Sat, 14 Feb 2026 01:48:27 +0530 Subject: [PATCH 2/4] Code refactored --- .../tests/Test-Assessment.25398.ps1 | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25398.ps1 b/src/powershell/tests/Test-Assessment.25398.ps1 index 76d165164..ffc8b3793 100644 --- a/src/powershell/tests/Test-Assessment.25398.ps1 +++ b/src/powershell/tests/Test-Assessment.25398.ps1 @@ -46,11 +46,12 @@ function Test-Assessment-25398 { Write-PSFMessage "Found $($privateAccessApps.Count) Private Access application(s)" -Tag Test -Level VeryVerbose - # Collect DC hosts and RDP apps - $dcHosts = @{} # Key: destinationHost, Value: @{SourceApp, Ports} - $allAppSegments = @{} # Key: appId, Value: segments array + # Initialize tracking collections + $dcHosts = @{} # Key: destinationHost, Value: @{SourceApp, Ports, RdpAppFound, RdpAppName} + $allAppSegments = @{} # Key: appId, Value: @{App, Segments} - # Q2A: Get segments for each app and identify DC hosts + # Q2A: Retrieve segments for each app and identify DC hosts + # DC hosts are identified by having BOTH port 88 (Kerberos) AND port 389 (LDAP) explicitly configured Write-ZtProgress -Activity $activity -Status 'Analyzing application segments for DC indicators' foreach ($app in $privateAccessApps) { @@ -66,7 +67,8 @@ function Test-Assessment-25398 { Segments = $segments } - # Check for DC indicators (ports 88 AND 389 as discrete values) + # Check for DC indicators: ports 88 (Kerberos) AND 389 (LDAP) as discrete values + # Note: -contains operator matches exact strings, so '88' won't match ranges like '50-100' $has88 = $false $has389 = $false $hostsWith88 = @() @@ -75,25 +77,26 @@ function Test-Assessment-25398 { foreach ($segment in $segments) { $ports = $segment.port - # Check if port 88 is explicitly configured (not in a range) + # Check if port 88 is explicitly configured (must be discrete, not in a range) if ($ports -contains '88') { $has88 = $true $hostsWith88 += $segment.destinationHost } - # Check if port 389 is explicitly configured (not in a range) + # Check if port 389 is explicitly configured (must be discrete, not in a range) if ($ports -contains '389') { $has389 = $true $hostsWith389 += $segment.destinationHost } } - # If both ports found, mark hosts as likely DCs + # If both port 88 AND port 389 are found, mark hosts with both as likely domain controllers if ($has88 -and $has389) { + # Find hosts that have both ports configured $commonHosts = $hostsWith88 | Where-Object { $hostsWith389 -contains $_ } - foreach ($host in $commonHosts) { - if (-not $dcHosts.ContainsKey($host)) { - $dcHosts[$host] = @{ + foreach ($dcHost in $commonHosts) { + if (-not $dcHosts.ContainsKey($dcHost)) { + $dcHosts[$dcHost] = @{ SourceApp = $app.displayName Ports = '88, 389' RdpAppFound = $false @@ -111,12 +114,12 @@ function Test-Assessment-25398 { Write-PSFMessage "Identified $($dcHosts.Count) likely DC host(s)" -Tag Test -Level VeryVerbose - # Identify RDP apps + # Q2A/Q2B: Identify RDP applications (port 3389 over TCP) $rdpApps = @() $appType = '' if ($dcHosts.Count -gt 0) { - # Look for RDP apps targeting DC hosts + # Q2A: DC hosts identified - search for RDP apps targeting those specific DC hosts Write-ZtProgress -Activity $activity -Status 'Searching for RDP apps targeting DC hosts' foreach ($appId in $allAppSegments.Keys) { @@ -127,17 +130,18 @@ function Test-Assessment-25398 { $ports = $segment.port $protocol = $segment.protocol - # Check if this targets a DC host and has RDP (port 3389) + # Check if this segment targets a DC host AND has RDP access (port 3389 over TCP) if ($dcHosts.ContainsKey($destinationHost) -and $protocol -eq 'tcp') { $hasRdp = $false - # Check for port 3389 (discrete or in range) + # Check for port 3389 (discrete value or within a port range) foreach ($portValue in $ports) { + # Check discrete port 3389 if ($portValue -eq '3389') { $hasRdp = $true break } - # Check if it's a port range containing 3389 + # Check if 3389 is within a port range (e.g., '1-5000' or '3000-4000') if ($portValue -match '^(\d+)-(\d+)$') { $start = [int]$Matches[1] $end = [int]$Matches[2] @@ -167,7 +171,8 @@ function Test-Assessment-25398 { $appType = 'DC RDP' } else { - # Q2B: Fallback - look for any general RDP apps + # Q2B: Fallback - no DC hosts identified, search for any general RDP apps + # These require manual investigation to determine if they target domain controllers Write-ZtProgress -Activity $activity -Status 'No DC hosts found, searching for general RDP apps' foreach ($appId in $allAppSegments.Keys) { @@ -180,11 +185,14 @@ function Test-Assessment-25398 { if ($protocol -eq 'tcp') { $hasRdp = $false + # Check for port 3389 (discrete value or within a port range) foreach ($portValue in $ports) { + # Check discrete port 3389 if ($portValue -eq '3389') { $hasRdp = $true break } + # Check if 3389 is within a port range if ($portValue -match '^(\d+)-(\d+)$') { $start = [int]$Matches[1] $end = [int]$Matches[2] @@ -210,8 +218,9 @@ function Test-Assessment-25398 { $appType = 'General RDP' } - # Remove duplicates - $rdpApps = $rdpApps | Sort-Object AppId -Unique + # Remove duplicates based on AppId and DestinationHost combination + # An app may have multiple segments targeting the same host; we only need one entry per app-host pair + $rdpApps = $rdpApps | Group-Object -Property AppId, DestinationHost | ForEach-Object { $_.Group | Select-Object -First 1 } Write-PSFMessage "Found $($rdpApps.Count) RDP application(s)" -Tag Test -Level VeryVerbose @@ -250,21 +259,23 @@ function Test-Assessment-25398 { #region Assessment Logic - # Check each RDP app for CA policy protection + # Evaluate each RDP app for Conditional Access policy protection with phishing-resistant MFA $results = @() foreach ($rdpApp in $rdpApps) { + # Initialize status variables $protected = $false $protectedBy = 'None' $authStrengthName = 'N/A' - $status = 'Fail' + $status = 'Fail' # Default to Fail for DC RDP apps $targetingMethod = 'None' + # Check if any enabled CA policy with phishing-resistant MFA targets this app foreach ($policy in $enabledPolicies) { $includeApps = $policy.conditions.applications.includeApplications $appFilter = $policy.conditions.applications.applicationFilter - # Check if policy targets this app + # Check if policy targets this app directly or via 'All' if ($includeApps -contains $rdpApp.AppId -or $includeApps -contains 'All') { $protected = $true $protectedBy = $policy.displayName @@ -273,18 +284,19 @@ function Test-Assessment-25398 { $targetingMethod = if ($includeApps -contains 'All') { 'All Apps' } else { 'Direct' } break } + # Check if policy uses custom security attributes (requires manual verification) elseif ($appFilter) { - # Policy uses custom security attributes $protected = $true $protectedBy = $policy.displayName $authStrengthName = 'Phishing-resistant MFA' - $status = 'Investigate' + $status = 'Investigate' # Cannot verify attribute assignment without CustomSecAttributeAssignment.Read.All $targetingMethod = 'Filter (Custom Security Attributes)' break } } - # If it's a general RDP app and not protected, mark as Investigate instead of Fail + # Special handling for General RDP apps: mark as Investigate if not protected + # (Cannot confirm if these target DCs without additional context) if (-not $protected -and $rdpApp.AppType -eq 'General RDP App') { $status = 'Investigate' } @@ -302,29 +314,32 @@ function Test-Assessment-25398 { } } - # Determine overall pass/fail + # Determine overall test status based on individual app results $passed = $false $testResultMarkdown = '' if ($appType -eq 'DC RDP') { - # DC RDP apps found + # DC RDP apps found - evaluate protection status $passedApps = $results | Where-Object { $_.Status -eq 'Pass' } $investigateApps = $results | Where-Object { $_.Status -eq 'Investigate' } $failedApps = $results | Where-Object { $_.Status -eq 'Fail' } + # Pass: All DC RDP apps are protected by CA policies with phishing-resistant MFA if ($passedApps.Count -gt 0 -and $failedApps.Count -eq 0 -and $investigateApps.Count -eq 0) { $passed = $true $testResultMarkdown = "✅ RDP access (port 3389) to identified domain controller hosts is protected by a Conditional Access policy requiring phishing-resistant authentication (FIDO2, Windows Hello for Business, or Certificate-based MFA).`n`n%TestResult%" } + # Investigate: CA policy targets apps via custom security attributes (cannot verify without additional permissions) elseif ($investigateApps.Count -gt 0) { $testResultMarkdown = "⚠️ A Conditional Access policy requiring phishing-resistant authentication targets applications via custom security attributes - manual verification required to confirm the domain controller RDP application has the required attribute assigned (Global Admin cannot read custom security attributes by default).`n`n%TestResult%" } + # Fail: DC RDP apps exist but are not protected by CA policies with phishing-resistant MFA else { $testResultMarkdown = "❌ RDP access (port 3389) to identified domain controller hosts is not protected by a Conditional Access policy requiring phishing-resistant authentication.`n`n%TestResult%" } } else { - # General RDP apps only + # Investigate: General RDP apps found but no DC hosts identified (manual verification needed) $testResultMarkdown = "⚠️ No domain controller hosts identified, but RDP-enabled Private Access applications (port 3389) were found - manual verification recommended to confirm these are not domain controllers and to ensure appropriate protection.`n`n%TestResult%" } @@ -340,10 +355,10 @@ function Test-Assessment-25398 { $mdInfo += "| DC host (FQDN/IP) | Source application | Ports configured | RDP app found | RDP app name |`n" $mdInfo += "| :--- | :--- | :--- | :--- | :--- |`n" - foreach ($host in $dcHosts.Keys) { - $info = $dcHosts[$host] + foreach ($dcHost in $dcHosts.Keys) { + $info = $dcHosts[$dcHost] $rdpFound = if ($info.RdpAppFound) { 'Yes' } else { 'No' } - $hostSafe = Get-SafeMarkdown -Text $host + $hostSafe = Get-SafeMarkdown -Text $dcHost $sourceSafe = Get-SafeMarkdown -Text $info.SourceApp $rdpAppSafe = Get-SafeMarkdown -Text $info.RdpAppName From d18ca09636e8671e28502dc9990e2c341dea80e5 Mon Sep 17 00:00:00 2001 From: Manoj Kesana Date: Mon, 16 Feb 2026 08:55:31 +0530 Subject: [PATCH 3/4] Update src/powershell/tests/Test-Assessment.25398.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/powershell/tests/Test-Assessment.25398.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25398.ps1 b/src/powershell/tests/Test-Assessment.25398.ps1 index 76d165164..ed8c0d054 100644 --- a/src/powershell/tests/Test-Assessment.25398.ps1 +++ b/src/powershell/tests/Test-Assessment.25398.ps1 @@ -210,8 +210,8 @@ function Test-Assessment-25398 { $appType = 'General RDP' } - # Remove duplicates - $rdpApps = $rdpApps | Sort-Object AppId -Unique + # Remove duplicates (per AppId and DestinationHost) + $rdpApps = $rdpApps | Sort-Object AppId, DestinationHost -Unique Write-PSFMessage "Found $($rdpApps.Count) RDP application(s)" -Tag Test -Level VeryVerbose From 1313a91d0d7a9afdba5e7e096a3551a3428c58c5 Mon Sep 17 00:00:00 2001 From: Manoj K Date: Mon, 16 Feb 2026 13:24:13 +0530 Subject: [PATCH 4/4] Refactored the code --- .../tests/Test-Assessment.25398.ps1 | 240 ++++++++---------- 1 file changed, 106 insertions(+), 134 deletions(-) diff --git a/src/powershell/tests/Test-Assessment.25398.ps1 b/src/powershell/tests/Test-Assessment.25398.ps1 index ffc8b3793..22de16681 100644 --- a/src/powershell/tests/Test-Assessment.25398.ps1 +++ b/src/powershell/tests/Test-Assessment.25398.ps1 @@ -29,6 +29,16 @@ function Test-Assessment-25398 { #region Data Collection + # Helper: Check if a specific port is included in a list of port values (discrete or range) + function Test-PortIncluded { + param([string[]]$Ports, [int]$TargetPort) + foreach ($portValue in $Ports) { + if ($portValue -eq $TargetPort.ToString()) { return $true } + if ($portValue -match '^(\d+)-(\d+)$' -and $TargetPort -ge [int]$Matches[1] -and $TargetPort -le [int]$Matches[2]) { return $true } + } + return $false + } + # Q1: Get all Private Access apps Write-ZtProgress -Activity $activity -Status 'Retrieving Private Access applications' @@ -131,39 +141,17 @@ function Test-Assessment-25398 { $protocol = $segment.protocol # Check if this segment targets a DC host AND has RDP access (port 3389 over TCP) - if ($dcHosts.ContainsKey($destinationHost) -and $protocol -eq 'tcp') { - $hasRdp = $false - - # Check for port 3389 (discrete value or within a port range) - foreach ($portValue in $ports) { - # Check discrete port 3389 - if ($portValue -eq '3389') { - $hasRdp = $true - break - } - # Check if 3389 is within a port range (e.g., '1-5000' or '3000-4000') - if ($portValue -match '^(\d+)-(\d+)$') { - $start = [int]$Matches[1] - $end = [int]$Matches[2] - if (3389 -ge $start -and 3389 -le $end) { - $hasRdp = $true - break - } - } + if ($dcHosts.ContainsKey($destinationHost) -and $protocol -eq 'tcp' -and (Test-PortIncluded -Ports $ports -TargetPort 3389)) { + $rdpApps += [PSCustomObject]@{ + AppId = $appData.App.appId + AppName = $appData.App.displayName + DestinationHost = $destinationHost + AppType = 'DC RDP App' } - if ($hasRdp) { - $rdpApps += [PSCustomObject]@{ - AppId = $appData.App.appId - AppName = $appData.App.displayName - DestinationHost = $destinationHost - AppType = 'DC RDP App' - } - - # Update DC host info - $dcHosts[$destinationHost].RdpAppFound = $true - $dcHosts[$destinationHost].RdpAppName = $appData.App.displayName - } + # Update DC host info + $dcHosts[$destinationHost].RdpAppFound = $true + $dcHosts[$destinationHost].RdpAppName = $appData.App.displayName } } } @@ -182,34 +170,12 @@ function Test-Assessment-25398 { $ports = $segment.port $protocol = $segment.protocol - if ($protocol -eq 'tcp') { - $hasRdp = $false - - # Check for port 3389 (discrete value or within a port range) - foreach ($portValue in $ports) { - # Check discrete port 3389 - if ($portValue -eq '3389') { - $hasRdp = $true - break - } - # Check if 3389 is within a port range - if ($portValue -match '^(\d+)-(\d+)$') { - $start = [int]$Matches[1] - $end = [int]$Matches[2] - if (3389 -ge $start -and 3389 -le $end) { - $hasRdp = $true - break - } - } - } - - if ($hasRdp) { - $rdpApps += [PSCustomObject]@{ - AppId = $appData.App.appId - AppName = $appData.App.displayName - DestinationHost = $segment.destinationHost - AppType = 'General RDP App' - } + if ($protocol -eq 'tcp' -and (Test-PortIncluded -Ports $ports -TargetPort 3389)) { + $rdpApps += [PSCustomObject]@{ + AppId = $appData.App.appId + AppName = $appData.App.displayName + DestinationHost = $segment.destinationHost + AppType = 'General RDP App' } } } @@ -235,7 +201,7 @@ function Test-Assessment-25398 { $authStrength = Invoke-ZtGraphRequest -RelativeUri 'policies/authenticationStrengthPolicies' -QueryParameters @{ '$filter' = "policyType eq 'builtIn' and displayName eq 'Phishing-resistant MFA'" - } -ApiVersion 'beta' + } -ApiVersion beta if (-not $authStrength -or $authStrength.Count -eq 0) { Write-PSFMessage "Phishing-resistant MFA authentication strength not found" -Tag Test -Level Warning @@ -248,7 +214,7 @@ function Test-Assessment-25398 { # Q4: Get CA policies using this authentication strength Write-ZtProgress -Activity $activity -Status 'Checking Conditional Access policies' - $caPolicies = Invoke-ZtGraphRequest -RelativeUri "policies/authenticationStrengthPolicies/$authStrengthId/usage" -ApiVersion 'beta' + $caPolicies = Invoke-ZtGraphRequest -RelativeUri "policies/authenticationStrengthPolicies/$authStrengthId/usage" -ApiVersion beta # Filter for enabled policies only $enabledPolicies = $caPolicies | Where-Object { $_.state -eq 'enabled' } @@ -263,83 +229,79 @@ function Test-Assessment-25398 { $results = @() foreach ($rdpApp in $rdpApps) { - # Initialize status variables $protected = $false $protectedBy = 'None' $authStrengthName = 'N/A' - $status = 'Fail' # Default to Fail for DC RDP apps + $status = 'Fail' $targetingMethod = 'None' + $policyId = $null # Check if any enabled CA policy with phishing-resistant MFA targets this app foreach ($policy in $enabledPolicies) { $includeApps = $policy.conditions.applications.includeApplications $appFilter = $policy.conditions.applications.applicationFilter - # Check if policy targets this app directly or via 'All' if ($includeApps -contains $rdpApp.AppId -or $includeApps -contains 'All') { $protected = $true $protectedBy = $policy.displayName $authStrengthName = 'Phishing-resistant MFA' $status = 'Pass' $targetingMethod = if ($includeApps -contains 'All') { 'All Apps' } else { 'Direct' } + $policyId = $policy.id break } - # Check if policy uses custom security attributes (requires manual verification) elseif ($appFilter) { $protected = $true $protectedBy = $policy.displayName $authStrengthName = 'Phishing-resistant MFA' - $status = 'Investigate' # Cannot verify attribute assignment without CustomSecAttributeAssignment.Read.All + $status = 'Investigate' $targetingMethod = 'Filter (Custom Security Attributes)' + $policyId = $policy.id break } } - # Special handling for General RDP apps: mark as Investigate if not protected - # (Cannot confirm if these target DCs without additional context) + # General RDP apps without protection need investigation (cannot confirm if they target DCs) if (-not $protected -and $rdpApp.AppType -eq 'General RDP App') { $status = 'Investigate' } $results += [PSCustomObject]@{ - AppName = $rdpApp.AppName - AppId = $rdpApp.AppId + AppName = $rdpApp.AppName + AppId = $rdpApp.AppId DestinationHost = $rdpApp.DestinationHost - AppType = $rdpApp.AppType - ProtectedBy = $protectedBy - AuthStrength = $authStrengthName - Status = $status + AppType = $rdpApp.AppType + ProtectedBy = $protectedBy + AuthStrength = $authStrengthName + Status = $status TargetingMethod = $targetingMethod - PolicyId = if ($protected) { ($enabledPolicies | Where-Object { $_.displayName -eq $protectedBy }).id } else { $null } + PolicyId = $policyId } } - # Determine overall test status based on individual app results + # Determine overall test status $passed = $false + $customStatus = $null $testResultMarkdown = '' if ($appType -eq 'DC RDP') { - # DC RDP apps found - evaluate protection status - $passedApps = $results | Where-Object { $_.Status -eq 'Pass' } - $investigateApps = $results | Where-Object { $_.Status -eq 'Investigate' } $failedApps = $results | Where-Object { $_.Status -eq 'Fail' } + $investigateApps = $results | Where-Object { $_.Status -eq 'Investigate' } - # Pass: All DC RDP apps are protected by CA policies with phishing-resistant MFA - if ($passedApps.Count -gt 0 -and $failedApps.Count -eq 0 -and $investigateApps.Count -eq 0) { - $passed = $true - $testResultMarkdown = "✅ RDP access (port 3389) to identified domain controller hosts is protected by a Conditional Access policy requiring phishing-resistant authentication (FIDO2, Windows Hello for Business, or Certificate-based MFA).`n`n%TestResult%" + if ($failedApps.Count -gt 0) { + $testResultMarkdown = "❌ RDP access (port 3389) to identified domain controller hosts is not protected by a Conditional Access policy requiring phishing-resistant authentication.`n`n%TestResult%" } - # Investigate: CA policy targets apps via custom security attributes (cannot verify without additional permissions) elseif ($investigateApps.Count -gt 0) { + $customStatus = 'Investigate' $testResultMarkdown = "⚠️ A Conditional Access policy requiring phishing-resistant authentication targets applications via custom security attributes - manual verification required to confirm the domain controller RDP application has the required attribute assigned (Global Admin cannot read custom security attributes by default).`n`n%TestResult%" } - # Fail: DC RDP apps exist but are not protected by CA policies with phishing-resistant MFA else { - $testResultMarkdown = "❌ RDP access (port 3389) to identified domain controller hosts is not protected by a Conditional Access policy requiring phishing-resistant authentication.`n`n%TestResult%" + $passed = $true + $testResultMarkdown = "✅ RDP access (port 3389) to identified domain controller hosts is protected by a Conditional Access policy requiring phishing-resistant authentication (FIDO2, Windows Hello for Business, or Certificate-based MFA).`n`n%TestResult%" } } else { - # Investigate: General RDP apps found but no DC hosts identified (manual verification needed) + $customStatus = 'Investigate' $testResultMarkdown = "⚠️ No domain controller hosts identified, but RDP-enabled Private Access applications (port 3389) were found - manual verification recommended to confirm these are not domain controllers and to ensure appropriate protection.`n`n%TestResult%" } @@ -347,67 +309,64 @@ function Test-Assessment-25398 { #region Report Generation - $mdInfo = '' + $privateAccessLink = 'https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateApplications.ReactView' + $caPoliciesLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Policies' - # Table 1: Identified DC Hosts (if any) + # Build DC Hosts section + $dcHostsSection = '' if ($dcHosts.Count -gt 0) { - $mdInfo += "`n## [Identified domain controller hosts](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateApplications.ReactView)`n`n" - $mdInfo += "| DC host (FQDN/IP) | Source application | Ports configured | RDP app found | RDP app name |`n" - $mdInfo += "| :--- | :--- | :--- | :--- | :--- |`n" - + $dcHostRows = '' foreach ($dcHost in $dcHosts.Keys) { $info = $dcHosts[$dcHost] $rdpFound = if ($info.RdpAppFound) { 'Yes' } else { 'No' } - $hostSafe = Get-SafeMarkdown -Text $dcHost - $sourceSafe = Get-SafeMarkdown -Text $info.SourceApp - $rdpAppSafe = Get-SafeMarkdown -Text $info.RdpAppName - - $mdInfo += "| $hostSafe | $sourceSafe | $($info.Ports) | $rdpFound | $rdpAppSafe |`n" + $dcHostRows += "| $(Get-SafeMarkdown $dcHost) | $(Get-SafeMarkdown $info.SourceApp) | $($info.Ports) | $rdpFound | $(Get-SafeMarkdown $info.RdpAppName) |`n" } - } - # Table 2: RDP Applications - $mdInfo += "`n## [Private Access RDP applications requiring protection](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateApplications.ReactView)`n`n" - $mdInfo += "| Application name | App ID | Target host | App type | Protected by CA policy | Authentication strength | Status |`n" - $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- | :--- |`n" + $dcHostsSection = @" + +## [Identified domain controller hosts]($privateAccessLink) +| DC host (FQDN/IP) | Source application | Ports configured | RDP app found | RDP app name | +| :--- | :--- | :--- | :--- | :--- | +$dcHostRows +"@ + } + + # Build RDP Apps section + $rdpAppRows = '' foreach ($result in $results) { - $appNameSafe = Get-SafeMarkdown -Text $result.AppName - $appIdSafe = Get-SafeMarkdown -Text $result.AppId - $hostSafe = Get-SafeMarkdown -Text $result.DestinationHost - $appTypeSafe = Get-SafeMarkdown -Text $result.AppType - - $policyLink = if ($result.ProtectedBy -ne 'None' -and $result.PolicyId) { - "[$($result.ProtectedBy)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($result.PolicyId))" - } else { - $result.ProtectedBy - } + $policyCell = if ($result.ProtectedBy -ne 'None' -and $result.PolicyId) { + "[$(Get-SafeMarkdown $result.ProtectedBy)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($result.PolicyId))" + } else { $result.ProtectedBy } $statusIcon = switch ($result.Status) { 'Pass' { '✅' } 'Fail' { '❌' } 'Investigate' { '⚠️' } - default { '' } } - $mdInfo += "| $appNameSafe | $appIdSafe | $hostSafe | $appTypeSafe | $policyLink | $($result.AuthStrength) | $statusIcon $($result.Status) |`n" + $rdpAppRows += "| $(Get-SafeMarkdown $result.AppName) | $(Get-SafeMarkdown $result.AppId) | $(Get-SafeMarkdown $result.DestinationHost) | $(Get-SafeMarkdown $result.AppType) | $policyCell | $($result.AuthStrength) | $statusIcon $($result.Status) |`n" } - # Table 3: CA Policies - if ($enabledPolicies.Count -gt 0) { - $mdInfo += "`n## [Conditional Access policies requiring phishing-resistant MFA](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Policies)`n`n" - $mdInfo += "| Policy name | State | Target applications | Targeting method |`n" - $mdInfo += "| :--- | :--- | :--- | :--- |`n" + $rdpAppsSection = @" - foreach ($policy in $enabledPolicies) { - $policyNameLink = "[$($policy.displayName)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($policy.id))" +## [Private Access RDP applications requiring protection]($privateAccessLink) + +| Application name | App ID | Target host | App type | Protected by CA policy | Authentication strength | Status | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +$rdpAppRows +"@ + # Build CA Policies section + $caPoliciesSection = '' + if ($enabledPolicies.Count -gt 0) { + $policyRows = '' + foreach ($policy in $enabledPolicies) { + $policyNameLink = "[$(Get-SafeMarkdown $policy.displayName)](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($policy.id))" + $policyState = Get-FormattedPolicyState $policy.state $includeApps = $policy.conditions.applications.includeApplications $appFilter = $policy.conditions.applications.applicationFilter - $targetApps = '' - $targetingMethod = '' - if ($includeApps -contains 'All') { $targetApps = 'All applications' $targetingMethod = 'All Apps' @@ -418,22 +377,33 @@ function Test-Assessment-25398 { } else { $appNames = @() - foreach ($appId in $includeApps) { - $matchedApp = $results | Where-Object { $_.AppId -eq $appId } | Select-Object -First 1 - if ($matchedApp) { - $appNames += $matchedApp.AppName - } + foreach ($aid in $includeApps) { + $matchedApp = $results | Where-Object { $_.AppId -eq $aid } | Select-Object -First 1 + if ($matchedApp) { $appNames += $matchedApp.AppName } } $targetApps = if ($appNames.Count -gt 0) { ($appNames | Sort-Object -Unique) -join ', ' } else { "$($includeApps.Count) application(s)" } $targetingMethod = 'Direct' } - $targetAppsSafe = Get-SafeMarkdown -Text $targetApps - - $mdInfo += "| $policyNameLink | $($policy.state) | $targetAppsSafe | $targetingMethod |`n" + $policyRows += "| $policyNameLink | $policyState | $(Get-SafeMarkdown $targetApps) | $targetingMethod |`n" } + + $caPoliciesSection = @" + +## [Conditional Access policies requiring phishing-resistant MFA]($caPoliciesLink) + +| Policy name | State | Target applications | Targeting method | +| :--- | :--- | :--- | :--- | +$policyRows +"@ } + # Combine sections using format template + $formatTemplate = @' +{0}{1}{2} +'@ + + $mdInfo = $formatTemplate -f $dcHostsSection, $rdpAppsSection, $caPoliciesSection $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation @@ -443,6 +413,8 @@ function Test-Assessment-25398 { Status = $passed Result = $testResultMarkdown } - + if ($customStatus) { + $params.CustomStatus = $customStatus + } Add-ZtTestResultDetail @params }