diff --git a/src/powershell/private/graph/Find-ZtProfilesLinkedToPolicy.ps1 b/src/powershell/private/graph/Find-ZtProfilesLinkedToPolicy.ps1 new file mode 100644 index 0000000000..bf9d995ed0 --- /dev/null +++ b/src/powershell/private/graph/Find-ZtProfilesLinkedToPolicy.ps1 @@ -0,0 +1,195 @@ +function Find-ZtProfilesLinkedToPolicy { + <# + .SYNOPSIS + Finds filtering profiles that are linked to a specific policy and evaluates if they meet pass criteria. + + .DESCRIPTION + This function searches through Global Secure Access filtering profiles to find those linked to a specific policy. + It evaluates whether each linked profile meets the pass criteria based on profile type: + - Baseline Profile (priority = 65000): Passes automatically regardless of link state + - Security Profile (priority < 65000): Passes only if linked to an enabled Conditional Access policy + + .PARAMETER PolicyId + The ID of the filtering policy to search for. + + .PARAMETER FilteringProfiles + Collection of all filtering profiles to search through. + + .PARAMETER CAPolicies + Collection of Conditional Access policies for Security Profile validation. + + .PARAMETER BaselinePriority + The priority value that identifies the Baseline Profile (typically 65000). + + .PARAMETER PolicyLinkType + The type of policy link to search for. Valid values: + - filteringPolicyLink (Web Content Filtering) + - tlsInspectionPolicyLink (TLS Inspection) + - filePolicyLink (File Policy) + - promptPolicyLink (Prompt Policy) + + .PARAMETER PolicyRules + Collection of policy rules associated with the policy (e.g., webCategory rules, TLS inspection rules). + + .EXAMPLE + $findParams = @{ + PolicyId = $policyId + FilteringProfiles = $filteringProfiles + CAPolicies = $caPolicies + BaselinePriority = 65000 + PolicyLinkType = 'filteringPolicyLink' + PolicyRules = $webCategoryRules + } + $linkedProfiles = Find-ZtProfilesLinkedToPolicy @findParams + + .OUTPUTS + Array of PSCustomObject with the following properties: + - ProfileId: The profile ID + - ProfileName: The profile name + - ProfileType: 'Baseline Profile' or 'Security Profile' + - ProfileState: The profile state + - ProfilePriority: The profile priority value + - PolicyLinkState: The state of the policy link (enabled/disabled/unknown) + - PassesCriteria: Boolean indicating if the profile meets pass criteria + - CAPolicy: Linked Conditional Access policies (for Security Profiles only) + - PolicyRules: The policy rules passed in + + .NOTES + This function is used by Global Secure Access assessment tests to evaluate policy enforcement. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PolicyId, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$FilteringProfiles, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$CAPolicies, + + [Parameter(Mandatory)] + [int]$BaselinePriority, + + [Parameter(Mandatory)] + [ValidateSet('filteringPolicyLink', 'tlsInspectionPolicyLink', 'filePolicyLink', 'promptPolicyLink')] + [string]$PolicyLinkType, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$PolicyRules + ) + + # OData type lookup for type safety + $odataTypeMap = @{ + 'filteringPolicyLink' = '#microsoft.graph.networkaccess.filteringPolicyLink' + 'tlsInspectionPolicyLink' = '#microsoft.graph.networkaccess.tlsInspectionPolicyLink' + 'filePolicyLink' = '#microsoft.graph.networkaccess.filePolicyLink' + 'promptPolicyLink' = '#microsoft.graph.networkaccess.promptPolicyLink' + } + + $odataType = $odataTypeMap[$PolicyLinkType] + if (-not $odataType) { + Write-PSFMessage "Unknown PolicyLinkType: $PolicyLinkType" -Tag Test -Level Warning + return @() + } + + $linkedProfiles = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($filteringProfile in $FilteringProfiles) { + # Get profile policies safely + $profilePolicies = @() + if ($null -ne $filteringProfile.policies) { + # Force array to handle both scalar and array returns from Graph API + $profilePolicies = @($filteringProfile.policies) + } + + foreach ($policyLink in $profilePolicies) { + $plinkType = $policyLink.'@odata.type' + $linkedPolicyId = $null + + # Only process the specified policy link type + if ($plinkType -eq $odataType -and $null -ne $policyLink.policy) { + $linkedPolicyId = $policyLink.policy.id + } + + if ($null -ne $linkedPolicyId -and $linkedPolicyId -eq $PolicyId) { + # Determine profile type based on priority + $priority = if ($null -ne $filteringProfile.priority) { + [int]$filteringProfile.priority + } + else { + $null + } + + # Per spec: Only process Baseline Profile (priority = 65000) or Security Profile (priority < 65000) + if ($null -eq $priority) { + Write-PSFMessage "Skipping profile '$($filteringProfile.name)' (ID: $($filteringProfile.id)) - missing priority property" -Tag Test -Level Debug + continue + } + + $linkState = if ($null -ne $policyLink.state) { + $policyLink.state + } + else { + 'unknown' + } + + if ($priority -eq $BaselinePriority) { + # Baseline Profile: passes regardless of enabled state per spec + $profileInfo = [PSCustomObject]@{ + ProfileId = $filteringProfile.id + ProfileName = $filteringProfile.name + ProfileType = 'Baseline Profile' + ProfileState = $filteringProfile.state + ProfilePriority = $priority + PolicyLinkState = $linkState + PassesCriteria = $true + CAPolicy = $null + PolicyRules = $PolicyRules + } + $linkedProfiles.Add($profileInfo) | Out-Null + } + elseif ($priority -lt $BaselinePriority) { + # Security Profile: check if linked to enabled CA policy + $linkedCAPolicies = $CAPolicies | Where-Object { + # Use null-conditional operator for safe navigation + $_.sessionControls?.globalSecureAccessFilteringProfile?.profileId -eq $filteringProfile.id -and + $_.sessionControls?.globalSecureAccessFilteringProfile?.isEnabled -eq $true + } + + $profileInfo = [PSCustomObject]@{ + ProfileId = $filteringProfile.id + ProfileName = $filteringProfile.name + ProfileType = 'Security Profile' + ProfileState = $filteringProfile.state + ProfilePriority = $priority + PolicyLinkState = $linkState + PassesCriteria = $false + CAPolicy = $null + PolicyRules = $PolicyRules + } + + if ($linkedCAPolicies) { + # Check if at least one CA policy is enabled + $enabledCAPolicies = $linkedCAPolicies | Where-Object { $_.state -eq 'enabled' } + if ($enabledCAPolicies) { + $profileInfo.PassesCriteria = $true + } + $profileInfo.CAPolicy = $linkedCAPolicies + } + + $linkedProfiles.Add($profileInfo) | Out-Null + } + else { + # Priority > BaselinePriority + Write-PSFMessage "Skipping profile '$($filteringProfile.name)' (ID: $($filteringProfile.id)) - unexpected priority value: $priority (expected <= $BaselinePriority)" -Tag Test -Level Debug + } + } + } + } + + return $linkedProfiles +} diff --git a/src/powershell/public/Connect-ZtAssessment.ps1 b/src/powershell/public/Connect-ZtAssessment.ps1 index 9d66eeb906..3b23b6caa9 100644 --- a/src/powershell/public/Connect-ZtAssessment.ps1 +++ b/src/powershell/public/Connect-ZtAssessment.ps1 @@ -83,7 +83,7 @@ $SkipAzureConnection, # The services to connect to such as Azure and ExchangeOnline. Default is Graph. - [ValidateSet('All', 'Azure', 'ExchangeOnline', 'Graph', 'SecurityCompliance', 'SharePointOnline')] + [ValidateSet('All', 'Azure', 'AipService', 'ExchangeOnline', 'Graph', 'SecurityCompliance', 'SharePointOnline')] [string[]]$Service = 'Graph', # The Exchange environment to connect to. Default is O365Default. Supported values include O365China, O365Default, O365GermanyCloud, O365USGovDoD, O365USGovGCCHigh. @@ -117,7 +117,7 @@ } - $OrderedImport = Get-ModuleImportOrder -Name @('Az.Accounts', 'ExchangeOnlineManagement', 'Microsoft.Graph.Authentication', 'Microsoft.Online.SharePoint.PowerShell') + $OrderedImport = Get-ModuleImportOrder -Name @('Az.Accounts', 'ExchangeOnlineManagement', 'Microsoft.Graph.Authentication', 'Microsoft.Online.SharePoint.PowerShell', 'AipService') Write-Verbose "Import Order: $($OrderedImport.Name -join ', ')" @@ -348,6 +348,32 @@ } } } + + 'AipService' { + if ($Service -contains 'AipService' -or $Service -contains 'All') { + try { + # Import module with compatibility if needed + if ($PSVersionTable.PSEdition -ne 'Desktop') { + # Assume module is installed in Windows PowerShell as per instructions + Import-Module AipService -UseWindowsPowerShell -WarningAction SilentlyContinue -ErrorAction Stop -Global + } + else { + Import-Module AipService -ErrorAction Stop -Global + } + } + catch { + # Provide clearer guidance when import fails, especially under PowerShell Core + if ($PSVersionTable.PSEdition -ne 'Desktop') { + $message = "Failed to import AipService module. When running in PowerShell Core, 'AipService' must be installed in Windows PowerShell 5.1 (Desktop) for -UseWindowsPowerShell to work. Underlying error: $_" + } + else { + $message = "Failed to import AipService module: $_" + } + Write-Host "`n$message" -ForegroundColor Red + Write-PSFMessage $message -Level Error + } + } + } } if ($Service -contains 'SharePointOnline' -or $Service -contains 'All') { @@ -388,4 +414,18 @@ } } } + + if ($Service -contains 'AipService' -or $Service -contains 'All') { + Write-Host "`nConnecting to Azure Information Protection" -ForegroundColor Yellow + Write-PSFMessage 'Connecting to Azure Information Protection' + + try { + Connect-AipService -ErrorAction Stop + Write-Verbose "Successfully connected to Azure Information Protection." + } + catch { + Write-Host "`nFailed to connect to Azure Information Protection: $_" -ForegroundColor Red + Write-PSFMessage "Failed to connect to Azure Information Protection: $_" -Level Error + } + } } diff --git a/src/powershell/tests/Test-Assessment.25395.md b/src/powershell/tests/Test-Assessment.25395.md new file mode 100644 index 0000000000..95f150ef0e --- /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 0000000000..31ca66f745 --- /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 +} diff --git a/src/powershell/tests/Test-Assessment.25408.md b/src/powershell/tests/Test-Assessment.25408.md new file mode 100644 index 0000000000..7be60088e0 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25408.md @@ -0,0 +1,10 @@ +Web Content Filtering in Microsoft Entra Internet Access helps organizations control access to websites based on web categories, domains or URL, reducing exposure to malicious or inappropriate content. When traffic is routed through Microsoft Entra Internet Access, filtering policies can block or allow entire categories like Gambling or Social Media, or specific domains/URL, ensuring safer browsing across all devices and locations. + +Configuring these policies is critical for security and compliance. It prevents phishing and malware risks, enforces corporate standards, and improves productivity by restricting non-business sites. Combined with identity-aware Conditional Access, Web Content Filtering delivers dynamic, cloud-based protection that aligns with modern Zero Trust principles. + +**Remediation action** + +- [How to configure Global Secure Access web content filtering](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-web-content-filtering) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25408.ps1 b/src/powershell/tests/Test-Assessment.25408.ps1 new file mode 100644 index 0000000000..d2e2689895 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25408.ps1 @@ -0,0 +1,233 @@ +<# +.SYNOPSIS + Checks that Global Secure Access web content filtering is enabled and configured +.DESCRIPTION + Verifies that Web Content Filtering policies are configured and applied either through the Baseline Profile + or through Security Profiles linked to active Conditional Access policies. This ensures that organizations + control access to websites based on categories, domains, or URLs to reduce exposure to malicious or + inappropriate content. + +.NOTES + Test ID: 25408 + Category: Global Secure Access + Required API: networkAccess/filteringPolicies, networkAccess/filteringProfiles, conditionalAccess/policies +#> + +function Test-Assessment-25408 { + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'Medium', + MinimumLicense = ('Entra_Premium_Internet_Access'), + Pillar = 'Network', + RiskLevel = 'Medium', + SfiPillar = 'Protect networks', + TenantType = ('Workforce','External'), + TestId = '25408', + Title = 'Global Secure Access web content filtering is enabled and configured', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Checking Global Secure Access web content filtering configuration' + Write-ZtProgress -Activity $activity -Status 'Querying Web Content Filtering policies' + + # Q1: Get all Web Content Filtering policies (excluding "All Websites") + $allFilteringPolicies = Invoke-ZtGraphRequest -RelativeUri 'networkAccess/filteringPolicies' -ApiVersion beta + $wcfPolicies = $allFilteringPolicies | Where-Object { $_.name -ne 'All websites' } + + Write-ZtProgress -Activity $activity -Status 'Querying filtering profiles' + + # Q2: Get all filtering profiles with their policies and priority + $filteringProfilesQueryParams = @{ + '$select' = 'id,name,description,state,version,priority' + '$expand' = 'policies($select=id,state;$expand=policy($select=id,name,version))' + } + $filteringProfiles = Invoke-ZtGraphRequest -RelativeUri 'networkAccess/filteringProfiles' -QueryParameters $filteringProfilesQueryParams -ApiVersion beta + + Write-ZtProgress -Activity $activity -Status 'Querying Conditional Access policies' + + # Q3 prep: Get all Conditional Access policies with session controls + $caPolicies = Get-ZtConditionalAccessPolicy + #endregion Data Collection + + #region Assessment Logic + # Initialize test variables + $testResultMarkdown = '' + $passed = $false + $appliedPolicies = @() + + # Check if any Web Content Filtering policies exist (excluding "All Websites") + if (-not $wcfPolicies -or $wcfPolicies.Count -eq 0) { + $testResultMarkdown = '❌ Web Content Filtering policy is not configured.' + $passed = $false + } + else { + # Check if WCF policies are linked to profiles + foreach ($wcfPolicy in $wcfPolicies) { + $policyId = $wcfPolicy.id + $policyName = $wcfPolicy.name + $policyAction = $wcfPolicy.action + + # Find profiles that have this policy linked + $linkedProfiles = @() + + foreach ($filteringProfile in $filteringProfiles) { + # Check if this profile contains the WCF policy + $policyLink = $filteringProfile.policies | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.networkaccess.filteringPolicyLink' -and + $_.policy.id -eq $policyId -and + $_.state -eq 'enabled' + } + + if ($policyLink) { + # Determine profile type based on priority + $priority = $filteringProfile.priority + $profileType = if ($priority -eq 65000) { 'Baseline Profile' } elseif ($null -ne $priority -and $priority -lt 65000) { 'Security Profile' } + + $profileInfo = [PSCustomObject]@{ + ProfileId = $filteringProfile.id + ProfileName = $filteringProfile.name + ProfileType = $profileType + ProfileState = $filteringProfile.state + ProfilePriority = $filteringProfile.priority + PolicyLinkState = $policyLink.state + IsApplied = $false + CAPolicy = $null + } + + # If Baseline Profile and enabled, it's automatically applied + if ($profileType -eq 'Baseline Profile' -and $filteringProfile.state -eq 'enabled') { + $profileInfo.IsApplied = $true + } + # If Security Profile, check if it's linked to an active CA policy + elseif ($profileType -eq 'Security Profile' -and $filteringProfile.state -eq 'enabled') { + # Step 4: Check for Conditional Access policy linkage + $linkedCAPolicies = $caPolicies | Where-Object { + $_.state -eq 'enabled' -and + $null -ne $_.sessionControls.globalSecureAccessFilteringProfile -and + $_.sessionControls.globalSecureAccessFilteringProfile.profileId -eq $filteringProfile.id -and + $_.sessionControls.globalSecureAccessFilteringProfile.isEnabled -eq $true + } + + if ($linkedCAPolicies) { + $profileInfo.IsApplied = $true + $profileInfo.CAPolicy = $linkedCAPolicies + } + } + + $linkedProfiles += $profileInfo + } + } + + # If this policy is applied through at least one profile, add it to applied policies + if ($linkedProfiles | Where-Object { $_.IsApplied -eq $true }) { + $appliedPolicies += [PSCustomObject]@{ + PolicyId = $policyId + PolicyName = $policyName + PolicyAction = $policyAction + LinkedProfiles = $linkedProfiles + } + } + } + + # Determine pass/fail + if ($appliedPolicies.Count -gt 0) { + $passed = $true + $testResultMarkdown = "✅ Web Content Filtering policy is enabled. `n`n%TestResult%" + } + else { + $passed = $false + $testResultMarkdown = "❌ Web Content Filtering policy is not applied to users. `n`n%TestResult%" + } + } + #endregion Assessment Logic + + #region Report Generation + # Build detailed markdown information + $mdInfo = '' + + if ($wcfPolicies -and $wcfPolicies.Count -gt 0) { + # Check if there are any applied policies to determine table structure + if ($appliedPolicies.Count -gt 0) { + # Add table title for applied policies + $mdInfo += "### Applied web content filtering policies`n`n" + + # table for applied policies + $mdInfo += "| Linked profile name | Linked profile priority | Linked policy name | Policy state | Profile state | Policy action | CA policy name | CA policy state |`n" + $mdInfo += "|---------------------|-------------------------|--------------------|--------------|---------------|---------------|----------------|-----------------|`n" + + foreach ($wcfPolicy in $wcfPolicies | Sort-Object -Property name) { + $safePolicyName = Get-SafeMarkdown $wcfPolicy.name + $policyAction = $wcfPolicy.action + $appliedPolicy = $appliedPolicies | Where-Object { $_.PolicyId -eq $wcfPolicy.id } + + if ($appliedPolicy) { + # Get applied profiles for this policy + $appliedProfiles = $appliedPolicy.LinkedProfiles | Where-Object { $_.IsApplied -eq $true } + + foreach ($profileInfo in $appliedProfiles) { + $safeProfileName = Get-SafeMarkdown $profileInfo.ProfileName + $profilePriority = $profileInfo.ProfilePriority + $profileState = $profileInfo.ProfileState + $policyLinkState = $profileInfo.PolicyLinkState + + # Create blade links + $profileBladeLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditProfileMenuBlade.MenuView/~/basics/profileId/$($profileInfo.ProfileId)" + $profileNameWithLink = "[$safeProfileName]($profileBladeLink)" + + $policyBladeLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/WebFilteringPolicy.ReactView" + $policyNameWithLink = "[$safePolicyName]($policyBladeLink)" + + # If there are CA policies, create a row for each one + if ($profileInfo.CAPolicy -and $profileInfo.CAPolicy.Count -gt 0) { + foreach ($caPolicy in $profileInfo.CAPolicy) { + $caPolicyPortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($caPolicy.id)" + $safeCAPolicyName = Get-SafeMarkdown $caPolicy.displayName + $caPolicyNameWithLink = "[$safeCAPolicyName]($caPolicyPortalLink)" + $caPolicyState = $caPolicy.state + + $mdInfo += "| $profileNameWithLink | $profilePriority | $policyNameWithLink | $policyLinkState | $profileState | $policyAction | $caPolicyNameWithLink | $caPolicyState |`n" + } + } + else { + # Baseline profile or profile without CA policy + $mdInfo += "| $profileNameWithLink | $profilePriority | $policyNameWithLink | $policyLinkState | $profileState | $policyAction | Not applicable | Not applicable |`n" + } + } + } + } + } + else { + # Add table title with blade link for unapplied policies + $mdInfo += "### [Web content filtering policies](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/WebFilteringPolicy.ReactView)`n`n" + + # table for unapplied policies + $mdInfo += "The following web content filtering policies are configured but not applied to users.`n`n" + $mdInfo += "| Policy name | Policy action |`n" + $mdInfo += "|-------------|---------------|`n" + + foreach ($wcfPolicy in $wcfPolicies | Sort-Object -Property name) { + $safePolicyName = Get-SafeMarkdown $wcfPolicy.name + $policyAction = $wcfPolicy.action + $mdInfo += "| $safePolicyName | $policyAction |`n" + } + } + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + $params = @{ + TestId = '25408' + Title = 'Global Secure Access web content filtering is enabled and configured' + Status = $passed + Result = $testResultMarkdown + } + + # Add test result details + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.25411.ps1 b/src/powershell/tests/Test-Assessment.25411.ps1 index 3250d4323c..caa35b3f61 100644 --- a/src/powershell/tests/Test-Assessment.25411.ps1 +++ b/src/powershell/tests/Test-Assessment.25411.ps1 @@ -44,28 +44,6 @@ function Test-Assessment-25411 { Write-ZtProgress -Activity $activity -Status 'Querying Conditional Access policies' $allCAPolicies = Get-ZtConditionalAccessPolicy - # Build CA profile lookup for O(1) access instead of O(N) search per profile - Write-ZtProgress -Activity $activity -Status 'Building Conditional Access policy lookup' - $caProfileLookup = @{} - foreach ($cap in $allCAPolicies) { - $session = $cap.sessionControls - if ($null -ne $session -and $null -ne $session.globalSecureAccessFilteringProfile) { - $sessionProfileId = $session.globalSecureAccessFilteringProfile.profileId - $sessionEnabled = $session.globalSecureAccessFilteringProfile.isEnabled - - if ($sessionEnabled -eq $true -and $cap.state -eq 'enabled') { - if (-not $caProfileLookup.ContainsKey($sessionProfileId)) { - $caProfileLookup[$sessionProfileId] = @() - } - $caProfileLookup[$sessionProfileId] += [PSCustomObject]@{ - Id = $cap.id - DisplayName = $cap.displayName - State = $cap.state - } - } - } - } - #endregion Data Collection #region Data Processing @@ -73,93 +51,55 @@ function Test-Assessment-25411 { $enabledSecurityProfiles = @() $enabledBaseLineProfiles = @() - # Iterate each TLS inspection policy and find linked profiles + # Iterate each TLS inspection policy and find linked profiles using the helper function foreach ($tlsPolicy in $tlsInspectionPolicies) { - $tlsId = $tlsPolicy.id - $baseLineProfileFound = $false - foreach ($profileItem in $filteringProfiles) { - $profilePolicies = @() - if ($null -ne $profileItem.policies) { - $profilePolicies = $profileItem.policies - } + $findParams = @{ + PolicyId = $tlsPolicy.id + FilteringProfiles = $filteringProfiles + CAPolicies = $allCAPolicies + BaselinePriority = $BASELINE_PROFILE_PRIORITY + PolicyLinkType = 'tlsInspectionPolicyLink' + PolicyRules = $tlsPolicy + } - foreach ($plink in $profilePolicies) { - $plinkType = $plink.'@odata.type' - $linkedPolicyId = $null - # Only process tlsInspectionPolicyLink entries - if ($plinkType -eq '#microsoft.graph.networkaccess.tlsInspectionPolicyLink' -and $null -ne $plink.policy) { - $linkedPolicyId = $plink.policy.id + $linkedProfiles = Find-ZtProfilesLinkedToPolicy @findParams + + foreach ($policyProfile in $linkedProfiles) { + if ($policyProfile.ProfileType -eq 'Baseline Profile' -and $policyProfile.PassesCriteria -and $policyProfile.ProfileState -eq 'enabled') { + $enabledBaseLineProfiles += [PSCustomObject]@{ + ProfileId = $policyProfile.ProfileId + ProfileName = $policyProfile.ProfileName + ProfileState = $policyProfile.ProfileState + ProfilePriority = $policyProfile.ProfilePriority + TLSPolicyId = $tlsPolicy.id + TLSPolicyName = $tlsPolicy.name + TLSPolicyLinkState = $policyProfile.PolicyLinkState + } + } + elseif ($policyProfile.ProfileType -eq 'Security Profile' -and $policyProfile.PassesCriteria -and $policyProfile.ProfileState -eq 'enabled') { + $matchedCAPolicies = @() + if ($null -ne $policyProfile.CAPolicy) { + $matchedCAPolicies = @($policyProfile.CAPolicy) } - if ($null -ne $linkedPolicyId -and $linkedPolicyId -eq $tlsId) { - $linkState = if ($null -ne $plink.state) { - $plink.state + $enabledSecurityProfiles += [PSCustomObject]@{ + ProfileId = $policyProfile.ProfileId + ProfileName = $policyProfile.ProfileName + ProfileState = $policyProfile.ProfileState + ProfilePriority = $policyProfile.ProfilePriority + TLSPolicyId = $tlsPolicy.id + TLSPolicyName = $tlsPolicy.name + TLSPolicyLinkState = $policyProfile.PolicyLinkState + MatchedCAPolicies = $matchedCAPolicies + CAPolicyCount = $matchedCAPolicies.Count + DefaultAction = if ($null -ne $tlsPolicy.settings) { + $tlsPolicy.settings.defaultAction } else { 'unknown' } - $profileState = if ($null -ne $profileItem.state) { - $profileItem.state - } - else { - 'unknown' - } - $priority = if ($null -ne $profileItem.priority) { - [int]$profileItem.priority - } - else { - $null - } - - if ($priority -eq $BASELINE_PROFILE_PRIORITY) { - # Baseline Profile: apply without CA - - if ($linkState -eq 'enabled' -and $profileState -eq 'enabled') { - $baseLineProfileFound = $true - $enabledBaseLineProfiles += [PSCustomObject]@{ - ProfileId = $profileItem.id - ProfileName = $profileItem.name - ProfileState = $profileState - ProfilePriority = $priority - TLSPolicyId = $tlsId - TLSPolicyName = $plink.policy.name - TLSPolicyLinkState = $linkState - } - break - } - } elseif ($null -ne $priority -and $priority -lt $BASELINE_PROFILE_PRIORITY) { - # Security Profile: must be applied via Conditional Access - # Validate CA policies reference this profile via sessionControls - $matchedCAPolicies = @() - if ($caProfileLookup.ContainsKey($profileItem.id)) { - $matchedCAPolicies = $caProfileLookup[$profileItem.id] - } - - if ($matchedCAPolicies.Count -gt 0 -and $profileState -eq 'enabled' -and $linkState -eq 'enabled') { - $enabledSecurityProfiles += [PSCustomObject]@{ - ProfileId = $profileItem.id - ProfileName = $profileItem.name - ProfileState = $profileState - ProfilePriority = $priority - TLSPolicyId = $tlsId - TLSPolicyName = $plink.policy.name - TLSPolicyLinkState = $linkState - MatchedCAPolicies = $matchedCAPolicies - CAPolicyCount = $matchedCAPolicies.Count - DefaultAction = if ($null -ne $tlsPolicy.settings) { - $tlsPolicy.settings.defaultAction - } - else { - 'unknown' - } - } - } - } } } - if ($baseLineProfileFound) { - break - } } } diff --git a/src/powershell/tests/Test-Assessment.35009.md b/src/powershell/tests/Test-Assessment.35009.md new file mode 100644 index 0000000000..985f5c781c --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35009.md @@ -0,0 +1,15 @@ +Co-authoring allows multiple users to simultaneously edit Office documents stored in SharePoint and OneDrive. When sensitivity labels apply encryption to documents, co-authoring capabilities are disabled by default, forcing users to work sequentially rather than collaboratively. Without co-authoring enabled for encrypted files, users face productivity barriers that incentivize removing encryption or working with unprotected copies to maintain collaboration velocity. The EnableLabelCoauth tenant-wide setting allows co-authoring on encrypted documents while maintaining protection and access controls defined by sensitivity labels. + +**Remediation action** + +To enable co-authoring for encrypted documents: + +1. Connect to Security & Compliance PowerShell: `Connect-IPPSSession` +2. Run the command: `Set-PolicyConfig -EnableLabelCoauth $true` +3. Wait for replication (changes may take up to 24 hours to propagate fully) +4. Users may need to sign out and sign back in to Office applications + +- [Enable co-authoring for encrypted documents](https://learn.microsoft.com/en-us/purview/sensitivity-labels-coauthoring) +- [Set-PolicyConfig cmdlet reference](https://learn.microsoft.com/en-us/powershell/module/exchange/set-policyconfig) + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35009.ps1 b/src/powershell/tests/Test-Assessment.35009.ps1 new file mode 100644 index 0000000000..447e8948d7 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35009.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS +Checks whether co-authoring is enabled for encrypted documents with sensitivity labels. +#> + +function Test-Assessment-35009 { + [ZtTest( + Category = 'Sensitivity Labels', + ImplementationCost = 'Low', + MinimumLicense = ('Microsoft 365 E5'), + Pillar = 'Data', + RiskLevel = 'Low', + SfiPillar = '', + TenantType = ('Workforce'), + TestId = 35009, + Title = 'Co-Authoring Enabled for Encrypted Documents', + UserImpact = 'High' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = "Checking co-authoring is enabled for encrypted documents" + Write-ZtProgress -Activity $activity -Status "Getting policy configuration" + + $cmdletFailed = $false + + # Q1: Retrieve policy configuration settings + try { + $policyConfig = Get-PolicyConfig -ErrorAction Stop + } + catch { + Write-PSFMessage "Failed to retrieve policy configuration: $_" -Tag Test -Level Warning + $cmdletFailed = $true + } + + # Q2: Check EnableLabelCoauth property value + if (-not $cmdletFailed) { + $enableLabelCoauth = $policyConfig.EnableLabelCoauth + } + + #endregion Data Collection + + #region Assessment Logic + + if ($cmdletFailed) { + # Cmdlet failed - mark as Investigate + $passed = $false + $customStatus = 'Investigate' + $testResultMarkdown = "⚠️ Policy configuration exists but EnableLabelCoauth setting cannot be determined.`n`n" + } + elseif ($enableLabelCoauth -eq $true) { + $passed = $true + $testResultMarkdown = "✅ Co-authoring is enabled for encrypted documents with sensitivity labels.`n`n%TestResult%" + } + else{ + $passed = $false + $testResultMarkdown = "❌ Co-authoring is disabled for encrypted documents.`n`n%TestResult%" + } + + #endregion Assessment Logic + + #region Report Generation + + if (-not $cmdletFailed) { + $reportDetails = "" + $reportDetails += "`n`n## Configuration Details`n`n" + $reportDetails += "| Setting | Status |`n" + $reportDetails += "| :------ | :----- |`n" + $statusDisplay = if ($enableLabelCoauth -eq $true) { '✅ Enabled' } elseif ($enableLabelCoauth -eq $false) { '❌ Disabled' } else { '-' } + $reportDetails += "| EnableLabelCoauth | $statusDisplay |`n" + + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $reportDetails + } + + #endregion Report Generation + + $params = @{ + TestId = '35009' + Title = 'Co-Authoring Enabled for Encrypted Documents' + Status = $passed + Result = $testResultMarkdown + } + if ($customStatus) { + $params.CustomStatus = $customStatus + } + + Add-ZtTestResultDetail @params + +} diff --git a/src/powershell/tests/Test-Assessment.35011.md b/src/powershell/tests/Test-Assessment.35011.md new file mode 100644 index 0000000000..614ac023d1 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35011.md @@ -0,0 +1,29 @@ +The super user feature in Azure Information Protection grants designated accounts the ability to decrypt all content protected by the organization's Rights Management service, regardless of the encryption permissions originally assigned. Super users can access encrypted documents even when they are not explicitly granted permissions by the content owner, enabling scenarios such as eDiscovery, data recovery, compliance investigations, and migration from encrypted content. + +Without super user configuration, organizations risk data loss when encryption keys become inaccessible, employees leave without transferring ownership of critical encrypted files, or legal holds require access to protected content where the original rights holders cannot be reached. The super user feature must be explicitly enabled and membership must be carefully controlled—typically limited to service accounts used by compliance tools, backup systems, or eDiscovery platforms rather than individual user accounts. Failure to configure super users creates operational risk where encrypted content becomes permanently inaccessible, while overly broad super user membership creates security risk where unauthorized accounts gain unrestricted access to all protected content. + +**Remediation action** + +To configure super users: + +1. Connect to Azure Information Protection PowerShell: `Connect-AipService` +2. Enable the super user feature: `Enable-AipServiceSuperUserFeature` +3. Add super users (service accounts recommended): + - For user accounts: `Add-AipServiceSuperUser -EmailAddress "serviceaccount@contoso.com"` + - For service principals: `Add-AipServiceSuperUser -ServicePrincipalId "service-principal-id"` +4. Verify configuration: `Get-AipServiceSuperUser` + +Best practices: + +- Limit super user membership to dedicated service accounts +- Use service principals for automated tools (eDiscovery, backup) +- Avoid assigning super user to individual employee accounts +- Audit super user access regularly +- Document business justification for each super user account + +- [Configure super users for Azure Information Protection](https://learn.microsoft.com/en-us/purview/encryption-super-users) +- [Enable-AipServiceSuperUserFeature](https://learn.microsoft.com/en-us/powershell/module/aipservice/enable-aipservicesuperuserfeature) +- [Add-AipServiceSuperUser](https://learn.microsoft.com/en-us/powershell/module/aipservice/add-aipservicesuperuser) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35011.ps1 b/src/powershell/tests/Test-Assessment.35011.ps1 new file mode 100644 index 0000000000..28c397c263 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35011.ps1 @@ -0,0 +1,152 @@ +<# +.SYNOPSIS + Azure Information Protection (AIP) Super User Feature Configuration + +.DESCRIPTION + Evaluates whether the Azure Information Protection (AIP) super user feature is enabled and properly configured with designated super users. The super user feature allows specified service accounts or administrators to decrypt rights-managed content for auditing, search, and compliance purposes. + + The cmdlets require the AipService module (v3.0+) which is only supported on Windows PowerShell 5.1. A PowerShell 7 subprocess workaround is automatically employed if running under PowerShell Core. + +.NOTES + Test ID: 35011 + Pillar: Data + Risk Level: High +#> + +function Test-Assessment-35011 { + [ZtTest( + Category = 'Azure Information Protection', + ImplementationCost = 'Medium', + MinimumLicense = ('Microsoft 365 E5'), + Pillar = 'Data', + RiskLevel = 'High', + SfiPillar = '', + TenantType = ('Workforce','External'), + TestId = 35011, + Title = 'Azure Information Protection (AIP) Super User Feature', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Azure Information Protection Super User Configuration' + Write-ZtProgress -Activity $activity -Status 'Querying AIP super user settings' + + $superUserFeatureEnabled = $null + $superUsers = @() + $errorMsg = $null + + try { + # Note: AipService must be authenticated in Connect-ZtAssessment first + # This test only performs queries against the authenticated service + + # Query Q1: Check if super user feature is enabled + $superUserFeatureEnabled = Get-AipServiceSuperUserFeature -ErrorAction Stop + + # Query Q2: Get list of configured super users + $superUsers = Get-AipServiceSuperUser -ErrorAction Stop + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying AIP Super User configuration: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + $passed = $false + $investigateFlag = $false + + if ($errorMsg) { + $investigateFlag = $true + } + else { + # Evaluation logic: + # 1. If feature is disabled, test fails + if ($superUserFeatureEnabled -eq $false) { + $passed = $false + } + # 2. If feature is enabled, check if at least one super user is configured + elseif ($superUserFeatureEnabled -eq $true) { + $superUserCount = if ($superUsers) { @($superUsers).Count } else { 0 } + + if ($superUserCount -ge 1) { + $passed = $true + } + else { + $passed = $false + } + } + } + #endregion Assessment Logic + + #region Report Generation + $testResultMarkdown = "" + $mdInfo = "" + + if ($investigateFlag) { + $testResultMarkdown = "⚠️ Unable to determine AIP super user configuration due to permissions or connection issues.`n`n" + } + else { + if ($passed) { + $testResultMarkdown = "✅ Super user feature is enabled with at least one member configured.`n`n" + } + else { + if ($superUserFeatureEnabled -eq $true) { + $testResultMarkdown = "❌ Super user feature is enabled BUT no members are configured.`n`n" + } + else { + $testResultMarkdown = "❌ Super user feature is DISABLED.`n`n" + } + } + + # Build detailed information section + $mdInfo = "## Azure Information Protection Super User Configuration`n`n" + + $featureStatus = if ($superUserFeatureEnabled) { "Enabled" } else { "Disabled" } + $mdInfo += "**Super User Feature: $featureStatus**`n`n" + + if ($superUserFeatureEnabled) { + $superUserCount = if ($superUsers) { @($superUsers).Count } else { 0 } + $mdInfo += "**Super Users Configured: $superUserCount**`n`n" + + if ($superUserCount -gt 0) { + $mdInfo += "| Email Address / Service Principal ID | Account Type |`n" + $mdInfo += "| :--- | :--- |`n" + + foreach ($superUser in $superUsers) { + $accountType = if ($superUser -like '*-*-*-*-*') { "Service Principal" } else { "User" } + $mdInfo += "| $superUser | $accountType |`n" + } + + $mdInfo += "`n" + } + } + + $mdInfo += "**Note:** Super user configuration is not available through the Azure portal and must be managed via PowerShell using the AipService module.`n" + + # Add mdInfo to the main markdown if there's content + if ($mdInfo) { + $testResultMarkdown += "%TestResult%" + } + } + #endregion Report Generation + + # Replace placeholder with actual detailed info + if ($mdInfo) { + $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $mdInfo + } + + $params = @{ + TestId = '35011' + Status = $passed + Result = $testResultMarkdown + } + # Add investigate status if needed + if ($investigateFlag -eq $true) { + $params.CustomStatus = 'Investigate' + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.35025.md b/src/powershell/tests/Test-Assessment.35025.md new file mode 100644 index 0000000000..8ae5cc3e55 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35025.md @@ -0,0 +1,23 @@ +Azure RMS includes both external and internal licensing capabilities that must be configured separately. While Azure RMS activation (test 35024) enables the service globally, internal RMS licensing specifically allows users and services within the organization to license protected content for internal distribution and sharing. Without internal RMS licensing enabled, users cannot share rights-protected content with internal recipients, preventing collaboration on encrypted emails and files within the organization. Internal RMS licensing must be explicitly enabled alongside super user configuration to ensure that legal holds, eDiscovery, and data recovery operations can access encrypted content. Organizations that have enabled Azure RMS but not internal licensing inadvertently block internal protected content sharing while potentially leaving external sharing unprotected. Both internal and external RMS licensing settings should be configured together as part of a comprehensive rights management strategy. + +**Remediation action** + +To enable internal RMS licensing: + +1. Verify Azure RMS is enabled (test 35024) - internal licensing requires Azure RMS to be active +2. Sign in as Global Administrator to the [Microsoft Purview portal](https://purview.microsoft.com) +3. Navigate to Settings > Encryption > Azure Information Protection +4. Review RMS licensing configuration settings +5. Ensure internal licensing and distribution settings are enabled for the organization +6. If not enabled, contact Microsoft Support to activate internal licensing configuration + +For organizations using Exchange Online, ensure mail flow policies and RMS features are not blocked: +1. Connect to Exchange Online: `Connect-ExchangeOnline` +2. Verify internal licensing is enabled: `Set-IRMConfiguration -InternalLicensingEnabled $true` +3. Verify the setting: `Get-IRMConfiguration | Select-Object -Property InternalLicensingEnabled, ExternalLicensingEnabled` + +- [Configure Azure Rights Management licensing](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities) +- [Rights Management in Exchange Online](https://learn.microsoft.com/en-us/purview/information-rights-management-in-exchange-online) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35025.ps1 b/src/powershell/tests/Test-Assessment.35025.ps1 new file mode 100644 index 0000000000..984edf7d60 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35025.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Validates that internal RMS licensing is enabled in Exchange Online. + +.DESCRIPTION + This test checks if internal RMS licensing is enabled, which allows users and services within the + organization to license protected content for internal distribution and sharing. Without internal + RMS licensing enabled, users cannot share rights-protected content with internal recipients. + +.NOTES + Test ID: 35025 + Category: Rights Management Service (RMS) + Pillar: Data + Required Module: ExchangeOnlineManagement + Required Connection: Exchange Online +#> + +function Test-Assessment-35025 { + [ZtTest( + Category = 'Rights Management Service (RMS)', + ImplementationCost = 'Low', + MinimumLicense = ('Microsoft 365 E3'), + Pillar = 'Data', + RiskLevel = 'High', + SfiPillar = 'Protect tenants and production systems', + TenantType = ('Workforce'), + TestId = 35025, + Title = 'Internal RMS Licensing Enabled', + UserImpact = 'High' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Internal RMS Licensing Status' + Write-ZtProgress -Activity $activity -Status 'Getting IRM configuration' + + # Get IRM licensing configuration + $irmConfig = $null + $errorMsg = $null + + try { + $irmConfig = Get-IRMConfiguration -ErrorAction Stop + } + catch { + $errorMsg = $_ + Write-PSFMessage "Failed to retrieve IRM configuration: $_" -Tag Test -Level Warning + } + #endregion Data Collection + + #region Assessment Logic + $passed = $false + $customStatus = $null + + if ($errorMsg) { + # Investigate: Cannot query IRM configuration + $passed = $false + $customStatus = 'Investigate' + } + elseif ($null -eq $irmConfig.InternalLicensingEnabled) { + # Investigate: Cannot determine licensing status + $passed = $false + $customStatus = 'Investigate' + } + elseif ($irmConfig.InternalLicensingEnabled -eq $true) { + # Pass: Internal RMS licensing is enabled + $passed = $true + } + else { + # Fail: Internal RMS licensing is not enabled + $passed = $false + } + #endregion Assessment Logic + + #region Report Generation + if ($customStatus -eq 'Investigate') { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to determine internal RMS licensing status due to permissions issues or incomplete configuration data." + } + else { + if ($passed) { + $testResultMarkdown = "✅ Internal RMS licensing is enabled, allowing internal users to license and share protected content within the organization.`n`n" + } + else { + $testResultMarkdown = "❌ Internal RMS licensing is not enabled or licensing endpoints are not configured.`n`n" + } + + # Build detailed information if we have data + if ($irmConfig) { + # Prepare values first + $internalLicensingValue = if ($null -eq $irmConfig.InternalLicensingEnabled) { + 'Unknown' + } else { + $irmConfig.InternalLicensingEnabled + } + + $externalLicensingValue = if ($null -eq $irmConfig.ExternalLicensingEnabled) { + 'Unknown' + } else { + $irmConfig.ExternalLicensingEnabled + } + + $azureRMSLicensingValue = if ($null -eq $irmConfig.AzureRMSLicensingEnabled) { + 'Unknown' + } else { + $irmConfig.AzureRMSLicensingEnabled + } + + $licensingLocationValue = if ($irmConfig.LicensingLocation) { + ($irmConfig.LicensingLocation | ForEach-Object { Get-SafeMarkdown $_ }) -join ', ' + } else { + 'Not configured' + } + + $internalLicensingConfig = if ($irmConfig.InternalLicensingEnabled -eq $true) { + '✅ Enabled' + } elseif ($irmConfig.InternalLicensingEnabled -eq $false) { + '❌ Disabled' + } else { + '⚠️ Incomplete' + } + + $licensingEndpoints = if ($irmConfig.LicensingLocation) { + '✅ Configured' + } else { + '❌ Not Configured' + } + + # Build table + $testResultMarkdown += "**[Internal RMS Licensing Status](https://purview.microsoft.com/settings/encryption)**`n" + $testResultMarkdown += "| Setting | Status |`n" + $testResultMarkdown += "| :--- | :--- |`n" + $testResultMarkdown += "| InternalLicensingEnabled | $internalLicensingValue |`n" + $testResultMarkdown += "| ExternalLicensingEnabled | $externalLicensingValue |`n" + $testResultMarkdown += "| AzureRMSLicensingEnabled | $azureRMSLicensingValue |`n" + $testResultMarkdown += "| LicensingLocation | $licensingLocationValue |`n`n" + + # Summary section + $testResultMarkdown += "**Summary:**`n" + $testResultMarkdown += "* Internal Licensing Configuration: $internalLicensingConfig`n" + $testResultMarkdown += "* Licensing Endpoints: $licensingEndpoints`n" + } + } + #endregion Report Generation + + $params = @{ + TestId = '35025' + Title = 'Internal RMS Licensing Enabled' + Status = $passed + Result = $testResultMarkdown + } + if ($customStatus) { + $params.CustomStatus = $customStatus + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.35030.md b/src/powershell/tests/Test-Assessment.35030.md new file mode 100644 index 0000000000..7ffc45f8ec --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35030.md @@ -0,0 +1,34 @@ +Data Loss Prevention (DLP) policies protect sensitive information by monitoring, detecting, and preventing the sharing of confidential data across Microsoft 365 workloads including Exchange Online, SharePoint Online, OneDrive, and Microsoft Teams. + +When DLP policies are not enabled or configured, organizations lack automated controls to prevent accidental or intentional disclosure of sensitive information such as credit card numbers, social security numbers, financial data, or proprietary information. Without active DLP policies, employees can freely share sensitive content through email, file uploads, or team communications without organizational oversight, increasing the risk of data breaches, regulatory violations (GDPR, HIPAA, PCI-DSS), and reputational damage. + +Enabling and configuring at least one DLP policy ensures organizations have automated detection and response capabilities for sensitive data, reducing the risk of unauthorized data exfiltration and demonstrating compliance readiness to regulators and auditors. + +**Remediation action** + +To create and enable DLP policies: + +1. Sign in as a Global Administrator or Compliance Administrator to the [Microsoft Purview portal](https://purview.microsoft.com) +2. Navigate to Data Loss Prevention > Policies +3. Select "+ Create policy" to start a new DLP policy +4. Choose a template (Financial data, Health data, Privacy, Custom, etc.) or create a custom policy +5. Define sensitive information types (SITs) to detect (credit card numbers, SSN, bank account numbers, etc.) +6. Configure rule conditions (locations, conditions for detection, scope) +7. Set enforcement actions (notify users, restrict access, block sharing, etc.) +8. Choose enforcement mode: + - Test mode (audit-only): Monitors but does not block activities + - Enforce mode: Blocks activities matching policy rules +9. Enable the policy and deploy to workloads (Exchange, SharePoint, OneDrive, Teams) +10. Monitor DLP alerts and adjust rules as needed + +Alternatively, create via PowerShell: +1. Connect to Exchange Online: `Connect-ExchangeOnline` +2. Create a policy: `New-DlpCompliancePolicy -Name "Sensitive Data Protection" -Mode "Enforce"` +3. Add rules to the policy: `New-DlpComplianceRule -Name "Block SSN" -Policy "Sensitive Data Protection"` +4. Enable and test: `Get-DlpCompliancePolicy | Select-Object -Property Name, Enabled` + +- [Create and configure DLP policies](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) +- [DLP policy templates](https://learn.microsoft.com/en-us/purview/dlp-policy-templates) +- [DLP Compliance Rules](https://learn.microsoft.com/en-us/powershell/module/exchange/new-dlpcompliancerule) + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35030.ps1 b/src/powershell/tests/Test-Assessment.35030.ps1 new file mode 100644 index 0000000000..6c53d54675 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35030.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Data Loss Prevention (DLP) Policies + +.DESCRIPTION + Data Loss Prevention (DLP) policies protect sensitive information by monitoring, detecting, and preventing the sharing of confidential data across Microsoft 365 workloads including Exchange Online, SharePoint Online, OneDrive, and Microsoft Teams. + When DLP policies are not enabled or configured, organizations lack automated controls to prevent accidental or intentional disclosure of sensitive information such as credit card numbers, social security numbers, financial data, or proprietary information. Without active DLP policies, employees can freely share sensitive content through email, file uploads, or team communications without organizational oversight, increasing the risk of data breaches, regulatory violations (GDPR, HIPAA, PCI-DSS), and reputational damage. Enabling and configuring at least one DLP policy ensures organizations have automated detection and response capabilities for sensitive data, reducing the risk of unauthorized data exfiltration and demonstrating compliance readiness to regulators and auditors. + +.NOTES + Test ID: 35030 + Pillar: Data + Risk Level: High +#> + +function Test-Assessment-35030 { + [ZtTest( + Category = 'Data Loss Prevention (DLP)', + ImplementationCost = 'Medium', + MinimumLicense = ('Microsoft 365 E3'), + Pillar = 'Data', + RiskLevel = 'High', + SfiPillar = 'Protect tenants and production systems', + TenantType = ('Workforce'), + TestId = 35030, + Title = 'DLP Policies Enabled', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Data Loss Prevention Policies' + Write-ZtProgress -Activity $activity -Status 'Querying DLP policies from compliance center' + + $dlpPolicies = $null + $dlpPoliciesDetailed = $null + $enabledPoliciesCount = 0 + $errorMsg = $null + + try { + # Q1: Get all DLP policies in the organization + $dlpPolicies = Get-DlpCompliancePolicy -ErrorAction Stop + + # Q2: Get details on DLP policy status and rule count + $dlpPoliciesDetailed = $dlpPolicies | Select-Object -Property Name, Enabled, WhenCreatedUTC, WhenChangedUTC + + # Q3: Count enabled vs disabled DLP policies + $enabledPoliciesCount = @($dlpPolicies | Where-Object Enabled).Count + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying DLP policies: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + $investigateFlag = $false + $passed = $false + + if ($errorMsg) { + $investigateFlag = $true + } + else { + # If enabled policy count >= 1, the test passes + if ($enabledPoliciesCount -ge 1) { + $passed = $true + } + else { + # No policies exist or all policies are disabled + $passed = $false + } + } + #endregion Assessment Logic + + #region Report Generation + $testResultMarkdown = "" + + if ($investigateFlag) { + $testResultMarkdown = "⚠️ Unable to determine DLP policy status due to permissions issues or service connection failure.`n`n" + } + else { + if ($passed) { + $testResultMarkdown = "✅ One or more DLP policies are enabled and configured, providing automated protection against sensitive data disclosure.`n`n" + } + else { + $testResultMarkdown = "❌ No DLP policies are enabled or no DLP policies exist in the organization.`n`n" + } + + $testResultMarkdown += "## Data Loss Prevention Policy Summary`n`n" + $testResultMarkdown += "**Total DLP Policies:** $($dlpPolicies.Count)`n`n" + $testResultMarkdown += "**Enabled Policies:** $enabledPoliciesCount`n`n" + + if ($dlpPoliciesDetailed.Count -gt 0) { + $testResultMarkdown += "### DLP Policies Configuration`n`n" + $testResultMarkdown += "| Policy Name | Enabled Status | Created Date | Last Modified Date |`n" + $testResultMarkdown += "| :--- | :--- | :--- | :--- |`n" + + foreach ($policy in $dlpPoliciesDetailed) { + $enabledStatus = if ($policy.Enabled) { "✅ Yes" } else { "❌ No" } + $createdDate = if ($policy.WhenCreatedUTC) { $policy.WhenCreatedUTC.ToString('yyyy-MM-dd') } else { "N/A" } + $modifiedDate = if ($policy.WhenChangedUTC) { $policy.WhenChangedUTC.ToString('yyyy-MM-dd') } else { "N/A" } + $testResultMarkdown += "| $($policy.Name) | $enabledStatus | $createdDate | $modifiedDate |`n" + } + $testResultMarkdown += "`n" + } + } + + $testResultMarkdown += "[View DLP Policies in Microsoft Purview Portal](https://purview.microsoft.com/datalossprevention/policies)`n" + #endregion Report Generation + + $params = @{ + TestId = '35030' + Status = $passed + Result = $testResultMarkdown + } + if ($investigateFlag -eq $true) { + $params.CustomStatus = 'Investigate' + } + Add-ZtTestResultDetail @params +}