tests/Test-Assessment.25395.ps1

<#
.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
}