tests/Test-Assessment.25398.ps1
|
<#
.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( $Database ) 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 Helper Functions # 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 } #endregion Helper Functions #region Data Collection # Q1: Get all Private Access apps Write-ZtProgress -Activity $activity -Status 'Retrieving Private Access applications' $privateAccessApps = $null if ($Database) { Write-PSFMessage 'Querying database for Private Access applications' -Tag Test -Level VeryVerbose try { $sql = @" SELECT id, appId, displayName FROM Application WHERE list_contains(tags, 'PrivateAccessNonWebApplication') "@ $privateAccessApps = @(Invoke-DatabaseQuery -Database $Database -Sql $sql -AsCustomObject) Write-PSFMessage "Found $($privateAccessApps.Count) Private Access application(s) from database" -Tag Test -Level VeryVerbose } catch { Write-PSFMessage "Database query failed: $_" -Tag Test -Level Warning $privateAccessApps = $null } } if (-not $privateAccessApps) { Write-PSFMessage 'No Private Access applications found' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Private Access applications configured in this tenant.' return } Write-PSFMessage "Found $($privateAccessApps.Count) Private Access application(s)" -Tag Test -Level VeryVerbose # Initialize tracking collections $dcHosts = @{} # Key: destinationHost, Value: @{SourceApp, Ports, RdpAppFound, RdpAppName} $allAppSegments = @{} # Key: appId, Value: @{App, Segments} # 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) { 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 (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 = @() $hostsWith389 = @() foreach ($segment in $segments) { $ports = $segment.ports # Check if port 88 is explicitly configured (must be discrete, not in a range) # API returns ports as ranges even for single ports (e.g. '88-88'), so check both forms if ($ports -contains '88' -or $ports -contains '88-88') { $has88 = $true $hostsWith88 += $segment.destinationHost } # Check if port 389 is explicitly configured (must be discrete, not in a range) if ($ports -contains '389' -or $ports -contains '389-389') { $has389 = $true $hostsWith389 += $segment.destinationHost } } # 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 ($dcHost in $commonHosts) { if (-not $dcHosts.ContainsKey($dcHost)) { $dcHosts[$dcHost] = @{ 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 # Q2A/Q2B: Identify RDP applications (port 3389 over TCP) $rdpApps = @() $appType = '' if ($dcHosts.Count -gt 0) { # 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) { $appData = $allAppSegments[$appId] foreach ($segment in $appData.Segments) { $destinationHost = $segment.destinationHost $ports = $segment.ports $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 -match 'tcp' -and (Test-PortIncluded -Ports $ports -TargetPort 3389)) { $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 - 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) { $appData = $allAppSegments[$appId] foreach ($segment in $appData.Segments) { $ports = $segment.ports $protocol = $segment.protocol if ($protocol -match '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' } } } } $appType = 'General RDP' } # 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 if ($rdpApps.Count -eq 0) { Write-PSFMessage 'No RDP applications found' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable -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 NotApplicable -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 # The /usage response is { mfa: [...], none: [...] } with minimal policy stubs (no conditions/grantControls). # Collect IDs from both arrays, then fetch each full policy to get conditions and grantControls. $usagePolicyIds = @() if ($caPolicies.mfa) { $usagePolicyIds += $caPolicies.mfa | Select-Object -ExpandProperty id } if ($caPolicies.none) { $usagePolicyIds += $caPolicies.none | Select-Object -ExpandProperty id } $usagePolicyIds = $usagePolicyIds | Select-Object -Unique $enabledPolicies = @() foreach ($policyId in $usagePolicyIds) { try { $fullPolicy = Invoke-ZtGraphRequest -RelativeUri "policies/conditionalAccessPolicies/$policyId" -ApiVersion beta if ($fullPolicy -and $fullPolicy.state -eq 'enabled') { $enabledPolicies += $fullPolicy } } catch { Write-PSFMessage "Unable to fetch full details for CA policy $policyId : $_" -Tag Test -Level Warning } } Write-PSFMessage "Found $($enabledPolicies.Count) enabled CA policy/policies with phishing-resistant MFA" -Tag Test -Level VeryVerbose #endregion Data Collection #region Assessment Logic # Evaluate each RDP app for Conditional Access policy protection with phishing-resistant MFA $results = @() foreach ($rdpApp in $rdpApps) { $protected = $false $protectedBy = 'None' $authStrengthName = 'N/A' $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 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 } elseif ($appFilter) { $protected = $true $protectedBy = $policy.displayName $authStrengthName = 'Phishing-resistant MFA' $status = 'Investigate' $targetingMethod = 'Filter (Custom Security Attributes)' $policyId = $policy.id break } } # 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 DestinationHost = $rdpApp.DestinationHost AppType = $rdpApp.AppType ProtectedBy = $protectedBy AuthStrength = $authStrengthName Status = $status TargetingMethod = $targetingMethod PolicyId = $policyId } } # Determine overall test status $passed = $false $customStatus = $null $testResultMarkdown = '' if ($appType -eq 'DC RDP') { $failedApps = $results | Where-Object { $_.Status -eq 'Fail' } $investigateApps = $results | Where-Object { $_.Status -eq 'Investigate' } 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%" } 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%" } else { $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 { $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%" } #endregion Assessment Logic #region Report Generation $privateAccessLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/EnterpriseApplicationListBladeV3/fromNav/globalSecureAccess/applicationType/GlobalSecureAccessApplication' $caPoliciesLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Policies' # Build DC Hosts section $dcHostsSection = '' if ($dcHosts.Count -gt 0) { $dcHostRows = '' foreach ($dcHost in $dcHosts.Keys) { $info = $dcHosts[$dcHost] $rdpFound = if ($info.RdpAppFound) { 'Yes' } else { 'No' } $dcHostRows += "| $(Get-SafeMarkdown $dcHost) | $(Get-SafeMarkdown $info.SourceApp) | $($info.Ports) | $rdpFound | $(Get-SafeMarkdown $info.RdpAppName) |`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) { $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' { '⚠️' } } $rdpAppRows += "| $(Get-SafeMarkdown $result.AppName) | $(Get-SafeMarkdown $result.AppId) | $(Get-SafeMarkdown $result.DestinationHost) | $(Get-SafeMarkdown $result.AppType) | $policyCell | $($result.AuthStrength) | $statusIcon $($result.Status) |`n" } $rdpAppsSection = @" ## [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 if ($includeApps -contains 'All') { $targetApps = 'All applications' $targetingMethod = 'All Apps' } elseif ($appFilter) { $targetApps = 'Via custom security attributes' $targetingMethod = 'Filter' } else { $appNames = @() 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' } $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 $params = @{ TestId = '25398' Title = 'Domain controller RDP access is protected by phishing-resistant authentication through Global Secure Access' Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |