-
Notifications
You must be signed in to change notification settings - Fork 122
Network-25396: Conditional Access policies enforce strong authentication for private apps #744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
7c5b06c
9afd381
9a81ac6
3ffa3e8
21fd120
943356d
c7ada61
9ae3fe2
d0c9deb
1a4dc87
4136445
5ef46ea
a3d65b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| When Private Access applications are not protected by Conditional Access policies requiring strong authentication, organizations undermine the security benefits of their Zero Trust Network Access implementation. | ||
|
|
||
| Threat actors who obtain user credentials through phishing attacks, credential stuffing, or password spraying can authenticate to private applications using only a compromised password, gaining initial access to internal resources that should be protected by stronger controls. Once authenticated, threat actors can establish persistence by accessing sensitive internal systems, downloading data, or creating additional access mechanisms. The absence of multifactor authentication—or worse, the use of phishable MFA methods such as SMS or voice—enables adversary-in-the-middle attacks where threat actors intercept authentication tokens and session cookies, facilitating credential access to additional systems. | ||
|
|
||
| Threat actors can then perform lateral movement by pivoting from the initially compromised private application to other internal resources accessible through the Private Access infrastructure. | ||
|
|
||
| Microsoft recommends enforcing phishing-resistant authentication methods such as FIDO2 security keys, Windows Hello for Business, or certificate-based authentication for access to private applications, with multifactor authentication as the minimum acceptable baseline. The authentication strength feature in Conditional Access allows organizations to require specific combinations of authentication methods, enabling granular enforcement aligned with the Microsoft passwordless strategy. | ||
|
|
||
| **Remediation action** | ||
|
|
||
| - [Apply Conditional Access policies to Private Access applications requiring MFA or authentication strength from within Global Secure Access](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-target-resource-private-access-apps) | ||
|
|
||
| - [Configure authentication strength policies to require phishing-resistant methods for high-value private applications](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-strengths) | ||
|
|
||
| - [Deploy phishing-resistant authentication methods including FIDO2 security keys, Windows Hello for Business, or certificate-based authentication](https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-plan-prerequisites-phishing-resistant-passwordless-authentication) | ||
| <!--- Results ---> | ||
| %TestResult% | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,339 @@ | ||
| <# | ||
| .SYNOPSIS | ||
| Validates that Conditional Access policies enforce strong authentication for Private Access applications. | ||
|
|
||
| .DESCRIPTION | ||
| This test checks if all Private Access applications (Per-app and Quick Access) are protected | ||
| by Conditional Access policies requiring strong authentication (MFA or authentication strength). | ||
|
|
||
| .NOTES | ||
| Test ID: 25396 | ||
| Category: Global Secure Access | ||
| Required API: applications, servicePrincipals, identity/conditionalAccess/policies, authenticationStrength/policies | ||
| #> | ||
|
|
||
| function Test-Assessment-25396 { | ||
| [ZtTest( | ||
| Category = 'Global Secure Access', | ||
| ImplementationCost = 'Medium', | ||
| MinimumLicense = ('Entra_Premium_Private_Access', 'AAD_PREMIUM'), | ||
| Pillar = 'Network', | ||
| RiskLevel = 'High', | ||
| SfiPillar = 'Protect identities and secrets', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @praneeth-0000
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @alexandair , The spec has 'Protect identities and secrets'. Ideally it should be related to network. Let me check with Thomas on this.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @praneeth-0000 Maybe you don't have the latest version of the spec. I see "Protect networks" in the spec in
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alexandair apologies, yes. There was some confusion between docx & md file. I've updated it now.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @alexandair , Thomas has confirmed that the SDI pillar should be "Protect identities and secrets" instead of "Protect networks". I've raised a PR in ztspecs to get the md file updated. |
||
| TenantType = ('Workforce'), | ||
| TestId = 25396, | ||
| Title = 'Conditional Access policies enforce strong authentication for private apps', | ||
| UserImpact = 'Medium' | ||
| )] | ||
| [CmdletBinding()] | ||
| param() | ||
|
|
||
| #region Data Collection | ||
| Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose | ||
|
|
||
| $activity = 'Checking Private Access authentication controls' | ||
| Write-ZtProgress -Activity $activity -Status 'Getting Private Access applications' | ||
|
|
||
| # Query Q1: Get all Private Access service principals with tags and CSAs | ||
| $privateAccessApps = Invoke-ZtGraphRequest -RelativeUri 'servicePrincipals' -Filter "(tags/any(t:t eq 'PrivateAccessNonWebApplication') or tags/any(t:t eq 'NetworkAccessQuickAccessApplication'))" -Select 'id,displayName,appId,tags,customSecurityAttributes' -ApiVersion v1.0 -ConsistencyLevel eventual | ||
|
|
||
| # Initialize test variables | ||
| $testResultMarkdown = '' | ||
| $passed = $false | ||
| $allAppDetails = @() | ||
| $totalApps = 0 | ||
| $phishingResistantApps = 0 | ||
| $passwordlessMfaApps = 0 | ||
| $mfaApps = 0 | ||
| $unprotectedApps = 0 | ||
| $manualReviewApps = 0 | ||
| $appsWithoutCSA = 0 | ||
| $filterPoliciesCount = 0 | ||
|
|
||
| # Built-in authentication strength IDs | ||
| $builtInAuthStrengthIds = @{ | ||
| 'MFA' = '00000000-0000-0000-0000-000000000002' | ||
| 'PasswordlessMFA' = '00000000-0000-0000-0000-000000000003' | ||
| 'PhishingResistant' = '00000000-0000-0000-0000-000000000004' | ||
| } | ||
|
|
||
| # Authentication level priority for comparison | ||
| $authLevelPriority = @{ | ||
| 'PhishingResistant' = 4 | ||
| 'PasswordlessMFA' = 3 | ||
| 'MFA' = 2 | ||
| 'None' = 1 | ||
| } | ||
|
|
||
| # Status sort order for reporting | ||
| $statusSortOrder = @{ | ||
| 'Protected' = 3 | ||
| 'Manual Review' = 2 | ||
| 'Unprotected' = 1 | ||
| } | ||
|
|
||
| # Phishing-resistant methods | ||
| $phishingResistantMethods = @('windowsHelloForBusiness', 'fido2', 'x509CertificateMultiFactor') | ||
| #endregion Data Collection | ||
|
|
||
| #region Assessment Logic | ||
| if (-not $privateAccessApps -or $privateAccessApps.Count -eq 0) { | ||
| $passed = $false | ||
| $testResultMarkdown = @" | ||
| ⚠️ No Private Access applications are configured. | ||
|
|
||
| ## Portal Links | ||
|
|
||
| - [Global Secure Access > Applications > Enterprise applications](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/EnterpriseApplicationListBladeV3/fromNav/globalSecureAccess/applicationType/GlobalSecureAccessApplication) | ||
| - [Conditional Access > Policies](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Overview/menuId//fromNav/Identity) | ||
| - [Authentication methods > Authentication strengths](https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/AuthStrengths) | ||
| "@ | ||
| } | ||
| else { | ||
| $totalApps = $privateAccessApps.Count | ||
|
|
||
| Write-ZtProgress -Activity $activity -Status 'Getting Conditional Access policies' | ||
|
|
||
| # Query Q2: Get all enabled CA policies | ||
| $caPolicies = Get-ZtConditionalAccessPolicy | Where-Object { $_.state -eq 'enabled' } | ||
|
|
||
| # Count policies with applicationFilter | ||
| $filterPolicies = $caPolicies | Where-Object { $_.conditions.applications.applicationFilter } | ||
| $filterPoliciesCount = if ($filterPolicies) { $filterPolicies.Count } else { 0 } | ||
|
|
||
| # Cache for authentication strength policies | ||
| $authStrengthCache = @{} | ||
|
|
||
| Write-ZtProgress -Activity $activity -Status 'Evaluating authentication controls for each app' | ||
|
|
||
| foreach ($app in $privateAccessApps) { | ||
| $appId = $app.appId | ||
| $displayName = $app.displayName | ||
|
|
||
| # Determine app type | ||
| $appType = if ($app.tags -contains 'NetworkAccessQuickAccessApplication') { 'Quick Access' } else { 'Per-App' } | ||
|
|
||
| # Check CSA presence | ||
| $hasCSA = $null -ne $app.customSecurityAttributes -and ($app.customSecurityAttributes.PSObject.Properties.Count -gt 0) | ||
|
|
||
| # Find CA policies targeting this app | ||
| $targetingPolicies = @() | ||
| foreach ($policy in $caPolicies) { | ||
| $includeApps = $policy.conditions.applications.includeApplications | ||
| $excludeApps = $policy.conditions.applications.excludeApplications | ||
|
|
||
| # Check if explicitly excluded | ||
| if ($excludeApps -contains $appId) { | ||
| continue | ||
| } | ||
|
|
||
| # Check direct targeting or "All" | ||
| if (($includeApps -contains $appId) -or ($includeApps -contains 'All')) { | ||
| $targetingPolicies += $policy | ||
| } | ||
| } | ||
|
|
||
| # Determine authentication strength level | ||
| $authLevel = 'None' | ||
| $authStrengthName = 'N/A' | ||
| $allPolicyDetails = @() | ||
| $status = 'Unprotected' | ||
|
|
||
| if ($targetingPolicies.Count -gt 0) { | ||
| # Evaluate all targeting policies and collect details | ||
| foreach ($policy in $targetingPolicies) { | ||
| $currentLevel = 'None' | ||
| $currentStrengthName = 'N/A' | ||
|
|
||
| # Check for authentication strength (Q3) | ||
| if ($policy.grantControls.authenticationStrength -and $policy.grantControls.authenticationStrength.id) { | ||
| $authStrengthId = $policy.grantControls.authenticationStrength.id | ||
|
|
||
| # Retrieve auth strength policy details if not cached | ||
| if (-not $authStrengthCache.ContainsKey($authStrengthId)) { | ||
| $authStrengthUri = "identity/conditionalAccess/authenticationStrength/policies/$authStrengthId" | ||
| $authStrengthPolicy = Invoke-ZtGraphRequest -RelativeUri $authStrengthUri -ApiVersion v1.0 | ||
| $authStrengthCache[$authStrengthId] = $authStrengthPolicy | ||
| } | ||
|
|
||
| $authStrengthPolicy = $authStrengthCache[$authStrengthId] | ||
| $currentStrengthName = $authStrengthPolicy.displayName | ||
|
|
||
| # Classify authentication strength | ||
| if ($authStrengthPolicy.policyType -eq 'builtIn') { | ||
| if ($authStrengthId -eq $builtInAuthStrengthIds['PhishingResistant']) { | ||
| $currentLevel = 'PhishingResistant' | ||
| } | ||
| elseif ($authStrengthId -eq $builtInAuthStrengthIds['PasswordlessMFA']) { | ||
| $currentLevel = 'PasswordlessMFA' | ||
| } | ||
| elseif ($authStrengthId -eq $builtInAuthStrengthIds['MFA']) { | ||
| $currentLevel = 'MFA' | ||
| } | ||
| } | ||
| elseif ($authStrengthPolicy.policyType -eq 'custom') { | ||
| # Check if ALL combinations contain at least one phishing-resistant method | ||
| $allPhishingResistant = $true | ||
praneeth-0000 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| foreach ($combination in $authStrengthPolicy.allowedCombinations) { | ||
| $methods = $combination -split ',' | ||
praneeth-0000 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $combinationIsPhishingResistant = $false | ||
| foreach($method in $methods) | ||
praneeth-0000 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| if ($phishingResistantMethods -contains $method.Trim()) { | ||
| $combinationIsPhishingResistant = $true | ||
| break | ||
| } | ||
| } | ||
| # If this combination doesn't have a phishing-resistant method, fail | ||
| if (-not $combinationIsPhishingResistant) { | ||
| $allPhishingResistant = $false | ||
praneeth-0000 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| break | ||
| } | ||
| } | ||
| $currentLevel = if ($allPhishingResistant) { 'PhishingResistant' } else { 'MFA' } | ||
| } | ||
| } | ||
| # Check for MFA in builtInControls | ||
| elseif ($policy.grantControls.builtInControls -contains 'mfa') { | ||
| $currentLevel = 'MFA' | ||
| $currentStrengthName = 'MFA (built-in)' | ||
| } | ||
|
|
||
| # Collect policy details | ||
| $allPolicyDetails += [PSCustomObject]@{ | ||
| PolicyName = $policy.displayName | ||
| AuthStrength = $currentStrengthName | ||
| Level = $currentLevel | ||
| Priority = $authLevelPriority[$currentLevel] | ||
| } | ||
| } | ||
|
|
||
| # Sort by strongest auth level and get the overall strongest | ||
| $sortedPolicies = $allPolicyDetails | Sort-Object -Property Priority -Descending | ||
| $strongestPolicy = $sortedPolicies | Select-Object -First 1 | ||
| $authLevel = $strongestPolicy.Level | ||
| $authStrengthName = $strongestPolicy.AuthStrength | ||
|
|
||
| # Get all policies with the strongest auth level | ||
| $strongestPolicies = $allPolicyDetails | Where-Object { $_.Level -eq $authLevel } | ||
| $strongestPolicyNames = ($strongestPolicies | ForEach-Object { $_.PolicyName }) -join ', ' | ||
| } | ||
|
|
||
| # Determine status | ||
| if ($authLevel -ne 'None') { | ||
| $status = 'Protected' | ||
|
|
||
| # Update counters | ||
| switch ($authLevel) { | ||
| 'PhishingResistant' { $phishingResistantApps++ } | ||
| 'PasswordlessMFA' { $passwordlessMfaApps++ } | ||
| 'MFA' { $mfaApps++ } | ||
| } | ||
| } | ||
| elseif ($hasCSA -and $filterPoliciesCount -gt 0) { | ||
| $status = 'Manual Review' | ||
| $manualReviewApps++ | ||
| } | ||
| else { | ||
| $status = 'Unprotected' | ||
| $unprotectedApps++ | ||
| } | ||
|
|
||
| if (-not $hasCSA) { | ||
| $appsWithoutCSA++ | ||
| } | ||
|
|
||
| # Add to results | ||
| $allAppDetails += [PSCustomObject]@{ | ||
| AppName = $displayName | ||
| AppId = $appId | ||
| AppType = $appType | ||
| HasCSA = if ($hasCSA) { 'Yes' } else { 'No' } | ||
| CAPolicies = if ($allPolicyDetails.Count -gt 0) { $strongestPolicyNames } else { 'None' } | ||
| AuthStrength = $authStrengthName | ||
| Level = $authLevel | ||
| Status = $status | ||
| StatusSort = $statusSortOrder[$status] | ||
| } | ||
| } | ||
|
|
||
| if ($unprotectedApps -eq 0 -and $manualReviewApps -eq 0) { | ||
| $passed = $true | ||
| $testResultMarkdown = "All Private Access applications are targeted by at least one enabled CA policy that requires authentication strength or MFA.`n`n%TestResult%" | ||
| } | ||
| elseif ($unprotectedApps -eq 0 -and $manualReviewApps -gt 0) { | ||
| # Investigate state | ||
| $passed = $false | ||
| $testResultMarkdown = "Private Access applications have Custom Security Attributes assigned but no direct CA policy coverage. CA policies use applicationFilter targeting. Manual review required to verify if these apps are protected by applicationFilter-based policies.`n`n%TestResult%" | ||
| } | ||
| else { | ||
| # Fail state | ||
| $passed = $false | ||
| $testResultMarkdown = "One or more Private Access applications are not protected by Conditional Access policies requiring strong authentication.`n`n%TestResult%" | ||
| } | ||
| } | ||
| #endregion Assessment Logic | ||
|
|
||
| #region Report Generation | ||
| $mdInfo = '' | ||
|
|
||
| if ($totalApps -gt 0) { | ||
| $portalAppsLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/EnterpriseApplicationListBladeV3/fromNav/globalSecureAccess/applicationType/GlobalSecureAccessApplication' | ||
| $portalCaLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Overview/menuId//fromNav/Identity' | ||
| $portalAuthStrengthLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/AuthStrengths' | ||
|
|
||
| $mdInfo += @" | ||
|
|
||
| ## Summary | ||
|
|
||
| - **Total Private Access Apps:** $totalApps | ||
| - **Apps with Phishing-Resistant MFA:** $phishingResistantApps | ||
| - **Apps with Passwordless MFA:** $passwordlessMfaApps | ||
| - **Apps with MFA (baseline):** $mfaApps | ||
| - **Apps without Strong Auth:** $unprotectedApps | ||
| - **Apps Requiring Manual Review:** $manualReviewApps | ||
| - **Apps without CSAs:** $appsWithoutCSA | ||
| - **CA Policies using applicationFilter:** $filterPoliciesCount | ||
|
|
||
| ## [Application Details](${portalAppsLink}) | ||
|
|
||
| | App name | App id | App type | Has CSAs | CA policy | Auth strength | Level | Status | | ||
| | :------- | :----- | :------- | :------- | :-------- | :------------ | :---- | :----- | | ||
|
|
||
| "@ | ||
|
|
||
| foreach ($app in ($allAppDetails | Sort-Object StatusSort, AppName)) { | ||
| $statusIcon = switch ($app.Status) { | ||
| 'Protected' { '✅' } | ||
| 'Unprotected' { '❌' } | ||
| 'Manual Review' { '⚠️' } | ||
| default { '' } | ||
| } | ||
| $mdInfo += "| $($app.AppName) | $($app.AppId) | $($app.AppType) | $($app.HasCSA) | $($app.CAPolicies) | $($app.AuthStrength) | $($app.Level) | $statusIcon $($app.Status) |`n" | ||
alexandair marked this conversation as resolved.
Show resolved
Hide resolved
praneeth-0000 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| $mdInfo += @" | ||
| ## Portal Links | ||
| - [Conditional Access Policies](${portalCaLink}) | ||
| - [Authentication Strengths](${portalAuthStrengthLink}) | ||
| "@ | ||
| } | ||
|
|
||
| # Replace the placeholder with detailed information | ||
| $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo | ||
| #endregion Report Generation | ||
|
|
||
| $params = @{ | ||
| TestId = '25396' | ||
| Title = 'Conditional Access policies enforce strong authentication for private apps' | ||
| Status = $passed | ||
| Result = $testResultMarkdown | ||
| } | ||
|
|
||
| if ($unprotectedApps -eq 0 -and $manualReviewApps -gt 0) { | ||
| $params.CustomStatus = 'Investigate' | ||
| } | ||
|
|
||
| # Add test result details | ||
| Add-ZtTestResultDetail @params | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.