tests/Test-Assessment.21884.ps1

<#
.SYNOPSIS
    Tests if workload identities are protected by location-based Conditional Access policies.
#>


function Test-Assessment-21884 {
    [ZtTest(
        Category = 'External collaboration',
        ImplementationCost = 'Medium',
        Pillar = 'Identity',
        RiskLevel = 'High',
        SfiPillar = 'Protect tenants and production systems',
        TenantType = ('Workforce','External'),
        TestId = 21884,
        Title = 'Conditional Access policies for workload identities based on known networks are configured',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param(
        $Database
    )

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity = 'Checking if workload identities are protected by location-based Conditional Access policies'
    Write-ZtProgress -Activity $activity -Status 'Getting service principals'

    # Get current tenant ID from context
    $tenantId = (Get-MgContext).TenantId

    # Q1: Get all service principals with credentials from database (filter in SQL)
    $sqlServicePrincipals = @"
    SELECT
        id,
        appId,
        displayName,
        servicePrincipalType,
        passwordCredentials,
        keyCredentials,
        appOwnerOrganizationId
    FROM ServicePrincipal
    WHERE servicePrincipalType = 'Application'
        AND cast(appOwnerOrganizationId as varchar) = '$tenantId'
        AND ((passwordCredentials IS NOT NULL AND passwordCredentials <> '[]')
             OR (keyCredentials IS NOT NULL AND keyCredentials <> '[]')
             OR appId IN (
                SELECT appId FROM Application
                WHERE signInAudience = 'AzureADMyOrg'
                  AND ((passwordCredentials IS NOT NULL AND passwordCredentials <> '[]')
                       OR (keyCredentials IS NOT NULL AND keyCredentials <> '[]'))
             )
        )
    LIMIT 1001
"@


    $servicePrincipalsWithCreds = Invoke-DatabaseQuery -Database $Database -Sql $sqlServicePrincipals

    if ($servicePrincipalsWithCreds.Count -eq 0) {
        $testResultMarkdown = 'No workload identities with credentials found to evaluate. All are compliant.'
        $params = @{
            TestId = '21884'
            Status = $true
            Result = $testResultMarkdown
        }
        Add-ZtTestResultDetail @params
        return
    }

    $spLimit = 1000
    $spTruncated = $false
    if ($servicePrincipalsWithCreds.Count -gt $spLimit) {
        $servicePrincipalsWithCreds = $servicePrincipalsWithCreds[0..($spLimit-1)]
        $spTruncated = $true
    }

    # Q4: Get all CA policies targeting workload identities from Graph API (fetch once)
    $policies = Invoke-ZtGraphRequest -RelativeUri 'identity/conditionalAccess/policies' -ApiVersion 'beta'

    # Check for a global policy that covers all service principals
    $allSpPolicy = $policies | Where-Object {
        $_.state -eq 'enabled' -and
        $_.conditions.clientApplications.includeServicePrincipals -contains 'ServicePrincipalsInMyTenant' -and
        (-not $_.conditions.clientApplications.excludeServicePrincipals)
    }

    if ($allSpPolicy) {
        # Verify location conditions in the global policy (no extra API call needed)
        $hasValidLocations = $false
        foreach ($policy in $allSpPolicy) {
            if ($policy.conditions.locations.includeLocations -or $policy.conditions.locations.excludeLocations) {
                $hasValidLocations = $true
                break
            }
        }

        if ($hasValidLocations) {
            $testResultMarkdown = 'All workload identities are protected by global service principal policies with location restrictions.'
            $params = @{
                TestId = '21884'
                Status = [bool]$true
                Result = $testResultMarkdown
            }
            Add-ZtTestResultDetail @params
            return
        }
    }

    if ($servicePrincipalsWithCreds.Count -gt 0 -and $policies.Count -eq 0) {
        $unprotectedSPs = @()
        foreach ($sp in $servicePrincipalsWithCreds) {
            $credTypes = @()
            if (($sp.passwordCredentials -ne '[]') -and ($null -ne $sp.passwordCredentials)) { $credTypes += 'Password' }
            if (($sp.keyCredentials -ne '[]') -and ($null -ne $sp.keyCredentials)) { $credTypes += 'Certificate' }
            $spPortalLink = "[$($sp.displayName)](https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($sp.id)/appId/$($sp.appId))"
            $unprotectedSPs += @{ PortalLink = $spPortalLink; CredentialTypes = $credTypes -join ', '; AppliedPolicies = 'None'; LocationRestrictions = 'None' }
        }
        $passed = $false
        $testResultMarkdown = "Found workload identities with credentials that lack network-based access restrictions."
        $testResultMarkdown += "`n`n| Service principal display name | Credential type | Applied policy names | Location restrictions |"
        $testResultMarkdown += "`n|-------------------------------|-----------------|---------------------|---------------------|"
        foreach ($sp in $unprotectedSPs) {
            $testResultMarkdown += "`n| $($sp.PortalLink) | $($sp.CredentialTypes) | $($sp.AppliedPolicies) | $($sp.LocationRestrictions) |"
        }
        if ($spTruncated) {
            $testResultMarkdown += "`n\n_Note: Only the first 1000 service principals are shown._"
        }
        $params = @{
            TestId = '21884'
            Status = $passed
            Result = $testResultMarkdown
        }
        Add-ZtTestResultDetail @params
        return
    }

        # Q6: Get named locations from Graph API (not stored in database)
        $namedLocations = Invoke-ZtGraphRequest -RelativeUri 'identity/conditionalAccess/namedLocations' -ApiVersion 'beta'

        if ($namedLocations.Count -eq 0) {
            $testResultMarkdown = 'No named locations found. Cannot implement network-based restrictions without defined locations.'

            $params = @{
                TestId = '21884'
                Status = [bool]$false
                Result = $testResultMarkdown
            }
            Add-ZtTestResultDetail @params
            return
        }

    $unprotectedSPs = @()
    $protectedSPs = @()

    foreach ($sp in $servicePrincipalsWithCreds) {
        $credTypes = @()
        if (($sp.passwordCredentials -ne '[]') -and ($null -ne $sp.passwordCredentials)) { $credTypes += 'Password' }
        if (($sp.keyCredentials -ne '[]') -and ($null -ne $sp.keyCredentials)) { $credTypes += 'Certificate' }

        $appliedPolicies = @()
        $locationRestrictions = @()
        $isProtected = $false

        # Check each policy for this SP (all policy data is local)
        foreach ($policy in $policies) {
            if ($policy.state -eq 'enabled') {
                $policyApplies = $false
                $hasLocationRestriction = $false
                $locationDetails = ""

                # Check if policy applies to this service principal
                if ($policy.conditions.clientApplications.includeServicePrincipals -contains 'ServicePrincipalsInMyTenant' -and
                    (-not $policy.conditions.clientApplications.excludeServicePrincipals)) {
                    $policyApplies = $true
                    $appliedPolicies += "$($policy.displayName) (Global - covers ServicePrincipalsInMyTenant)"
                } elseif ($policy.conditions.clientApplications.includeServicePrincipals -contains $sp.id) {
                    $policyApplies = $true
                    $appliedPolicies += $policy.displayName
                }

                # Check location conditions if policy applies
                if ($policyApplies) {
                    if ($policy.conditions.locations.includeLocations -or $policy.conditions.locations.excludeLocations) {
                        $hasLocationRestriction = $true

                        # Build location details
                        $locationParts = @()
                        if ($policy.conditions.locations.includeLocations) {
                            $includeLocations = $policy.conditions.locations.includeLocations
                            if ($includeLocations -contains 'All') {
                                $locationParts += 'Include: All Locations'
                            } else {
                                $locationNames = @()
                                foreach ($locId in $includeLocations) {
                                    $location = $namedLocations | Where-Object { $_.id -eq $locId }
                                    if ($location) {
                                        $locationNames += $location.displayName
                                    } else {
                                        $locationNames += $locId
                                    }
                                }
                                $locationParts += "Include: $($locationNames -join ', ')"
                            }
                        }
                        if ($policy.conditions.locations.excludeLocations) {
                            $excludeLocations = $policy.conditions.locations.excludeLocations
                            if ($excludeLocations -contains 'All') {
                                $locationParts += 'Exclude: All Locations'
                            } else {
                                $locationNames = @()
                                foreach ($locId in $excludeLocations) {
                                    $location = $namedLocations | Where-Object { $_.id -eq $locId }
                                    if ($location) {
                                        $locationNames += $location.displayName
                                    } else {
                                        $locationNames += $locId
                                    }
                                }
                                $locationParts += "Exclude: $($locationNames -join ', ')"
                            }
                        }
                        $locationDetails = $locationParts -join '; '
                        $locationRestrictions += $locationDetails
                    }

                    if ($hasLocationRestriction) {
                        $isProtected = $true
                    }
                }
            }
        }

        # Build SP information object
        $spInfo = @{
            DisplayName = $sp.displayName
            AppId = $sp.appId
            PortalLink = "[$($sp.displayName)](https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($sp.id)/appId/$($sp.appId))"
            CredentialTypes = $credTypes -join ', '
            AppliedPolicies = if ($appliedPolicies.Count -gt 0) { $appliedPolicies -join '; ' } else { 'None' }
            LocationRestrictions = if ($locationRestrictions.Count -gt 0) { $locationRestrictions -join '; ' } else { 'None' }
            IsProtected = $isProtected
        }

        if ($isProtected) {
            $protectedSPs += $spInfo
        } else {
            $unprotectedSPs += $spInfo
        }
    }

    $result = $unprotectedSPs.Count -eq 0
    $passed = [bool]$result
    if ($passed) {
        $testResultMarkdown = "All workload identities with credentials are protected by location-based Conditional Access policies."
        if ($protectedSPs.Count -gt 0) {
            $testResultMarkdown += "`n`n## Protected service principals"
            $testResultMarkdown += "`n| Service principal display name | Credential type | Applied policy names | Location restrictions |"
            $testResultMarkdown += "`n|-------------------------------|-----------------|---------------------|---------------------|"
            foreach ($sp in $protectedSPs) {
                $testResultMarkdown += "`n| $($sp.PortalLink) | $($sp.CredentialTypes) | $($sp.AppliedPolicies) | $($sp.LocationRestrictions) |"
            }
            if ($spTruncated) {
                $testResultMarkdown += "`n\n_Note: Only the first 1000 service principals are shown._"
            }
        }
    } else {
        $testResultMarkdown = "Found workload identities with credentials that lack network-based access restrictions."
        if ($unprotectedSPs.Count -gt 0) {
            $testResultMarkdown += "`n`n## Unprotected service principals"
            $testResultMarkdown += "`n| Service principal display name | Credential type | Applied policy names | Location restrictions |"
            $testResultMarkdown += "`n|-------------------------------|-----------------|---------------------|---------------------|"
            foreach ($sp in $unprotectedSPs) {
                $testResultMarkdown += "`n| $($sp.PortalLink) | $($sp.CredentialTypes) | $($sp.AppliedPolicies) | $($sp.LocationRestrictions) |"
            }
            if ($spTruncated) {
                $testResultMarkdown += "`n\n_Note: Only the first 1000 service principals are shown._"
            }
        }
        if ($protectedSPs.Count -gt 0) {
            $testResultMarkdown += "`n`n## Protected service principals (for reference)"
            $testResultMarkdown += "`n| Service principal display name | Credential type | Applied policy names | Location restrictions |"
            $testResultMarkdown += "`n|-------------------------------|-----------------|---------------------|---------------------|"
            foreach ($sp in $protectedSPs) {
                $testResultMarkdown += "`n| $($sp.PortalLink) | $($sp.CredentialTypes) | $($sp.AppliedPolicies) | $($sp.LocationRestrictions) |"
            }
            if ($spTruncated) {
                $testResultMarkdown += "`n\n_Note: Only the first 1000 service principals are shown._"
            }
        }
    }
    $params = @{
        TestId = '21884'
        Status = $passed
        Result = $testResultMarkdown
    }
    Add-ZtTestResultDetail @params
}