diff --git a/src/powershell/tests/Test-Assessment.25395.md b/src/powershell/tests/Test-Assessment.25395.md new file mode 100644 index 000000000..95f150ef0 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25395.md @@ -0,0 +1,20 @@ +When organizations configure Microsoft Entra Private Access with broad application segments—such as wide IP ranges, multiple protocols, or Quick Access configurations—they effectively replicate the over-permissive access model of traditional VPNs. This approach contradicts the Zero Trust principle of least-privilege access, where users should only reach the specific resources required for their role. Threat actors who compromise a user's credentials or device can leverage these broad network permissions to perform reconnaissance, identifying additional systems and services within the permitted range. + +With visibility into the network topology, they can escalate privileges by targeting vulnerable systems, move laterally to access sensitive data stores or administrative interfaces, and establish persistence by deploying backdoors across multiple accessible systems. The lack of granular segmentation also complicates incident response, as security teams cannot quickly determine which specific resources a compromised identity could access. By contrast, per-application segmentation with tightly scoped destination hosts, specific ports, and Custom Security Attributes enables dynamic, attribute-driven Conditional Access enforcement—requiring stronger authentication or device compliance for high-risk applications while streamlining access to lower-risk resources. + +This approach aligns with the Zero Trust "verify explicitly" principle by ensuring each access request is evaluated against the specific security requirements of the target application rather than applying uniform policies to broad network segments. + +**Remediation action** +- [Transition from Quick Access](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-per-app-access) to per-app Private Access by creating individual Global Secure Access enterprise applications with specific FQDNs, IP addresses, and ports for each private resource. +- [Use Application Discovery](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-application-discovery) to identify which resources users access through Quick Access, then create targeted Private Access apps for those resources. +- [Create Custom Security Attribute sets](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-add) and definitions to categorize Private Access applications by risk level, department, or compliance requirements. +- [Assign Custom Security Attributes](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/custom-security-attributes-apps) to Private Access application service principals to enable attribute-based access control. +- [Create Conditional Access policies using application filters](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-filter-for-applications) to target Private Access apps based on their Custom Security Attributes, enforcing granular controls like MFA or device compliance. +- [Apply Conditional Access policies to Private Access](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-target-resource-private-access-apps) apps from within Global Secure Access for streamlined configuration. + +Review +- [Zero Trust network segmentation guidance for software-defined perimeters](https://learn.microsoft.com/en-us/security/zero-trust/deploy/networks#1-network-segmentation-and-software-defined-perimeters). + + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25395.ps1 b/src/powershell/tests/Test-Assessment.25395.ps1 new file mode 100644 index 000000000..31ca66f74 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25395.ps1 @@ -0,0 +1,405 @@ +<# +.SYNOPSIS + Validates that Entra Private Access applications enforce least-privilege + using granular network segments and Custom Security Attributes (CSA). + +.DESCRIPTION + This test evaluates Private Access applications to ensure segmentation + follows least-privilege principles and supports attribute-based + Conditional Access targeting. + +.NOTES + Test ID: 25395 + Category: Global Secure Access + Required APIs: applications (beta), servicePrincipals (beta), conditionalAccess/policies (beta) +#> + +function Test-Assessment-25395 { + + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'High', + MinimumLicense = 'Entra_Premium_Private_Access', + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = 'Workforce', + TestId = 25395, + Title = 'Private Access application segments enforce least-privilege access', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + # Active Directory well-known ports + $AD_WELL_KNOWN_PORTS = @('53','88','135','389','445','464','636','3268','3269') + + #region Helper Functions + + function Test-IsBroadCidr { + <# + .SYNOPSIS + Checks if a CIDR range is overly permissive (/16 or broader). + .DESCRIPTION + CIDR ranges with prefix length <= 16 are treated as overly permissive. + This includes /16 itself (65,536 IPs) and any broader ranges such as /15, /14, etc. + .OUTPUTS + System.Boolean + True - CIDR prefix length <= 16 + False - CIDR prefix length > 16 or invalid format + #> + param([string]$Cidr) + if ($Cidr -match '/(\d+)$') { return ([int]$matches[1] -le 16) } + return $false + } + + function Test-IsBroadIpRange { + <# + .SYNOPSIS + Checks if an IP range spans more than 256 addresses. + .OUTPUTS + System.Boolean - True if range exceeds 256 addresses, false otherwise. + #> + param([string]$Range) + if ($Range -match '^([\d\.]+)-([\d\.]+)$') { + $start = [System.Net.IPAddress]::Parse($matches[1]).GetAddressBytes() + $end = [System.Net.IPAddress]::Parse($matches[2]).GetAddressBytes() + [array]::Reverse($start) + [array]::Reverse($end) + return (([BitConverter]::ToUInt32($end,0) - [BitConverter]::ToUInt32($start,0) + 1) -gt 256) + } + return $false + } + + function Test-IsBroadPortRange { + <# + .SYNOPSIS + Checks if a port range is overly broad (>10 ports or fully open). + .OUTPUTS + System.Boolean - True if port range is considered too broad, false otherwise. + #> + param([string]$Port) + + # Maximum number of ports allowed in a range before it is considered "broad". + $BroadPortRangeThreshold = 10 + + if ($Port -eq '1-65535') { return $true } + if ($Port -match '^(\d+)-(\d+)$' -and (([int]$matches[2] - [int]$matches[1] + 1) -gt $BroadPortRangeThreshold)) { return $true } + return $false + } + + function Test-IsAdRpcException { + <# + .SYNOPSIS + Checks if a port range is a valid Active Directory RPC ephemeral port exception. + .OUTPUTS + System.Boolean - True if port is a valid AD RPC exception, false otherwise. + #> + param([string]$AppName, [string]$Port) + if ($AppName -match 'Active Directory|Domain Controller|AD DS') { + if ($Port -in @('49152-65535','1025-5000')) { return $true } + } + return $false + } + + function Test-IsAdWellKnownPort { + <# + .SYNOPSIS + Checks if a port is a well-known Active Directory port. + .OUTPUTS + System.Boolean - True if port is a valid AD well-known port, false otherwise. + #> + param([string]$Port) + if ($Port -match '^(\d+)-(\d+)$') { + return ($matches[1] -eq $matches[2] -and $AD_WELL_KNOWN_PORTS -contains $matches[1]) + } + return ($AD_WELL_KNOWN_PORTS -contains $Port) + } + + function Test-ContainsAdWellKnownPort { + <# + .SYNOPSIS + Checks if a port range contains any well-known Active Directory ports. + .DESCRIPTION + Evaluates whether a port range (e.g., '50-500') includes any of the + well-known AD ports (53, 88, 135, 389, 445, 464, 636, 3268, 3269). + .OUTPUTS + System.Boolean - True if range contains AD ports, false otherwise. + #> + param([string]$Port) + if ($Port -match '^(\d+)-(\d+)$') { + $start = [int]$matches[1] + $end = [int]$matches[2] + foreach ($adPort in $AD_WELL_KNOWN_PORTS) { + if ([int]$adPort -ge $start -and [int]$adPort -le $end) { + return $true + } + } + } + return $false + } + + #endregion Helper Functions + + #region Data Collection + + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Evaluating Private Access application segmentation' + Write-ZtProgress -Activity $activity -Status 'Querying applications' + + # Query Q1: List all Private Access enterprise applications + $apps = Invoke-ZtGraphRequest -RelativeUri "applications?`$filter=(tags/any(t:t eq 'PrivateAccessNonWebApplication') or tags/any(t:t eq 'NetworkAccessQuickAccessApplication'))&`$select=id,displayName,appId,tags" -ApiVersion beta + + # Query Q2: Retrieve service principals with Custom Security Attributes + $servicePrincipals = Invoke-ZtGraphRequest -RelativeUri "servicePrincipals?`$filter=(tags/any(t:t eq 'PrivateAccessNonWebApplication') or tags/any(t:t eq 'NetworkAccessQuickAccessApplication'))&`$select=id,appId,displayName,customSecurityAttributes&`$count=true" -ApiVersion beta -ConsistencyLevel eventual + + # Query Q3: Retrieve enabled Conditional Access policies + $caPolicies = $null + $filterPolicies = @() + + if ($null -ne $apps -and $apps.Count -gt 0) { + + Write-ZtProgress -Activity $activity -Status 'Checking Conditional Access policies' + + $allCAPolicies = Get-ZtConditionalAccessPolicy + $caPolicies = $allCAPolicies | Where-Object { $_.state -eq 'enabled' } + + if ($caPolicies) { + $filterPolicies = $caPolicies | Where-Object { + $_.conditions.applications.applicationFilter + } + } + } + + #endregion Data Collection + + #region Assessment Logic + + # Initialize evaluation containers + $passed = $false + $customStatus = $null + $testResultMarkdown = '' + $broadAccessApps = @() + $appsWithoutCSA = @() + $segmentFindings = @() + $appResults = @() + # Step 1: Check if any per-app Private Access applications exist + if ($null -ne $apps -and $apps.Count -gt 0) { + + Write-ZtProgress -Activity $activity -Status 'Evaluating application segments' + + foreach ($app in $apps) { + + # Query Q4: Retrieve application segments for the current app + $segments = Invoke-ZtGraphRequest -RelativeUri "applications/$($app.id)/onPremisesPublishing/segmentsConfiguration/microsoft.graph.ipSegmentConfiguration/applicationSegments" -ApiVersion beta + + $hasBroadSegment = $false + $hasWildcardDns = $false + $hasBroadPorts = $false + $segmentSummary = @() + + if (-not $segments -or $segments.Count -eq 0) { + $segmentSummary = @('No segments configured') + } + + foreach ($segment in $segments) { + + # Step 2: Evaluate segment destination granularity + $issues = @() + + $segmentSummary += "$($segment.destinationHost):$($segment.ports -join ',')" + + switch ($segment.destinationType) { + 'dnsSuffix' { + $hasWildcardDns = $true + $issues += 'Wildcard DNS' + } + 'ipRangeCidr' { + if (Test-IsBroadCidr $segment.destinationHost) { + $hasBroadSegment = $true + $issues += 'Broad CIDR' + } + } + 'ipRange' { + if (Test-IsBroadIpRange $segment.destinationHost) { + $hasBroadSegment = $true + $issues += 'Broad IP range' + } + } + } + + # Step 3: Evaluate port breadth with AD RPC exceptions + foreach ($port in $segment.ports) { + if (Test-IsBroadPortRange $port) { + # Check if this is a valid AD RPC exception or exact AD well-known port + if (-not (Test-IsAdRpcException -AppName $app.displayName -Port $port) ` + -and -not (Test-IsAdWellKnownPort $port)) { + $hasBroadPorts = $true + $issues += 'Broad port range' + + # Additionally flag if the broad range contains AD well-known ports + if (Test-ContainsAdWellKnownPort $port) { + $issues += 'Broad range overlaps AD ports' + } + } + } + } + + # Step 4: Flag dual-protocol usage combined with broad scope + if ($segment.protocol -eq 'tcp,udp' -and $issues.Count -gt 0) { + $hasBroadPorts = $true + $issues += 'Dual protocol with broad scope' + } + + if ($issues.Count -gt 0) { + $segmentFindings += [PSCustomObject]@{ + AppName = $app.displayName + AppId = $app.appId + SegmentId = $segment.id + Issue = ($issues -join ', ') + Destination = $segment.destinationHost + Ports = ($segment.ports -join ', ') + } + } + } + + # Step 5: Identify apps with overly broad access + if ($hasBroadSegment -or $hasWildcardDns -or $hasBroadPorts) { + $broadAccessApps += $app + } + + # Step 6: Check CSA presence for the app + $sp = $servicePrincipals | Where-Object { $_.appId -eq $app.appId } + if (-not $sp.customSecurityAttributes) { + $appsWithoutCSA += $app + } + + # Determine per-app status including Manual Review when filterPolicies exist + $appStatus = if (-not $sp.customSecurityAttributes) { + 'Fail – Missing CSA' + } elseif ($hasBroadSegment -or $hasWildcardDns -or $hasBroadPorts) { + 'Fail – Broad segment' + } elseif ($filterPolicies.Count -gt 0) { + 'Manual Review' + } else { + 'Pass' + } + + $appResults += [PSCustomObject]@{ + AppName = $app.displayName + AppObjectId = $app.id + AppId = $app.appId + SegmentType = if ($segments) { ($segments.destinationType | Select-Object -Unique) -join ', ' } else { 'None' } + SegmentScope = ($segmentSummary -join ' | ') + HasCSA = [bool]$sp.customSecurityAttributes + Status = $appStatus + } + + + } + } + + # Step 7: Determine overall test result (Pass / Fail / Investigate) + + if (-not $apps -or $apps.Count -eq 0) { + + $customStatus = 'Investigate' + $testResultMarkdown = + "⚠️ No per-app Private Access applications configured. Please review the documentation on how to configure Private Access applications with granular network segments.`n`n%TestResult%" + + } + elseif ($broadAccessApps.Count -eq 0 -and $appsWithoutCSA.Count -eq 0) { + + if ($filterPolicies.Count -gt 0) { + + # Pass conditions met but filterPolicies exist - requires manual review + $customStatus = 'Investigate' + $testResultMarkdown = + "⚠️ Private Access applications exist with appropriate segmentation and CSAs assigned. CA policies use applicationFilter targeting. Manual review required to verify CA policy coverage for these apps.`n`n%TestResult%" + + } + else { + + $passed = $true + $testResultMarkdown = + "✅ All Private Access applications are configured with granular network segments and are protected by Conditional Access policies using Custom Security Attributes, enforcing least-privilege access.`n`n%TestResult%" + + } + + } + else { + + $passed = $false + $testResultMarkdown = + "❌ One or more Private Access applications have overly broad network segments or lack Custom Security Attribute-based Conditional Access policies, potentially allowing excessive network access.`n`n%TestResult%" + + } + + #endregion Assessment Logic + + #region Report Generation + + $mdInfo = "`n## Summary`n`n" + $mdInfo += "| Metric | Value |`n|---|---|`n" + $mdInfo += "| Total Private Access apps | $($apps.Count) |`n" + $mdInfo += "| Apps with broad segments | $($broadAccessApps.Count) |`n" + $mdInfo += "| Apps with CSA assigned | $($apps.Count - $appsWithoutCSA.Count) |`n" + $mdInfo += "| Apps without CSA | $($appsWithoutCSA.Count) |`n" + $mdInfo += "| CA policies using applicationFilter | $($filterPolicies.Count) |`n`n" + + if ($appResults.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +## [Application details](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/EnterpriseApplicationListBladeV3/fromNav/globalSecureAccess/applicationType/GlobalSecureAccessApplication) + +| App name | Segment type | Segment scope | Has CSAs | Status | +|---|---|---|---|---| +{0} + +'@ + foreach ($r in $appResults) { + $appLink = "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/overview/appId/$($r.AppId)" + $linkedAppName = "[{0}]({1})" -f (Get-SafeMarkdown $r.AppName), $appLink + $hasCSAText = if ($r.HasCSA) {'Yes'} else {'No'} + $tableRows += "| $linkedAppName | $($r.SegmentType) | $($r.SegmentScope) | $hasCSAText | $($r.Status) |`n" + } + $mdInfo += $formatTemplate -f $tableRows + } + + + if ($segmentFindings.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +## Segment findings + +| App name | Issue | Destination | Ports | Recommendation | +|---|---|---|---|---| +{0} + +'@ + foreach ($f in $segmentFindings) { + $appLink = "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/overview/appId/$($f.AppId)" + $linkedAppName = "[{0}]({1})" -f (Get-SafeMarkdown $f.AppName), $appLink + $tableRows += "| $linkedAppName | $($f.Issue) | $($f.Destination) | $($f.Ports) | Narrow destination and ports |`n" + } + $mdInfo += $formatTemplate -f $tableRows + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + $params = @{ + TestId = '25395' + Title = 'Private Access application segments enforce least-privilege access' + Status = $passed + Result = $testResultMarkdown + } + + # Add CustomStatus if status is 'Investigate' + if ($null -ne $customStatus) { + $params.CustomStatus = $customStatus + } + + # Add test result details + Add-ZtTestResultDetail @params +}