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'),
        CompatibleLicense = ('Entra_Premium_Private_Access'),
        Pillar = 'Network',
        RiskLevel = 'High',
        SfiPillar = 'Protect networks',
        TenantType = ('Workforce'),
        TestId = 25395,
        Title = 'Entra Private Access Application segments are defined to enforce least-privilege access',
        UserImpact = 'Medium'
    )]
    [CmdletBinding()]
    param(
        $Database
    )

    # Active Directory well-known ports
    $AD_WELL_KNOWN_PORTS = @('53','88','135','389','445','464','636','3268','3269')

    # Portal link templates
    $portalLinkAppList = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/EnterpriseApplicationListBladeV3/fromNav/globalSecureAccess/applicationType/GlobalSecureAccessApplication'
    $portalLinkAppTemplate = 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/overview/appId/{0}'

    #region Helper Functions

    function Test-HasCustomSecurityAttributes {
        <#
        .SYNOPSIS
            Checks if customSecurityAttributes is non-null and non-empty.
        .DESCRIPTION
            Graph API returns an empty object {} when CSAs are removed, which
            evaluates as $true in PowerShell. This function properly checks
            whether actual CSA values are present.
        .OUTPUTS
            System.Boolean - True if CSAs are assigned, false otherwise.
        #>

        param($Csa)
        if ($null -eq $Csa) { return $false }
        # Handle empty string
        if ($Csa -is [string]) {
            if ([string]::IsNullOrWhiteSpace($Csa)) { return $false }
            # Check for empty JSON object
            if ($Csa.Trim() -eq '{}') { return $false }
            # Non-empty string (likely JSON with data)
            return $true
        }
        # Handle hashtable
        if ($Csa -is [hashtable]) { return $Csa.Count -gt 0 }
        # Handle PSCustomObject or other objects - check for properties
        $props = @($Csa.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' })
        return $props.Count -gt 0
    }

    function Test-IsBroadCidr {
        <#
        .SYNOPSIS
            Checks if a CIDR range is overly permissive (broader than /24).
        .DESCRIPTION
            CIDR ranges with prefix length < 24 are treated as overly permissive.
            This includes /23 (512 IPs), /22 (1,024 IPs), and any broader ranges.
        .OUTPUTS
            System.Boolean
            True - CIDR prefix length < 24
            False - CIDR prefix length >= 24 or invalid format
        #>

        param([string]$Cidr)
        if ($Cidr -match '/(\d+)$') { return ([int]$matches[1] -lt 24) }
        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 = $null
    if ($Database) {
        Write-PSFMessage 'Querying database for Private Access applications' -Tag Test -Level VeryVerbose
        try {
            $sql = @"
SELECT id, appId, displayName, tags
FROM Application
WHERE list_contains(tags, 'PrivateAccessNonWebApplication')
"@

            $apps = @(Invoke-DatabaseQuery -Database $Database -Sql $sql -AsCustomObject)
            Write-PSFMessage "Found $($apps.Count) Private Access application(s) from database" -Tag Test -Level VeryVerbose
        }
        catch {
            Write-PSFMessage "Database query failed: $_" -Tag Test -Level Warning
            $apps = $null
        }
    }

    # Query Q2: Retrieve service principals with Custom Security Attributes
    $servicePrincipals = @()
    if ($Database) {
        Write-PSFMessage 'Querying database for service principals' -Tag Test -Level VeryVerbose
        try {
            $sql = @"
SELECT id, appId, displayName, customSecurityAttributes
FROM ServicePrincipal
WHERE list_contains(tags, 'PrivateAccessNonWebApplication')
"@

            $servicePrincipals = @(Invoke-DatabaseQuery -Database $Database -Sql $sql -AsCustomObject)
            Write-PSFMessage "Found $($servicePrincipals.Count) service principal(s) from database" -Tag Test -Level VeryVerbose
        }
        catch {
            Write-PSFMessage "Database query for service principals failed: $_" -Tag Test -Level Warning
            $servicePrincipals = @()
        }
    }

    # 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
            try {
                $segments = Invoke-ZtGraphRequest -RelativeUri "applications/$($app.id)/onPremisesPublishing/segmentsConfiguration/microsoft.graph.ipSegmentConfiguration/applicationSegments" -ApiVersion beta -ErrorAction Stop
            }
            catch {
                Write-PSFMessage -Level Warning -Message "Failed to retrieve segments for app $($app.displayName): $_"
                $segments = $null
            }

            $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 }
            $hasCSA = Test-HasCustomSecurityAttributes $sp.customSecurityAttributes
            if (-not $hasCSA) {
                $appsWithoutCSA += $app
            }

            # Determine per-app status including Manual Review when filterPolicies exist
            $appStatus = if ($hasBroadSegment -or $hasWildcardDns -or $hasBroadPorts) {
                'Fail – Broad segment'
            } elseif (-not $hasCSA) {
                'Investigate – Missing CSA'
            } 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       = $hasCSA
                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%"

        }

    }
    elseif ($broadAccessApps.Count -gt 0) {

        $passed = $false
        $testResultMarkdown =
            "❌ One or more Private Access applications have overly broad network segments, potentially allowing excessive network access.`n`n%TestResult%"

    }
    else {

        # broadAccessApps = 0 but appsWithoutCSA > 0 → Investigate
        $customStatus = 'Investigate'
        $testResultMarkdown =
            "⚠️ Private Access applications are missing Custom Security Attributes. Consider adding Custom Security Attributes to enable attribute-based Conditional Access targeting.`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]({0})
 
| App name | Segment type | Segment scope | Has CSAs | Status |
|---|---|---|---|---|
{1}
 
'@

        foreach ($r in $appResults) {
            $appLink = $portalLinkAppTemplate -f $r.AppId
            $linkedAppName = "[{0}]({1})" -f (Get-SafeMarkdown $r.AppName), $appLink
            $hasCSAText = if ($r.HasCSA) {'Yes'} else {'No'}
            $segmentTypeSafe = Get-SafeMarkdown $r.SegmentType
            $segmentScopeSafe = Get-SafeMarkdown $r.SegmentScope
            $tableRows += "| $linkedAppName | $segmentTypeSafe | $segmentScopeSafe | $hasCSAText | $($r.Status) |`n"
        }
        $mdInfo += $formatTemplate -f $portalLinkAppList, $tableRows
    }


    if ($segmentFindings.Count -gt 0) {
        $tableRows = ""
        $formatTemplate = @'
## Segment findings
 
| App name | Issue | Destination | Ports | Recommendation |
|---|---|---|---|---|
{0}
 
'@

        foreach ($f in $segmentFindings) {
            $appLink = $portalLinkAppTemplate -f $f.AppId
            $linkedAppName = "[{0}]({1})" -f (Get-SafeMarkdown $f.AppName), $appLink
            $issueSafe = Get-SafeMarkdown $f.Issue
            $destSafe = Get-SafeMarkdown $f.Destination
            $portsSafe = Get-SafeMarkdown $f.Ports
            $tableRows += "| $linkedAppName | $issueSafe | $destSafe | $portsSafe | 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  = 'Entra Private Access Application segments are defined to 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
}