diff --git a/code-tests/test-assessments/Test-Assessment.35006.Tests.ps1 b/code-tests/test-assessments/Test-Assessment.35006.Tests.ps1 new file mode 100644 index 0000000000..b033748a35 --- /dev/null +++ b/code-tests/test-assessments/Test-Assessment.35006.Tests.ps1 @@ -0,0 +1,108 @@ +Describe "Test-Assessment-35006" { + BeforeAll { + $here = $PSScriptRoot + $srcRoot = Join-Path $here "../../src/powershell" + + # Mock external module dependencies if they are not present + if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) { + function Write-PSFMessage {} + } + if (-not (Get-Command Get-SPOTenant -ErrorAction SilentlyContinue)) { + function Get-SPOTenant {} + } + + # Load the class + $classPath = Join-Path $srcRoot "classes/ZtTest.ps1" + if (-not ("ZtTest" -as [type])) { + . $classPath + } + + # Load the SUT + $sut = Join-Path $srcRoot "tests/Test-Assessment.35006.ps1" + . $sut + + # Setup output file + $script:outputFile = Join-Path $here "../TestResults/Report-Test-Assessment.35006.md" + $outputDir = Split-Path $script:outputFile + if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null } + "# Test Results for 35006`n" | Set-Content $script:outputFile + } + + # Mock common module functions + BeforeEach { + Mock Write-PSFMessage {} + Mock Write-ZtProgress {} + } + + Context "When querying SharePoint tenant settings fails" { + It "Should return Investigate status" { + Mock Get-SPOTenant { throw "Connection error" } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Error querying settings`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35006 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match "Unable to query SharePoint Tenant Settings" + } + } + } + + Context "When PDF labeling support is enabled" { + It "Should return Pass status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + EnableSensitivityLabelforPDF = $true + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: PDF labeling enabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35006 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $true -and $Result -match 'EnableSensitivityLabelforPDF: True' + } + } + } + + Context "When PDF labeling support is disabled" { + It "Should return Fail status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + EnableSensitivityLabelforPDF = $false + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: PDF labeling disabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35006 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match 'EnableSensitivityLabelforPDF: False' + } + } + } + + Context "When Get-SPOTenant returns null" { + It "Should return Fail status" { + Mock Get-SPOTenant { return $null } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Get-SPOTenant returns null`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35006 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match 'EnableSensitivityLabelforPDF: False' + } + } + } +} diff --git a/code-tests/test-assessments/Test-Assessment.35007.Tests.ps1 b/code-tests/test-assessments/Test-Assessment.35007.Tests.ps1 new file mode 100644 index 0000000000..cf16771f0a --- /dev/null +++ b/code-tests/test-assessments/Test-Assessment.35007.Tests.ps1 @@ -0,0 +1,92 @@ +Describe "Test-Assessment-35007" { + BeforeAll { + $here = $PSScriptRoot + $srcRoot = Join-Path $here "../../src/powershell" + + # Mock external module dependencies if they are not present + if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) { + function Write-PSFMessage {} + } + if (-not (Get-Command Get-SPOTenant -ErrorAction SilentlyContinue)) { + function Get-SPOTenant {} + } + + # Load the class + $classPath = Join-Path $srcRoot "classes/ZtTest.ps1" + if (-not ("ZtTest" -as [type])) { + . $classPath + } + + # Load the SUT + $sut = Join-Path $srcRoot "tests/Test-Assessment.35007.ps1" + . $sut + + # Setup output file + $script:outputFile = Join-Path $here "../TestResults/Report-Test-Assessment.35007.md" + $outputDir = Split-Path $script:outputFile + if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null } + "# Test Results for 35007`n" | Set-Content $script:outputFile + } + + # Mock common module functions + BeforeEach { + Mock Write-PSFMessage {} + Mock Write-ZtProgress {} + } + + Context "When querying SharePoint tenant settings fails" { + It "Should return Investigate status" { + Mock Get-SPOTenant { throw "Connection error" } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Error querying settings`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35007 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match "Unable to query SharePoint Tenant Settings" + } + } + } + + Context "When IRM is enabled (Fail)" { + It "Should return Fail status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + IrmEnabled = $true + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: IRM enabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35007 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match 'IrmEnabled: True' + } + } + } + + Context "When IRM is disabled (Pass)" { + It "Should return Pass status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + IrmEnabled = $false + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: IRM disabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35007 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $true -and $Result -match 'IrmEnabled: False' + } + } + } +} diff --git a/code-tests/test-assessments/Test-Assessment.35008.Tests.ps1 b/code-tests/test-assessments/Test-Assessment.35008.Tests.ps1 new file mode 100644 index 0000000000..3d7fdcc2d1 --- /dev/null +++ b/code-tests/test-assessments/Test-Assessment.35008.Tests.ps1 @@ -0,0 +1,112 @@ +Describe "Test-Assessment-35008" { + BeforeAll { + $here = $PSScriptRoot + $srcRoot = Join-Path $here "../../src/powershell" + + # Mock external module dependencies if they are not present + if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) { + function Write-PSFMessage {} + } + if (-not (Get-Command Get-SPOTenant -ErrorAction SilentlyContinue)) { + function Get-SPOTenant {} + } + + # Load the class + $classPath = Join-Path $srcRoot "classes/ZtTest.ps1" + if (-not ("ZtTest" -as [type])) { + . $classPath + } + + # Load the SUT + $sut = Join-Path $srcRoot "tests/Test-Assessment.35008.ps1" + . $sut + + # Setup output file + $script:outputFile = Join-Path $here "../TestResults/Report-Test-Assessment.35008.md" + $outputDir = Split-Path $script:outputFile + if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null } + "# Test Results for 35008`n" | Set-Content $script:outputFile + } + + # Mock common module functions + BeforeEach { + Mock Write-PSFMessage {} + Mock Write-ZtProgress {} + } + + Context "When querying SharePoint tenant settings fails" { + It "Should return Fail status with Investigate message" { + Mock Get-SPOTenant { throw "Connection error" } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Error querying settings`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35008 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match "Unable to query SharePoint Tenant Settings" + } + } + } + + Context "When Default Labeling is Disabled (Fail)" { + It "Should return Fail status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + DisableDocumentLibraryDefaultLabeling = $true + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Default Labeling Disabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35008 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $false -and $Result -match 'DisableDocumentLibraryDefaultLabeling: True' + } + } + } + + Context "When Default Labeling is Enabled (Pass)" { + It "Should return Pass status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + DisableDocumentLibraryDefaultLabeling = $false + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Default Labeling Enabled`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35008 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $true -and $Result -match 'DisableDocumentLibraryDefaultLabeling: False' + } + } + } + + Context "When Default Labeling is Null (Pass)" { + It "Should return Pass status" { + Mock Get-SPOTenant { + return [PSCustomObject]@{ + DisableDocumentLibraryDefaultLabeling = $null + } + } + Mock Add-ZtTestResultDetail { + param($TestId, $Title, $Status, $Result) + "## Scenario: Default Labeling Null`n`n$Result`n" | Add-Content $script:outputFile + } + + Test-Assessment-35008 + + Should -Invoke Add-ZtTestResultDetail -ParameterFilter { + $Status -eq $true -and $Result -match 'DisableDocumentLibraryDefaultLabeling: False' + } + } + } +} 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/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.25403.md b/src/powershell/tests/Test-Assessment.25403.md new file mode 100644 index 0000000000..9bd3caa5bb --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25403.md @@ -0,0 +1,8 @@ +Without the Microsoft Entra Private Access Sensor agent deployed to domain controllers, threat actors can exploit Kerberos authentication requests from any device on the network. This includes unmanaged or compromised endpoints that can obtain service tickets for on-premises resources without multifactor authentication or device compliance validation. Once initial access is established through weak authentication to domain controllers, threat actors can request Kerberos tickets for privileged resources such as file shares, database servers, and remote desktop services. This enables lateral movement across the on-premises environment. The absence of identity-centric access controls on domain controllers allows threat actors to bypass conditional access policies. Kerberos authentication traditionally operates within a perimeter-based trust model where any authenticated user can request tickets regardless of authentication strength or device posture. This creates opportunities for threat actors to maintain persistence by continuously requesting service tickets from compromised or unmanaged devices, escalating privileges by targeting Service Principal Names associated with sensitive systems, and exfiltrating data from resources that rely solely on Kerberos authentication without additional security layers. The gap becomes particularly problematic when threat actors compromise user credentials through phishing or credential theft, as they can immediately leverage those credentials to access domain-authenticated resources. This can occur without triggering conditional access policies or multifactor authentication requirements that would normally apply to cloud-based access scenarios. + +**Remediation Resources** + +- [Configure Microsoft Entra Private Access for Active Directory domain controllers](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-domain-controllers) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25403.ps1 b/src/powershell/tests/Test-Assessment.25403.ps1 new file mode 100644 index 0000000000..38584c985e --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25403.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Validates that Private Access Sensors are deployed on domain controllers and enforcing strong authentication policies. + +.DESCRIPTION + This test checks if Microsoft Entra Private Access Sensors are deployed to domain controllers + and configured to enforce strong authentication policies (status active and not in audit mode). + +.NOTES + Test ID: 25403 + Category: Private Access + Required API: onPremisesPublishingProfiles/privateAccess/sensors (beta) +#> + +function Test-Assessment-25403 { + [ZtTest( + Category = 'Private Access', + ImplementationCost = 'Medium', + MinimumLicense = ('Entra_Suite', 'Entra_Premium_Private_Access'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce'), + TestId = 25403, + Title = 'DC Agent is deployed and enforcing strong authentication policies', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Private Access Sensors on domain controllers' + Write-ZtProgress -Activity $activity -Status 'Getting Private Access Sensors' + + # Query all Private Access Sensors + $sensors = Invoke-ZtGraphRequest -RelativeUri 'onPremisesPublishingProfiles/privateAccess/sensors' -ApiVersion beta + #endregion Data Collection + + #region Assessment Logic + # Initialize test variables + $testResultMarkdown = '' + $passed = $false + + if ($null -eq $sensors -or $sensors.Count -eq 0) { + # No sensors found - fail + $passed = $false + $testResultMarkdown = "❌ Microsoft Entra Private Access Sensors for domain controllers is not deployed.`n`n%TestResult%" + } + else { + # Identify sensors that are active and enforcing (not in audit mode) + $enforcingSensors = $sensors | Where-Object { $_.status -eq 'active' -and $_.isAuditMode -eq $false } + $nonEnforcingSensors = $sensors | Where-Object { $_.status -ne 'active' -or $_.isAuditMode -eq $true } + + # Determine pass/fail status + if ($enforcingSensors.Count -gt 0 -and $nonEnforcingSensors.Count -eq 0) { + # All sensors are active and enforcing - pass + $passed = $true + $testResultMarkdown = "✅ Microsoft Entra Private Access for domain controllers is deployed and enforcing strong authentication policies.`n`n%TestResult%" + } + elseif ($enforcingSensors.Count -eq 0) { + # No sensors are enforcing - fail + $passed = $false + $testResultMarkdown = "❌ Microsoft Entra Private Access Sensors are deployed but strong authentication policies are not configured.`n`n%TestResult%" + } + else { + # Some sensors enforcing, some not - partial deployment warning (fail) + $passed = $false + $testResultMarkdown = "⚠️ Microsoft Entra Private Access Sensors are partially configured. Some domain controllers are not enforcing strong authentication policies.`n`n%TestResult%" + } + } + #endregion Assessment Logic + + #region Report Generation + # Build detailed markdown information + $mdInfo = '' + + if ($sensors -and $sensors.Count -gt 0) { + $reportTitle = "Private Access Sensors" + + $mdInfo += "`n## $reportTitle`n`n" + $mdInfo += "[Open Private Access in Entra Portal](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/PrivateAccessOverview.ReactView)`n`n" + + # Summary statistics + $mdInfo += "- **Total sensors**: $($sensors.Count)`n" + $mdInfo += "- **Active and enforcing**: $($enforcingSensors.Count)`n" + $mdInfo += "- **Not enforcing**: $($nonEnforcingSensors.Count)`n`n" + + # Show warning for sensors not enforcing + if ($nonEnforcingSensors.Count -gt 0) { + $mdInfo += "**⚠️ Sensors not enforcing policies:** $($nonEnforcingSensors.Count)`n`n" + } + + # Build table rows - show problematic sensors first + $allSensors = @() + $allSensors += $nonEnforcingSensors | ForEach-Object { $_ | Add-Member -NotePropertyName 'Priority' -NotePropertyValue 1 -PassThru -Force } + $allSensors += $enforcingSensors | ForEach-Object { $_ | Add-Member -NotePropertyName 'Priority' -NotePropertyValue 2 -PassThru -Force } + + $tableRows = $allSensors | Sort-Object -Property Priority, machineName | ForEach-Object { + $statusIcon = if ($_.status -eq 'active') { '✅' } else { '❌' } + $auditModeIcon = if ($_.isAuditMode) { '⚠️ Yes' } else { '✅ No' } + $machineName = Get-SafeMarkdown $_.machineName + $version = Get-SafeMarkdown $_.version + $externalIp = Get-SafeMarkdown $_.externalIp + + "| $machineName | $statusIcon $($_.status) | $auditModeIcon | $version | $externalIp |" + } + + $mdInfo += @' +| Machine name | Status | Audit mode | Version | External IP | +| :----------- | :----- | :--------- | :------ | :---------- | +{0} + +'@ -f ($tableRows -join "`n") + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + $params = @{ + TestId = '25403' + Title = 'DC Agent is deployed and enforcing strong authentication policies' + Status = $passed + Result = $testResultMarkdown + } + + 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..46d062e1e4 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25408.ps1 @@ -0,0 +1,219 @@ +<# +.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() + + # Define constants + [int]$BASELINE_PROFILE_PRIORITY = 65000 + + #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: Get all enabled Conditional Access policies with session controls + $allCAPolicies = Get-ZtConditionalAccessPolicy + $enabledCAPolicies = $allCAPolicies | Where-Object { $_.state -eq 'enabled' } + #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 using shared helper + foreach ($wcfPolicy in $wcfPolicies) { + $policyId = $wcfPolicy.id + $policyName = $wcfPolicy.name + $policyAction = $wcfPolicy.action + + # Use shared helper function to find profiles linked to this policy + $findParams = @{ + PolicyId = $policyId + FilteringProfiles = $filteringProfiles + CAPolicies = $enabledCAPolicies + BaselinePriority = $BASELINE_PROFILE_PRIORITY + PolicyLinkType = 'filteringPolicyLink' + PolicyRules = @() + } + $linkedProfiles = Find-ZtProfilesLinkedToPolicy @findParams + + # Filter for enabled profiles and policy links, then check pass criteria + $appliedProfiles = $linkedProfiles | Where-Object { + $_.PolicyLinkState -eq 'enabled' -and + $_.ProfileState -eq 'enabled' -and + $_.PassesCriteria -eq $true + } + + # If this policy is applied through at least one profile, add it to applied policies + if ($appliedProfiles) { + # Convert to format expected by report generation + $formattedProfiles = @() + foreach ($profileInfo in $appliedProfiles) { + $formattedProfiles += [PSCustomObject]@{ + ProfileId = $profileInfo.ProfileId + ProfileName = $profileInfo.ProfileName + ProfileType = $profileInfo.ProfileType + ProfileState = $profileInfo.ProfileState + ProfilePriority = $profileInfo.ProfilePriority + PolicyLinkState = $profileInfo.PolicyLinkState + IsApplied = $true + CAPolicy = $profileInfo.CAPolicy + } + } + + $appliedPolicies += [PSCustomObject]@{ + PolicyId = $policyId + PolicyName = $policyName + PolicyAction = $policyAction + LinkedProfiles = $formattedProfiles + } + } + } + + # 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.25409.md b/src/powershell/tests/Test-Assessment.25409.md new file mode 100644 index 0000000000..45a3d6629c --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25409.md @@ -0,0 +1,10 @@ +Without web content filtering policies based on website categories, users can freely access potentially malicious or inappropriate websites regardless of their location. Threat actors often leverage compromised or malicious websites across various categories to distribute malware, launch phishing campaigns, or establish command and control channels. When users navigate to these sites without category-based filtering, their devices can become infected with malware that establishes persistence mechanisms. Threat actors can then use these compromised endpoints to move laterally within the network, escalate privileges, and exfiltrate sensitive organizational data. Additionally, without categorization-based controls, organizations lack visibility into user browsing patterns that could indicate compromised accounts or insider threats. Web content filtering provides defense in depth by blocking entire categories of risky websites at the network edge before traffic reaches user endpoints, preventing initial access and reducing the attack surface across all internet-connected devices whether on or off the corporate network. + +**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) - Guide to create and manage web content filtering policies +- [Configure security profiles](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-web-content-filtering#create-a-security-profile) - Guide to create and manage security profiles that group filtering policies +- [Link security profiles to Conditional Access](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-configure-web-content-filtering#create-and-link-conditional-access-policy) - Instructions for delivering security profiles through Conditional Access session controls + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25409.ps1 b/src/powershell/tests/Test-Assessment.25409.ps1 new file mode 100644 index 0000000000..ee823e3ec6 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25409.ps1 @@ -0,0 +1,236 @@ +<# +.SYNOPSIS + Validates that web content filtering policies based on website categories are configured in Global Secure Access. + +.DESCRIPTION + This test checks if web content filtering policies using website categories (webCategory ruleType) are configured + and applied either through the Baseline Profile or through security profiles linked to active Conditional Access policies. + +.NOTES + Test ID: 25409 + Category: Global Secure Access + Required API: networkAccess/filteringProfiles, networkAccess/filteringPolicies, conditionalAccess/policies (beta) +#> + +function Test-Assessment-25409 { + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'Medium', + MinimumLicense = ('Entra_Premium_Internet_Access'), + Pillar = 'Network', + RiskLevel = 'Medium', + SfiPillar = 'Protect networks', + TenantType = ('Workforce', 'External'), + TestId = 25409, + Title = 'Global Secure Access Web content filtering controls internet access based on website categories', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + # Define constants + [int]$BASELINE_PROFILE_PRIORITY = 65000 + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Checking Global Secure Access web content filtering by website categories' + Write-ZtProgress -Activity $activity -Status 'Querying Web Content Filtering policies' + + # Q1: Get all Web Content Filtering policies (excluding "All Websites") + try { + $allFilteringPolicies = Invoke-ZtGraphRequest -RelativeUri 'networkAccess/filteringPolicies' -ApiVersion beta -ErrorAction Stop + $wcfPolicies = $allFilteringPolicies | Where-Object { $_.name -ne 'All websites' } + } + catch { + Write-PSFMessage "Failed to retrieve filtering policies: $_" -Tag Test -Level Warning + $wcfPolicies = @() + } + + Write-ZtProgress -Activity $activity -Status 'Querying filtering profiles' + + # Q2: Get all filtering profiles with their policies and priority + try { + $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 -ErrorAction Stop + } + catch { + Write-PSFMessage "Failed to retrieve filtering profiles: $_" -Tag Test -Level Warning + $filteringProfiles = @() + } + + 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 + $policiesWithWebCategory = @() + + # 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 { + # Per spec: Check if webCategory policies exist in Baseline Profile or Security Profiles with enabled CA + foreach ($wcfPolicy in $wcfPolicies) { + $policyId = $wcfPolicy.id + $policyName = $wcfPolicy.name + + # Get full policy details with rules to check for webCategory + $policyDetails = Invoke-ZtGraphRequest -RelativeUri "networkAccess/filteringPolicies/$policyId`?`$select=id,name,version&`$expand=policyRules" -ApiVersion beta + $webCategoryRules = @($policyDetails.policyRules) | Where-Object { $_.ruleType -eq 'webCategory' } + + # Skip if no webCategory rules + if (-not $webCategoryRules) { + continue + } + + # Find profiles that have this policy linked using shared helper function + $findParams = @{ + PolicyId = $policyId + FilteringProfiles = $filteringProfiles + CAPolicies = $caPolicies + BaselinePriority = $BASELINE_PROFILE_PRIORITY + PolicyLinkType = 'filteringPolicyLink' + PolicyRules = $webCategoryRules + } + $linkedProfiles = Find-ZtProfilesLinkedToPolicy @findParams + + # Check if any linked profile passes criteria + $profilePasses = $linkedProfiles | Where-Object { $_.PassesCriteria -eq $true } + if ($profilePasses) { + $passed = $true + } + + # Add policy with its linked profiles to collection + if ($linkedProfiles.Count -gt 0) { + $policiesWithWebCategory += [PSCustomObject]@{ + PolicyId = $policyId + PolicyName = $policyName + LinkedProfiles = $linkedProfiles + } + } + } + + # Determine status message based on pass/fail + if ($passed) { + $testResultMarkdown = "✅ Web content filtering with web category controls is configured and applied through either the Baseline Profile or a security profile linked to an active Conditional Access policy. `n`n%TestResult%" + } + else { + $testResultMarkdown = "❌ No policies using web category filtering were found in the Baseline Profile or in security profiles linked to active Conditional Access policies. `n`n%TestResult%" + } + } + #endregion Assessment Logic + + #region Report Generation + # Build detailed markdown information + $mdInfo = '' + + if ($policiesWithWebCategory.Count -gt 0) { + # Table 1: Filtering Policies with Web Category Rules + $mdInfo += "`n## Filtering Policies with Web Category Rules`n`n" + $mdInfo += "| Profile type | Profile name | Policy name | Rule name | Web categories | State |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- |`n" + + foreach ($wcfPolicy in $policiesWithWebCategory | Sort-Object -Property PolicyName) { + $safePolicyName = Get-SafeMarkdown $wcfPolicy.PolicyName + + foreach ($profileInfo in $wcfPolicy.LinkedProfiles) { + $safeProfileName = Get-SafeMarkdown $profileInfo.ProfileName + $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)" + + $encodedPolicyName = [System.Uri]::EscapeDataString($wcfPolicy.PolicyName) + $policyBladeLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditFilteringPolicyMenuBlade.MenuView/~/Basics/policyId/$($wcfPolicy.PolicyId)/title/$encodedPolicyName/defaultMenuItemId/Basics" + $policyNameWithLink = "[$safePolicyName]($policyBladeLink)" + + # Process each webCategory rule + foreach ($rule in $profileInfo.PolicyRules) { + $safeRuleName = Get-SafeMarkdown $rule.name + $webCategories = ($rule.destinations | ForEach-Object { $_.displayName }) -join ', ' + $safeWebCategories = Get-SafeMarkdown $webCategories + + # Show state with indicator + $stateDisplay = if ($policyLinkState -eq 'enabled') { '✅ Enabled' } else { '❌ Disabled' } + + $mdInfo += "| $($profileInfo.ProfileType) | $profileNameWithLink | $policyNameWithLink | $safeRuleName | $safeWebCategories | $stateDisplay |`n" + } + } + } + + # Table 2: Conditional Access Linkages (for Security Profiles only) + $securityProfiles = $policiesWithWebCategory.LinkedProfiles | Where-Object { $_.ProfileType -eq 'Security Profile' -and $null -ne $_.CAPolicy } + if ($securityProfiles.Count -gt 0) { + $mdInfo += "`n## Conditional Access Linkages (for Security Profiles only)`n`n" + $mdInfo += "| CA policy name | Security profile name | CA policy state |`n" + $mdInfo += "| :--- | :--- | :--- |`n" + + # Build unique CA linkages + $uniqueCALinks = @{} + foreach ($policy in $policiesWithWebCategory) { + foreach ($profileInfo in $policy.LinkedProfiles) { + if ($profileInfo.ProfileType -eq 'Security Profile' -and $null -ne $profileInfo.CAPolicy -and $profileInfo.CAPolicy.Count -gt 0) { + foreach ($caPolicy in $profileInfo.CAPolicy) { + $key = "$($profileInfo.ProfileId)|$($caPolicy.id)" + if (-not $uniqueCALinks.ContainsKey($key)) { + $uniqueCALinks[$key] = [PSCustomObject]@{ + ProfileName = $profileInfo.ProfileName + ProfileId = $profileInfo.ProfileId + CAPolicyName = $caPolicy.displayName + CAPolicyId = $caPolicy.id + CAPolicyState = $caPolicy.state + } + } + } + } + } + } + + foreach ($item in $uniqueCALinks.Values | Sort-Object CAPolicyName, ProfileName) { + $safeProfileName = Get-SafeMarkdown $item.ProfileName + $safeCAPolicyName = Get-SafeMarkdown $item.CAPolicyName + + $caPolicyPortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($item.CAPolicyId)" + $caPolicyNameWithLink = "[$safeCAPolicyName]($caPolicyPortalLink)" + + $profilePortalLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditProfileMenuBlade.MenuView/~/basics/profileId/$($item.ProfileId)" + $profileNameWithLink = "[$safeProfileName]($profilePortalLink)" + + # Show actual state with indicator + $caPolicyState = if ($item.CAPolicyState -eq 'enabled') { '✅ Enabled' } else { '❌ Disabled' } + + $mdInfo += "| $caPolicyNameWithLink | $profileNameWithLink | $caPolicyState |`n" + } + } + + # Add portal links at the end + $mdInfo += "`n### Portal links`n`n" + $mdInfo += "- [Web content filtering policies](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/WebFilteringPolicy.ReactView)`n" + $mdInfo += "- [Security profiles](https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/FilteringPolicyProfiles.ReactView)`n" + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + $params = @{ + TestId = '25409' + Title = 'Web content filtering with website categories is configured' + Status = $passed + Result = $testResultMarkdown + } + + # Add test result details + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.25411.md b/src/powershell/tests/Test-Assessment.25411.md new file mode 100644 index 0000000000..60efcfd519 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25411.md @@ -0,0 +1,9 @@ +TLS Inspection empowers Microsoft Entra Global Secure Access to securely interpret encrypted traffic, enabling advanced protection. While basic Web Content Filtering works without it, TLS Inspection is required for many Internet Access features like URL Filtering. By decrypting and inspecting TLS sessions, organizations gain deeper visibility and control, ensuring policies are applied effectively to safeguard users and data. + +**Remediation action** + +Please check the article below for guidance on configuring the TLS Inspection Policy. +- [Configure Transport Layer Security Inspection Policies - Global Secure Access | Microsoft Learn](https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-transport-layer-security) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25411.ps1 b/src/powershell/tests/Test-Assessment.25411.ps1 new file mode 100644 index 0000000000..caa35b3f61 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25411.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + TLS inspection is enabled and correctly configured for outbound traffic in Global Secure Access. +.DESCRIPTION + Verifies that a TLS Inspection policy is properly configured. It will fail if no TLS Inspection policy exists, if the policy is not linked to a Security Profile, or if no Conditional Access policy assigning that Security Profile can be identified. +#> + +function Test-Assessment-25411 { + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'High', + MinimumLicense = ('Entra_Premium_Internet_Access'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce'), + TestId = 25411, + Title = 'TLS inspection is enabled and correctly configured for outbound traffic in Global Secure Access', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + # Define constants + [int]$BASELINE_PROFILE_PRIORITY = 65000 + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'TLS inspection is enabled and correctly configured for outbound traffic in Global Secure Access.' + Write-ZtProgress -Activity $activity -Status 'Querying TLS inspection policies' + + # Step 1: Get TLS Inspection policies + $tlsInspectionPolicies = Invoke-ZtGraphRequest -RelativeUri 'networkAccess/tlsInspectionPolicies' -ApiVersion beta + + # Step 2: List all policies in the Baseline Profile and in each Security Profile + Write-ZtProgress -Activity $activity -Status 'Querying filtering profiles and policies' + $filteringProfiles = Invoke-ZtGraphRequest -RelativeUri 'networkAccess/filteringProfiles' -QueryParameters @{ + '$select' = 'id,name,description,state,version,priority' + '$expand' = 'policies($select=id,state;$expand=policy($select=id,name,version)),conditionalAccessPolicies($select=id,displayName)' + } -ApiVersion beta + + # Query all Conditional Access policies with details + Write-ZtProgress -Activity $activity -Status 'Querying Conditional Access policies' + $allCAPolicies = Get-ZtConditionalAccessPolicy + + #endregion Data Collection + + #region Data Processing + # Graph responses are automatically unwrapped by Invoke-ZtGraphRequest + $enabledSecurityProfiles = @() + $enabledBaseLineProfiles = @() + + # Iterate each TLS inspection policy and find linked profiles using the helper function + foreach ($tlsPolicy in $tlsInspectionPolicies) { + $findParams = @{ + PolicyId = $tlsPolicy.id + FilteringProfiles = $filteringProfiles + CAPolicies = $allCAPolicies + BaselinePriority = $BASELINE_PROFILE_PRIORITY + PolicyLinkType = 'tlsInspectionPolicyLink' + PolicyRules = $tlsPolicy + } + + $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) + } + + $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' + } + } + } + } + } + + #endregion Data Processing + #region Assessment logic + + $testResultMarkdown = '' + $passed = $false + $mdInfo = '' + + if ($null -eq $tlsInspectionPolicies -or $tlsInspectionPolicies.Count -eq 0) { + $testResultMarkdown = "❌ TLS Inspection Policy has not been properly configured. `n`n%TestResult%" + $passed = $false + } + elseif ($enabledBaseLineProfiles.Count -gt 0) { + $testResultMarkdown = "✅ TLS Inspection Policy is enabled and properly configured to inspect encrypted outbound traffic.`n`n%TestResult%" + $passed = $true + } + elseif ($enabledSecurityProfiles.Count -gt 0) { + $testResultMarkdown = "✅ TLS Inspection Policy is enabled and properly configured to inspect encrypted outbound traffic.`n`n%TestResult%" + $passed = $true + } + else { + $testResultMarkdown = "❌ TLS Inspection Policy has not been properly configured.`n`n%TestResult%" + $passed = $false + } + + #endregion Assessment logic + + #region Report Generation + + if ($enabledBaseLineProfiles.Count -gt 0) { + + $mdInfo += "`n## TLS Inspection Policies Linked to Baseline Profiles`n`n" + $mdInfo += "| Linked Profile Name | Linked Profile Priority | Linked Policy Name | Policy Link State | Profile State |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- |`n" + foreach ($policy in $enabledBaseLineProfiles) { + $baseLineProfilePortalLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditProfileMenuBlade.MenuView/~/basics/profileId/$(($policy.ProfileId))" + $tlsPolicyPortalLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditTlsInspectionPolicyMenuBlade.MenuView/~/basics/policyId/$(($policy.TLSPolicyId))" + $profileName = Get-SafeMarkdown -Text $policy.ProfileName + $profilePriority = $policy.ProfilePriority + $tlsPolicyName = Get-SafeMarkdown -Text $policy.TLSPolicyName + $tlsPolicyLinkState = $policy.TLSPolicyLinkState + $profileState = $policy.ProfileState + $mdInfo += "| [$profileName]($baseLineProfilePortalLink) | $profilePriority | [$tlsPolicyName]($tlsPolicyPortalLink) | $tlsPolicyLinkState | $profileState |`n" + } + } + + if ($enabledSecurityProfiles.Count -gt 0) { + $mdInfo += "`n## Security Profiles Linked to Conditional Access Policies`n`n" + $mdInfo += "| Linked Profile Name | Linked Profile Priority | CA Policy Names | CA Policy State | Profile State | TLS Inspection Policy Name | Default Action |`n" + $mdInfo += "| :--- | :--- | :--- | :--- | :--- | :--- | :--- |`n" + foreach ($enabledProfile in $enabledSecurityProfiles) { + $securityProfilePortalLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditProfileMenuBlade.MenuView/~/basics/profileId/$(($enabledProfile.ProfileId))" + $tlsPolicyPortalLink = "https://entra.microsoft.com/#view/Microsoft_Azure_Network_Access/EditTlsInspectionPolicyMenuBlade.MenuView/~/basics/policyId/$(($enabledProfile.TLSPolicyId))" + $profileName = Get-SafeMarkdown -Text $enabledProfile.ProfileName + $profilePriority = $enabledProfile.ProfilePriority + # Build CA policy links + $caPolicyLinksMarkdown = @() + $caPolicyStatesList = @() + foreach ($caPolicy in $enabledProfile.MatchedCAPolicies) { + $caPolicyPortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($caPolicy.Id)" + $safeName = Get-SafeMarkdown -Text $caPolicy.DisplayName + $caPolicyLinksMarkdown += "[$safeName]($caPolicyPortalLink)" + $caPolicyStatesList += $caPolicy.State + } + $caPolicyNamesLinked = $caPolicyLinksMarkdown -join ', ' + $caPolicyStates = $caPolicyStatesList -join ', ' + $profileState = $enabledProfile.ProfileState + $tlsPolicyName = Get-SafeMarkdown -Text $enabledProfile.TLSPolicyName + $defaultAction = $enabledProfile.DefaultAction + $mdInfo += "| [$profileName]($securityProfilePortalLink) | $profilePriority | $caPolicyNamesLinked | $caPolicyStates | $profileState | [$tlsPolicyName]($tlsPolicyPortalLink) | $defaultAction |`n" + } + } + + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + #endregion Report Generation + + + $params = @{ + TestId = '25411' + Status = $passed + Result = $testResultMarkdown + } + + # Add test result details + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.25533.md b/src/powershell/tests/Test-Assessment.25533.md new file mode 100644 index 0000000000..3afeea2096 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.md @@ -0,0 +1,10 @@ +DDoS attacks remain a major security and availability risk for customers with cloud-hosted applications. These attacks aim to overwhelm an application's compute, network, or memory resources, rendering it inaccessible to legitimate users. Any public-facing endpoint exposed to the internet can be a potential target for a DDoS attack. Azure DDoS Protection provides always-on monitoring and automatic mitigation against DDoS attacks targeting public-facing workloads. Without Azure DDoS Protection (Network Protection or IP Protection), public IP addresses for services such as Application Gateways, Load Balancers, or virtual machines remain exposed to DDoS attacks that can overwhelm network bandwidth, exhaust system resources, and cause complete service unavailability. These attacks can disrupt access for legitimate users, degrade performance, and create cascading outages across dependent services.Azure DDoS Protection uses adaptive real-time tuning to profile normal traffic patterns and automatically detects anomalies indicative of an attack. When an attack is identified, mitigation is engaged at the Azure network edge to absorb and filter malicious traffic before it reaches your applications. This check verifies that Azure DDoS Protection is enabled for public IP addresses within a VNET, ensuring that applications are protected from DDoS attacks. If this check does not pass, your workloads remain significantly more vulnerable to downtime, customer impact, and operational disruption during an attack. + +## Remediation Resources + +- [Please check the articles below for guidance on how to enable DDoS Protection for Public IP addresses.](https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-protection) +- [Please check the articles below for guidance on how to enable DDoS Protection for Public IP addresses.](https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-ip-protection-portal) + + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25533.ps1 b/src/powershell/tests/Test-Assessment.25533.ps1 new file mode 100644 index 0000000000..5fb91305dc --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25533.ps1 @@ -0,0 +1,324 @@ +function Test-Assessment-25533 { + [ZtTest( + Category = 'Azure Network Security', + ImplementationCost = 'Low', + MinimumLicense = ('DDoS_Network_Protection', 'DDoS_IP_Protection'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce', 'External'), + TestId = 25533, + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = "Checking DDoS Protection is enabled for all Public IP Addresses in VNETs" + Write-ZtProgress -Activity $activity -Status "Checking Azure connection" + + # Check Azure connection + try { + $accessToken = Get-AzAccessToken -AsSecureString -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + catch { + Write-PSFMessage $_.Exception.Message -Tag Test -Level Error + } + + if (!$accessToken) { + Write-PSFMessage "Azure authentication token not found." -Level Warning + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + + # Get Azure subscriptions + Write-ZtProgress -Activity $activity -Status "Getting Azure subscriptions" + + $resourceManagementUrl = (Get-AzContext).Environment.ResourceManagerUrl + $subscriptionsUri = $resourceManagementUrl + 'subscriptions?api-version=2022-12-01' + + try { + $subscriptionsResponse = Invoke-AzRestMethod -Method GET -Uri $subscriptionsUri -ErrorAction Stop + + # Check if the response was successful + if ($subscriptionsResponse.StatusCode -lt 200 -or $subscriptionsResponse.StatusCode -ge 300) { + throw "API returned status code: $($subscriptionsResponse.StatusCode). Content: $($subscriptionsResponse.Content)" + } + + $subscriptions = ($subscriptionsResponse.Content | ConvertFrom-Json).value + } + catch { + Write-PSFMessage "Failed to retrieve Azure subscriptions: $_" -Level Error + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + + if (!$subscriptions -or $subscriptions.Count -eq 0) { + Write-PSFMessage "No Azure subscriptions found." -Level Warning + $customStatus = 'Investigate' + + } + + $publicIpFindings = @() + + foreach ($sub in $subscriptions) { + Write-ZtProgress -Activity $activity -Status "Processing subscription: $($sub.displayName)" + + $publicIpsUri = $resourceManagementUrl + "subscriptions/$($sub.subscriptionId)/providers/Microsoft.Network/publicIPAddresses?api-version=2025-03-01" + + try { + $publicIpsResponse = Invoke-AzRestMethod -Method GET -Uri $publicIpsUri -ErrorAction Stop + + # Check if the response was successful + if ($publicIpsResponse.StatusCode -lt 200 -or $publicIpsResponse.StatusCode -ge 300) { + Write-PSFMessage "Unable to list Public IPs in subscription $($sub.displayName) - Status Code: $($publicIpsResponse.StatusCode)" -Tag Test -Level Warning + continue + } + + $publicIps = ($publicIpsResponse.Content | ConvertFrom-Json).value + } + catch { + Write-PSFMessage "Unable to list Public IPs in subscription $($sub.displayName): $_" -Tag Test -Level Warning + continue + } + + foreach ($pip in $publicIps) { + $protectionMode = 'Disabled' + + # Check ddosSettings property as per API documentation + if ($pip.properties.ddosSettings -and $pip.properties.ddosSettings.protectionMode) { + $protectionMode = $pip.properties.ddosSettings.protectionMode + } + + # Determine compliance: Pass if VirtualNetworkInherited or Enabled + # Fail if Disabled or missing + $isCompliant = $protectionMode -in @('VirtualNetworkInherited', 'Enabled') + + $publicIpFindings += [PSCustomObject]@{ + PublicIpName = $pip.name + PublicIpId = $pip.id + Location = $pip.location + ProtectionMode = $protectionMode + IsCompliant = $isCompliant + SubscriptionId = $sub.subscriptionId + SubscriptionName = $sub.displayName + } + } + } + #endregion Data Collection + + #region Assessment Logic + # Initialize test variables + $testResultMarkdown = '' + $passed = $false + + # Check if no public IPs found + if ($publicIpFindings.Count -eq 0) { + Add-ZtTestResultDetail -TestId '25533' -Status $true -Result "No Public IP addresses found." + return + } + + # Determine pass/fail status + $nonCompliant = $publicIpFindings | Where-Object { -not $_.IsCompliant } + $passed = ($nonCompliant.Count -eq 0) + + if ($passed) { + $testResultMarkdown = "DDoS Protection is enabled for all Public IP addresses.`n`n%TestResult%" + } + else { + $testResultMarkdown = "DDoS Protection is not enabled for one or more Public IP addresses.`n`n%TestResult%" + } + #endregion Assessment Logic + + #region Report Generation + $mdInfo = "## Public IP Address DDoS Protection Details`n`n" + $mdInfo += "| | Public IP address name and id | DdosSettingsProtectionMode value |`n" + $mdInfo += "| :--- | :--- | :--- |`n" + + foreach ($item in $publicIpFindings | Sort-Object IsCompliant, PublicIpName) { + $icon = if ($item.IsCompliant) { '✅' } else { '❌' } + $pipLink = "https://portal.azure.com/#@/resource$($item.PublicIpId)" + $safeName = Get-SafeMarkdown -Text $item.PublicIpName + + $mdInfo += "| $icon | [$safeName]($pipLink) | ``$($item.ProtectionMode)`` |`n" + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + # Add test result details + + #endregion Report Generation + $params = @{ + TestId = '25533' + Status = $passed + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs' + Result = $testResultMarkdown + } + Add-ZtTestResultDetail @params +} + +function Test-Assessment-25533 { + [ZtTest( + Category = 'Azure Network Security', + ImplementationCost = 'Low', + MinimumLicense = ('DDoS_Network_Protection', 'DDoS_IP_Protection'), + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Protect networks', + TenantType = ('Workforce', 'External'), + TestId = 25533, + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = "Checking DDoS Protection is enabled for all Public IP Addresses in VNETs" + Write-ZtProgress -Activity $activity -Status "Checking Azure connection" + + # Check Azure connection + try { + $accessToken = Get-AzAccessToken -AsSecureString -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + catch { + Write-PSFMessage $_.Exception.Message -Tag Test -Level Error + } + + if (!$accessToken) { + Write-PSFMessage "Azure authentication token not found." -Level Warning + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + + # Get Azure subscriptions + Write-ZtProgress -Activity $activity -Status "Getting Azure subscriptions" + + $resourceManagementUrl = (Get-AzContext).Environment.ResourceManagerUrl + $subscriptionsUri = $resourceManagementUrl + 'subscriptions?api-version=2022-12-01' + + try { + $subscriptionsResponse = Invoke-AzRestMethod -Method GET -Uri $subscriptionsUri -ErrorAction Stop + + # Check if the response was successful + if ($subscriptionsResponse.StatusCode -ne 200) { + throw "API returned status code: $($subscriptionsResponse.StatusCode). Content: $($subscriptionsResponse.Content)" + } + + $subscriptions = ($subscriptionsResponse.Content | ConvertFrom-Json).value + } + catch { + Write-PSFMessage "Failed to retrieve Azure subscriptions: $_" -Level Error + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + + if (!$subscriptions -or $subscriptions.Count -eq 0) { + Write-PSFMessage "No Azure subscriptions found." -Level Warning + Add-ZtTestResultDetail -SkippedBecause NoAzureAccess + return + } + $publicIpFindings = @() + + foreach ($sub in $subscriptions) { + Write-ZtProgress -Activity $activity -Status "Processing subscription: $($sub.displayName)" + + $publicIpsUri = $resourceManagementUrl + "subscriptions/$($sub.subscriptionId)/providers/Microsoft.Network/publicIPAddresses?api-version=2025-03-01" + + try { + $publicIpsResponse = Invoke-AzRestMethod -Method GET -Uri $publicIpsUri -ErrorAction Stop + + # Check if the response was successful + if ($publicIpsResponse.StatusCode -ne 200) { + throw "API returned status code: $($publicIpsResponse.StatusCode). Content: $($publicIpsResponse.Content)" + } + else{ + $publicIps = ($publicIpsResponse.Content | ConvertFrom-Json).value + } + + + } + catch { + Write-PSFMessage "Unable to list Public IPs in subscription $($sub.displayName): $_" -Tag Test -Level Warning + continue + } + + foreach ($pip in $publicIps) { + $protectionMode = 'Disabled' + + # Check ddosSettings property as per API documentation + if ($pip.properties.ddosSettings -and $pip.properties.ddosSettings.protectionMode) { + $protectionMode = $pip.properties.ddosSettings.protectionMode + } + + # Determine compliance: Pass if VirtualNetworkInherited or Enabled + # Fail if Disabled or missing + $isCompliant = $protectionMode -in @('VirtualNetworkInherited', 'Enabled') + + $publicIpFindings += [PSCustomObject]@{ + PublicIpName = $pip.name + PublicIpId = $pip.id + Location = $pip.location + ProtectionMode = $protectionMode + IsCompliant = $isCompliant + SubscriptionId = $sub.subscriptionId + SubscriptionName = $sub.displayName + } + } + } + #endregion Data Collection + + #region Assessment Logic + # Initialize test variables + $testResultMarkdown = '' + $passed = $false + + # Check if no public IPs found + if ($publicIpFindings.Count -eq 0) { + Add-ZtTestResultDetail -TestId '25533' -Status $False -Result "No Public IP addresses found." + return + } + + # Determine pass/fail status + $nonCompliant = $publicIpFindings | Where-Object { -not $_.IsCompliant } + $passed = ($nonCompliant.Count -eq 0) + + if ($passed) { + $testResultMarkdown = "DDoS Protection is enabled for all Public IP addresses.`n`n%TestResult%" + } + else { + $testResultMarkdown = "DDoS Protection is not enabled for one or more Public IP addresses.`n`n%TestResult%" + } + #endregion Assessment Logic + + #region Report Generation + $mdInfo = "## Summary `n`n" + $mdInfo += "| | Public IP name | DdosSettings protection mode |`n" + $mdInfo += "| :--- | :--- | :--- |`n" + + foreach ($item in $publicIpFindings | Sort-Object IsCompliant, PublicIpName) { + $icon = if ($item.IsCompliant) { '✅' } else { '❌' } + $pipLink = "https://portal.azure.com/#@/resource$($item.PublicIpId)" + $safeName = Get-SafeMarkdown -Text $item.PublicIpName + + $mdInfo += "| $icon | [$safeName]($pipLink) | $($item.ProtectionMode) |`n" + } + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + # Add test result details + + #endregion Report Generation + $params = @{ + TestId = '25533' + Status = $passed + Title = 'DDoS Protection is enabled for all Public IP Addresses in VNETs' + Result = $testResultMarkdown + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.35006.md b/src/powershell/tests/Test-Assessment.35006.md new file mode 100644 index 0000000000..02f84d7e98 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35006.md @@ -0,0 +1,16 @@ +PDF files stored in SharePoint Online and OneDrive for Business require separate enablement of sensitivity label support beyond the base Office file integration. When `EnableSensitivityLabelforPDF` is disabled, organizations create a protection gap where PDF documents remain unclassified and unprotected despite sensitivity label policies being active for Office files. + +**Remediation action** + +To enable PDF labeling support in SharePoint Online: +1. Verify base integration is enabled: `Get-SPOTenant | Select-Object EnableAIPIntegration` (must be `$true`) +2. Connect to SharePoint Online: `Connect-SPOService -Url https://-admin.sharepoint.com` +3. Enable PDF labeling: `Set-SPOTenant -EnableSensitivityLabelforPDF $true` +4. Wait for propagation (typically immediate, but can take up to 1 hour) +5. Test by uploading a PDF to SharePoint and applying a label through Office for the web + +- [PDF support for sensitivity labels in SharePoint and OneDrive](https://learn.microsoft.com/purview/sensitivity-labels-sharepoint-onedrive-files#pdf-support) +- [Enable sensitivity labels for Office files in SharePoint and OneDrive](https://learn.microsoft.com/microsoft-365/compliance/sensitivity-labels-sharepoint-onedrive-files) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35006.ps1 b/src/powershell/tests/Test-Assessment.35006.ps1 new file mode 100644 index 0000000000..f961abe6d7 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35006.ps1 @@ -0,0 +1,88 @@ +<# +.SYNOPSIS + PDF Labeling Support in SharePoint Online + +.DESCRIPTION + PDF files stored in SharePoint Online and OneDrive for Business require separate enablement of sensitivity label support beyond the base Office file integration. When EnableSensitivityLabelforPDF is disabled, organizations create a protection gap where PDF documents remain unclassified and unprotected despite sensitivity label policies being active for Office files. + +.NOTES + Test ID: 35006 + Pillar: Data + Risk Level: Medium +#> + +function Test-Assessment-35006 { + [ZtTest( + Category = 'SharePoint Online', + ImplementationCost = 'Low', + MinimumLicense = ('MIP_P1'), + Pillar = 'Data', + RiskLevel = 'Medium', + SfiPillar = '', + TenantType = ('Workforce'), + TestId = 35006, + Title = 'PDF Labeling Support in SharePoint Online', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking PDF Labeling Support in SharePoint Online' + Write-ZtProgress -Activity $activity -Status 'Getting SharePoint Tenant Settings' + + $spoTenant = $null + $errorMsg = $null + + try { + # Query: Retrieve SharePoint Online tenant PDF labeling support status + $spoTenant = Get-SPOTenant -ErrorAction Stop + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying SharePoint Tenant Settings: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + if ($errorMsg) { + $passed = $false + } + else { + $passed = $null -ne $spoTenant -and $spoTenant.EnableSensitivityLabelforPDF -eq $true + } + #endregion Assessment Logic + + #region Report Generation + if ($errorMsg) { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to query SharePoint Tenant Settings due to error: $errorMsg" + } + else { + if ($passed) { + $testResultMarkdown = "✅ PDF labeling support is enabled in SharePoint Online and OneDrive, allowing users to classify and protect PDF files.`n`n" + } + else { + $testResultMarkdown = "❌ PDF labeling support is NOT enabled. PDF files cannot be labeled or protected in SharePoint and OneDrive.`n`n" + } + + $testResultMarkdown += "### SharePoint Online Configuration Summary`n`n" + $testResultMarkdown += "**Tenant Settings:**`n" + + $enableSensitivityLabelForPDF = if ($null -ne $spoTenant -and $spoTenant.EnableSensitivityLabelforPDF -eq $true) { "True" } else { "False" } + $testResultMarkdown += "* EnableSensitivityLabelforPDF: $enableSensitivityLabelForPDF`n" + + $testResultMarkdown += "`n[Manage information protection in SharePoint Admin Center](https://admin.microsoft.com/sharepoint?page=classicSettings&modern=true)`n" + } + #endregion Report Generation + + $params = @{ + TestId = '35006' + Title = 'PDF Labeling Support in SharePoint Online' + Status = $passed + Result = $testResultMarkdown + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.35007.md b/src/powershell/tests/Test-Assessment.35007.md new file mode 100644 index 0000000000..3936bf27c0 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35007.md @@ -0,0 +1,18 @@ +Information Rights Management (IRM) integration in SharePoint Online libraries is a legacy feature that has been replaced by Enhanced SharePoint Permissions (ESP). Any library using this legacy capability should be flagged to move to newer capabilities. + +**Remediation action** + +To disable legacy IRM in SharePoint Online: +1. Identify libraries currently using IRM protection (audit existing sites) +2. Plan migration to modern sensitivity labels with encryption +3. Connect to SharePoint Online: `Connect-SPOService -Url https://-admin.sharepoint.com` +4. Disable legacy IRM: `Set-SPOTenant -IrmEnabled $false` +5. Enable modern sensitivity labels: `Set-SPOTenant -EnableAIPIntegration $true` +6. Configure and publish sensitivity labels with encryption to replace IRM policies + +- [Enable sensitivity labels for SharePoint and OneDrive](https://learn.microsoft.com/microsoft-365/compliance/sensitivity-labels-sharepoint-onedrive-files) +- [SharePoint IRM and sensitivity labels (migration guidance)](https://learn.microsoft.com/microsoft-365/compliance/sensitivity-labels-sharepoint-onedrive-files#sharepoint-information-rights-management-irm-and-sensitivity-labels) +- [Create and configure sensitivity labels with encryption](https://learn.microsoft.com/microsoft-365/compliance/encryption-sensitivity-labels) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35007.ps1 b/src/powershell/tests/Test-Assessment.35007.ps1 new file mode 100644 index 0000000000..e48569850f --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35007.ps1 @@ -0,0 +1,88 @@ +<# +.SYNOPSIS + Information Rights Management (IRM) Enabled in SharePoint Online + +.DESCRIPTION + Information Rights Management (IRM) integration in SharePoint Online libraries is a legacy feature that has been replaced by Enhanced SharePoint Permissions (ESP). Any library using this legacy capabilitiy should be flagged to move to newer capabilities. + +.NOTES + Test ID: 35007 + Pillar: Data + Risk Level: Low +#> + +function Test-Assessment-35007 { + [ZtTest( + Category = 'SharePoint Online', + ImplementationCost = 'Low', + MinimumLicense = ('Microsoft 365 E3'), + Pillar = 'Data', + RiskLevel = 'Low', + SfiPillar = '', + TenantType = ('Workforce'), + TestId = 35007, + Title = 'Information Rights Management (IRM) Enabled in SharePoint Online', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Information Rights Management (IRM) Status in SharePoint Online' + Write-ZtProgress -Activity $activity -Status 'Getting SharePoint Tenant Settings' + + $spoTenant = $null + $errorMsg = $null + + try { + # Query: Retrieve SharePoint Online tenant IRM enablement status + $spoTenant = Get-SPOTenant -ErrorAction Stop + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying SharePoint Tenant Settings: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + if ($errorMsg) { + $passed = $false + } + else { + $passed = $null -ne $spoTenant -and $spoTenant.IrmEnabled -ne $true + } + #endregion Assessment Logic + + #region Report Generation + if ($errorMsg) { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to query SharePoint Tenant Settings due to error: $errorMsg" + } + else { + if ($passed) { + $testResultMarkdown = "✅ Legacy IRM feature is disabled. Organizations should use modern sensitivity labels for document protection.`n`n" + } + else { + $testResultMarkdown = "❌ Legacy IRM feature is still enabled. Libraries may be using outdated protection mechanisms.`n`n" + } + + $testResultMarkdown += "### SharePoint Online Configuration Summary`n`n" + $testResultMarkdown += "**Tenant Settings:**`n" + + $irmEnabled = if ($null -ne $spoTenant -and $spoTenant.IrmEnabled -eq $true) { "True" } else { "False" } + $testResultMarkdown += "* IrmEnabled: $irmEnabled`n" + + $testResultMarkdown += "`n[Manage Information Rights Management (IRM) in SharePoint Admin Center](https://admin.microsoft.com/sharepoint?page=classicSettings&modern=true)`n" + } + #endregion Report Generation + + $params = @{ + TestId = '35007' + Title = 'Information Rights Management (IRM) Enabled in SharePoint Online' + Status = $passed + Result = $testResultMarkdown + } + Add-ZtTestResultDetail @params +} diff --git a/src/powershell/tests/Test-Assessment.35008.md b/src/powershell/tests/Test-Assessment.35008.md new file mode 100644 index 0000000000..9ff525e30a --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35008.md @@ -0,0 +1,18 @@ +SharePoint document libraries support configuring default sensitivity labels that automatically apply baseline protection to new or edited files that lack existing labels or have lower-priority labels. When the tenant-level capability `DisableDocumentLibraryDefaultLabeling` is enabled (set to `$true`), organizations block site administrators from establishing automatic baseline classification for document libraries. +Using default labels is a critical feature in organizations' auto-labeling strategy. + +**Remediation action** + +To enable the default sensitivity label capability for SharePoint document libraries: +1. Verify sensitivity labels are enabled for SharePoint: `(Get-SPOTenant).EnableAIPIntegration` (must be `$true`) +2. Connect to SharePoint Online: `Connect-SPOService -Url https://-admin.sharepoint.com` +3. Enable default library labeling capability (if disabled): `Set-SPOTenant -DisableDocumentLibraryDefaultLabeling $false` +4. Wait approximately 15 minutes for tenant-level change to propagate +5. Site admins can then configure default labels on individual libraries via library settings + +- [Configure a default sensitivity label for a SharePoint document library](https://learn.microsoft.com/microsoft-365/compliance/sensitivity-labels-sharepoint-default-label) +- [Add a sensitivity label to SharePoint document library](https://support.microsoft.com/office/54b1602b-db0a-4bcb-b9ac-5e20cbc28089) +- [Enable sensitivity labels for SharePoint and OneDrive](https://learn.microsoft.com/microsoft-365/compliance/sensitivity-labels-sharepoint-onedrive-files) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35008.ps1 b/src/powershell/tests/Test-Assessment.35008.ps1 new file mode 100644 index 0000000000..820947ea7e --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35008.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + SPO Default Document Library Label (Tenant-Wide) + +.DESCRIPTION + SharePoint document libraries support configuring default sensitivity labels that automatically apply baseline protection to new or edited files that lack existing labels or have lower-priority labels. When the tenant-level capability DisableDocumentLibraryDefaultLabeling is enabled (set to $true), organizations block site administrators from establishing automatic baseline classification for document libraries. + +.NOTES + Test ID: 35008 + Pillar: Data + Risk Level: Medium +#> + +function Test-Assessment-35008 { + [ZtTest( + Category = 'SharePoint Online', + ImplementationCost = 'Low', + MinimumLicense = ('Microsoft 365 E5'), + Pillar = 'Data', + RiskLevel = 'Medium', + SfiPillar = '', + TenantType = ('Workforce'), + TestId = 35008, + Title = 'SPO Default Document Library Label (Tenant-Wide)', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking SPO Default Document Library Label Capability' + Write-ZtProgress -Activity $activity -Status 'Getting SharePoint Tenant Settings' + + $spoTenant = $null + $errorMsg = $null + + try { + # Query: Retrieve SharePoint tenant setting for document library default labeling capability + $spoTenant = Get-SPOTenant -ErrorAction Stop + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying SharePoint Tenant Settings: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + if ($errorMsg) { + $passed = $false + } + else { + if ($null -ne $spoTenant -and $spoTenant.DisableDocumentLibraryDefaultLabeling -eq $true) { + $passed = $false + } + else { + $passed = $true + } + } + #endregion Assessment Logic + + #region Report Generation + if ($errorMsg) { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to query SharePoint Tenant Settings due to error: $errorMsg" + } + else { + if ($passed) { + $testResultMarkdown = "✅ Default sensitivity label capability is enabled for SharePoint document libraries, allowing automatic baseline labeling.`n`n" + } + else { + $testResultMarkdown = "❌ Default sensitivity label capability is DISABLED. Site admins cannot configure library-level default labels.`n`n" + } + + $testResultMarkdown += "### SharePoint Online Configuration Summary`n`n" + $testResultMarkdown += "**Tenant Settings:**`n" + + $disableDocumentLibraryDefaultLabeling = if ($spoTenant.DisableDocumentLibraryDefaultLabeling) { "True" } else { "False" } + $testResultMarkdown += "* DisableDocumentLibraryDefaultLabeling: $disableDocumentLibraryDefaultLabeling`n" + } + #endregion Report Generation + + $testResultDetail = @{ + TestId = '35008' + Title = 'SPO Default Document Library Label (Tenant-Wide)' + Status = $passed + Result = $testResultMarkdown + } + Add-ZtTestResultDetail @testResultDetail +} 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.35021.md b/src/powershell/tests/Test-Assessment.35021.md new file mode 100644 index 0000000000..e9df490e58 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35021.md @@ -0,0 +1,32 @@ +SharePoint sites and OneDrive accounts are the primary repositories for unstructured file content in Microsoft 365, containing diverse document types, spreadsheets, presentations, and business intelligence files. + +Without auto-labeling policies specifically targeting these locations, organizations cannot automatically classify files based on their content or sensitive information types, leaving sensitive data vulnerable. Files uploaded to SharePoint and OneDrive without automatic classification become invisible to data loss prevention (DLP) policies that rely on label detection, allowing sensitive information to circulate uncontrolled and potentially be shared externally or accessed by unauthorized users. Manual classification by users is inconsistent and unreliable due to user oversight, lack of awareness about what constitutes sensitive data, or time constraints in daily workflows. + +Auto-labeling policies deployed in enforcement mode (not simulation) for SharePoint and OneDrive locations actively scan new and modified files, automatically applying sensitivity labels when the policy conditions are met. Implementing at least one auto-labeling policy in enforcement mode for these locations ensures that file-based sensitive data is consistently classified at the point of creation or modification, enabling downstream data protection controls like DLP rules, access restrictions, and audit logging to function effectively across the organization's file-sharing infrastructure. + +**Remediation action** + +**Remediation Steps:** +1. Navigate to [Auto-labeling policies](https://purview.microsoft.com/informationprotection/autolabeling) in Microsoft Purview +2. Assess existing policies targeting SharePoint/OneDrive +3. If policies exist in simulation mode, review statistics and transition to enforcement +4. If no policies exist for these workloads, create a new policy +5. Select SharePoint and/or OneDrive as target locations +6. Choose file-based sensitive information types +7. Select appropriate label and test in simulation mode +8. Activate enforcement mode after validation + +**Best Practices:** +- Prioritize file-based sensitive data types (financial records, healthcare information, trade secrets) over email-specific types when targeting SharePoint/OneDrive +- Start with high-confidence SITs (credit card numbers, SSNs, bank account numbers) to minimize false positives in file content +- Test file-based policies thoroughly in simulation mode; false positives in files are more visible to end users than in email +- Combine SharePoint/OneDrive auto-labeling with DLP rules that detect the applied labels for additional file protection +- Monitor OneDrive auto-labeling separately from SharePoint—OneDrive policies have different scan schedules and may catch content before SharePoint +- Document the sensitive data types and business justification for each SharePoint/OneDrive policy to support audit and compliance reporting + +**Learn More:** +- [Apply sensitivity labels automatically for SharePoint and OneDrive](https://learn.microsoft.com/en-us/purview/apply-sensitivity-label-automatically?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Sensitive information types entity reference](https://learn.microsoft.com/en-us/purview/sensitive-information-type-entity-definitions?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Create and configure auto-labeling policies](https://learn.microsoft.com/en-us/purview/apply-sensitivity-label-automatically?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35021.ps1 b/src/powershell/tests/Test-Assessment.35021.ps1 new file mode 100644 index 0000000000..2f7a4c54a7 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35021.ps1 @@ -0,0 +1,196 @@ +<# +.SYNOPSIS + Auto-Labeling Policies Enabled for SharePoint and OneDrive + +.DESCRIPTION + SharePoint sites and OneDrive accounts are the primary repositories for unstructured file content in Microsoft 365. Without auto-labeling policies specifically targeting these locations, organizations cannot automatically classify files based on their content or sensitive information types, leaving sensitive data vulnerable. Auto-labeling policies deployed in enforcement mode (not simulation) for SharePoint and OneDrive locations actively scan new and modified files, automatically applying sensitivity labels when the policy conditions are met. Implementing at least one auto-labeling policy in enforcement mode for SharePoint and OneDrive ensures that file-based sensitive data is consistently classified. + +.NOTES + Test ID: 35021 + Pillar: Data + Risk Level: High +#> + +function Test-Assessment-35021 { + [ZtTest( + Category = 'Information Protection', + ImplementationCost = 'Medium', + MinimumLicense = ('Microsoft 365 E5'), + Pillar = 'Data', + RiskLevel = 'High', + SfiPillar = 'Protect tenants and production systems', + TenantType = ('Workforce'), + TestId = 35021, + Title = 'Auto-Labeling Policies Enabled for SharePoint and OneDrive', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Auto-Labeling Policies for SharePoint and OneDrive' + Write-ZtProgress -Activity $activity -Status 'Getting auto-labeling policies' + + $errorMsg = $null + $allPolicies = @() + + try { + # Get all auto-labeling policies + $allPolicies = @(Get-AutoSensitivityLabelPolicy -ErrorAction Stop) + } + catch { + $errorMsg = $_ + Write-PSFMessage "Error querying auto-labeling policies: $_" -Level Error + } + #endregion Data Collection + + #region Assessment Logic + $customStatus = $null + $spodPolicies = @() + $enforcementPolicies = @() + + if ($errorMsg) { + $passed = $false + $customStatus = 'Investigate' + } + else { + # Filter for policies targeting SharePoint or OneDrive + foreach ($policy in $allPolicies) { + if ($policy.Workload) { + $workloadList = $policy.Workload -split ', ' | ForEach-Object { $_.Trim() } + if ($workloadList -contains 'SharePoint' -or $workloadList -contains 'OneDriveForBusiness') { + $spodPolicies += $policy + + # Check if enabled AND in enforcement mode + if ($policy.Enabled -eq $true -and $policy.Mode -eq 'Enable') { + $enforcementPolicies += $policy + } + } + } + } + + # Pass if at least one policy is enabled and in enforcement mode + $passed = $enforcementPolicies.Count -gt 0 + } + #endregion Assessment Logic + + #region Report Generation + if ($errorMsg) { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to determine SharePoint/OneDrive auto-labeling enforcement status due to error: $errorMsg" + } + else { + $policyLink = "https://purview.microsoft.com/informationprotection/autolabeling" + + if ($passed) { + $testResultMarkdown = "✅ $($enforcementPolicies.Count) auto-labeling $(if ($enforcementPolicies.Count -eq 1) { 'policy is' } else { 'policies are' }) enabled and in enforcement mode for SharePoint and/or OneDrive.`n`n" + + $testResultMarkdown += "### [Auto-Labeling Policies for SharePoint/OneDrive]($policyLink)`n`n" + $testResultMarkdown += "| Policy Name | Description | Enabled | Mode | Workload | Created | Last Modified |`n" + $testResultMarkdown += "| :--- | :--- | :---: | :--- | :--- | :--- | :--- |`n" + + foreach ($policy in $spodPolicies) { + $policyName = Get-SafeMarkdown -Text $policy.Name + $description = if ($policy.Comment) { Get-SafeMarkdown -Text $policy.Comment } else { '' } + $enabled = if ($policy.Enabled) { '✅' } else { '❌' } + $mode = if ($policy.Mode -eq 'Enable') { 'Enforcement' } elseif ($policy.Mode) { $policy.Mode } else { 'Unknown' } + $workload = if ($policy.Workload) { $policy.Workload } else { 'Not specified' } + $created = if ($policy.WhenCreatedUTC) { $policy.WhenCreatedUTC.ToString('yyyy-MM-dd') } else { 'Unknown' } + $lastModified = if ($policy.WhenChangedUTC) { $policy.WhenChangedUTC.ToString('yyyy-MM-dd') } else { 'Unknown' } + + $testResultMarkdown += "| $policyName | $description | $enabled | $mode | $workload | $created | $lastModified |`n" + } + + # Summary section + $testResultMarkdown += "`n### Summary`n`n" + $testResultMarkdown += "* **Total Policies Targeting SharePoint/OneDrive:** $($spodPolicies.Count)`n" + + # Count by status + $disabledCount = ($spodPolicies | Where-Object { $_.Enabled -eq $false }).Count + $simulationCount = ($spodPolicies | Where-Object { $_.Enabled -eq $true -and $_.Mode -ne 'Enable' }).Count + $enforcementCount = $enforcementPolicies.Count + + $testResultMarkdown += "* **Policies in Enforcement Mode:** $enforcementCount`n" + $testResultMarkdown += "* **Policies in Simulation Mode:** $simulationCount`n" + $testResultMarkdown += "* **Policies Disabled:** $disabledCount`n" + + # Check workload coverage from enforcement policies + $enforcementWorkloads = @() + foreach ($policy in $enforcementPolicies) { + if ($policy.Workload) { + $enforcementWorkloads += $policy.Workload -split ', ' | ForEach-Object { $_.Trim() } + } + } + $enforcementWorkloads = $enforcementWorkloads | Select-Object -Unique + + $hasSharePoint = $enforcementWorkloads -contains 'SharePoint' + $hasOneDrive = $enforcementWorkloads -contains 'OneDriveForBusiness' + + $testResultMarkdown += "* **SharePoint Coverage:** [$(if ($hasSharePoint) { 'Yes' } else { 'No' })]`n" + $testResultMarkdown += "* **OneDrive Coverage:** [$(if ($hasOneDrive) { 'Yes' } else { 'No' })]`n" + + # Date range for enforcement activation + $createdDates = $enforcementPolicies.WhenCreatedUTC | Where-Object { $_ } | Sort-Object + if ($createdDates) { + $oldest = $createdDates[0].ToString('yyyy-MM-dd') + $newest = $createdDates[-1].ToString('yyyy-MM-dd') + $testResultMarkdown += "* **Enforcement Activation Date Range:** $oldest to $newest`n" + } + } + else { + if ($spodPolicies.Count -eq 0) { + $testResultMarkdown = "❌ No auto-labeling policies are configured for SharePoint or OneDrive.`n`n" + } + else { + $testResultMarkdown = "❌ $($spodPolicies.Count) auto-labeling $(if ($spodPolicies.Count -eq 1) { 'policy targets' } else { 'policies target' }) SharePoint/OneDrive, but none are enabled and in enforcement mode.`n`n" + + $testResultMarkdown += "### [Auto-Labeling Policies for SharePoint/OneDrive]($policyLink)`n`n" + $testResultMarkdown += "| Policy Name | Description | Enabled | Mode | Workload | Created | Last Modified |`n" + $testResultMarkdown += "| :--- | :--- | :---: | :--- | :--- | :--- | :--- |`n" + + foreach ($policy in $spodPolicies) { + $policyName = Get-SafeMarkdown -Text $policy.Name + $description = if ($policy.Comment) { Get-SafeMarkdown -Text $policy.Comment } else { '' } + $enabled = if ($policy.Enabled) { '✅' } else { '❌' } + $mode = if ($policy.Mode -eq 'Enable') { 'Enforcement' } elseif ($policy.Mode) { $policy.Mode } else { 'Unknown' } + $workload = if ($policy.Workload) { $policy.Workload } else { 'Not specified' } + $created = if ($policy.WhenCreatedUTC) { $policy.WhenCreatedUTC.ToString('yyyy-MM-dd') } else { 'Unknown' } + $lastModified = if ($policy.WhenChangedUTC) { $policy.WhenChangedUTC.ToString('yyyy-MM-dd') } else { 'Unknown' } + + $testResultMarkdown += "| $policyName | $description | $enabled | $mode | $workload | $created | $lastModified |`n" + } + + # Summary section + $testResultMarkdown += "`n### Summary`n`n" + $testResultMarkdown += "* **Total Policies Targeting SharePoint/OneDrive:** $($spodPolicies.Count)`n" + + $disabledCount = ($spodPolicies | Where-Object { $_.Enabled -eq $false }).Count + $simulationCount = ($spodPolicies | Where-Object { $_.Enabled -eq $true -and $_.Mode -ne 'Enable' }).Count + + $testResultMarkdown += "* **Policies in Enforcement Mode:** 0`n" + $testResultMarkdown += "* **Policies in Simulation Mode:** $simulationCount`n" + $testResultMarkdown += "* **Policies Disabled:** $disabledCount`n" + $testResultMarkdown += "* **SharePoint Coverage:** [No]`n" + $testResultMarkdown += "* **OneDrive Coverage:** [No]`n" + } + + $testResultMarkdown += "`n### Recommendation`n`n" + $testResultMarkdown += "Enable at least one auto-labeling policy in enforcement mode for SharePoint and/or OneDrive to automatically classify sensitive files. " + $testResultMarkdown += "Visit the [Auto-labeling policies portal]($policyLink) to create or configure policies.`n" + } + } + #endregion Report Generation + + $params = @{ + TestId = '35021' + Title = 'Auto-Labeling Policies Enabled for SharePoint and OneDrive' + Status = $passed + Result = $testResultMarkdown + } + if ($customStatus) { + $params.CustomStatus = $customStatus + } + 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 +} diff --git a/src/powershell/tests/Test-Assessment.35033.md b/src/powershell/tests/Test-Assessment.35033.md new file mode 100644 index 0000000000..000de07dfd --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35033.md @@ -0,0 +1,37 @@ +Custom Sensitive Information Types (SITs) are organization-specific classification rules that detect patterns of sensitive data beyond the built-in SIT library. Custom SITs enable organizations to identify proprietary data formats, business-specific terminology, regulatory identifiers, or internal classification schemes that are unique to their industry or operations. By creating custom SITs, organizations can extend Microsoft Purview's data discovery capabilities to automatically detect and protect organization-specific sensitive information in auto-labeling policies, Data Loss Prevention (DLP) rules, and communication compliance monitoring. Custom SITs are particularly critical for organizations handling proprietary data formats, internal identifiers, specialized healthcare codes, financial account numbers, or regulatory compliance data that doesn't match standard built-in patterns. Without custom SITs, data protection mechanisms rely exclusively on generic patterns and may miss organization-specific sensitive information that requires targeted protection. + +**Remediation action** + +To create custom Sensitive Information Types: + +1. Sign in as a Global Administrator or Compliance Administrator to the [Microsoft Purview portal](https://purview.microsoft.com) +2. Navigate to Data Classification > Sensitive Info Types +3. Select "+ Create sensitive info type" to create a new custom SIT +4. Enter a name and description for your custom SIT +5. Define detection patterns: + - **Regex pattern**: Define a regular expression to match the data format + - **Keyword list**: Create a list of specific terms or identifiers to match + - **Supporting evidence**: Add additional patterns that provide confidence to the match +6. Set confidence level (Low, Medium, High) based on pattern specificity +7. Define character proximity for multi-pattern matching +8. Test the pattern with sample data to verify accuracy +9. Create and activate the custom SIT +10. Use the custom SIT in auto-labeling policies or DLP rules + +Example custom SIT patterns: +- **Internal Project Codes**: Regex pattern matching "PROJ-[0-9]{6}" format +- **Employee ID Numbers**: Regex pattern matching "EMP-[0-9]{8}" format +- **Healthcare Record Numbers**: Regex pattern matching proprietary medical record identifiers +- **Financial Account Numbers**: Regex pattern matching internal bank account formats +- **Regulatory Reference Numbers**: Keyword lists or patterns specific to industry compliance codes + +Alternatively, create via PowerShell: +1. Connect to Compliance & Security PowerShell: `Connect-IPPSSession` +2. Custom SITs cannot be created directly via PowerShell; use the portal for creation +3. Verify creation: `Get-DlpSensitiveInformationType -Filter "IsBuiltIn -eq $false"` + +- [Create and configure custom sensitive information types](https://learn.microsoft.com/en-us/purview/create-a-custom-sensitive-information-type) +- [Sensitive information types (SITs) reference](https://learn.microsoft.com/en-us/purview/sit-learn-about-exact-data-match-based-sits) +- [Regular expressions for custom SITs](https://learn.microsoft.com/en-us/purview/sensitive-information-type-entity-definitions) + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.35033.ps1 b/src/powershell/tests/Test-Assessment.35033.ps1 new file mode 100644 index 0000000000..390a90e177 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.35033.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Validates that custom Sensitive Information Types (SITs) are configured in the organization. + +.DESCRIPTION + This test checks if custom Sensitive Information Types are configured, enabling detection of + organization-specific sensitive data patterns beyond the built-in SIT library. Custom SITs are + critical for protecting proprietary data formats and industry-specific information. + +.NOTES + Test ID: 35033 + Category: Advanced Classification + Pillar: Data + Required Module: ExchangeOnlineManagement + Required Connection: Security & Compliance PowerShell +#> + +function Test-Assessment-35033 { + [ZtTest( + Category = 'Advanced Classification', + ImplementationCost = 'High', + MinimumLicense = ('Microsoft 365 E5 Compliance'), + Pillar = 'Data', + RiskLevel = 'High', + SfiPillar = 'Protect tenants and production systems', + TenantType = ('Workforce'), + TestId = 35033, + Title = 'Custom Sensitive Information Types (SITs) Configured', + UserImpact = 'Medium' + )] + [CmdletBinding()] + param() + + #region Data Collection + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + + $activity = 'Checking Custom Sensitive Information Types Configuration' + Write-ZtProgress -Activity $activity -Status 'Getting custom SIT configuration' + + # Get all custom Sensitive Information Types + $customSITs = $null + $errorMsg = $null + + try { + $allSITs = Get-DlpSensitiveInformationType -ErrorAction Stop + # Filter for custom SITs (Publisher is not "Microsoft Corporation") + $customSITs = @($allSITs | Where-Object { $_.Publisher -ne 'Microsoft Corporation' }) + } + catch { + $errorMsg = $_ + Write-PSFMessage "Failed to retrieve custom SIT configuration: $_" -Tag Test -Level Warning + } + #endregion Data Collection + + #region Assessment Logic + $passed = $false + $customStatus = $null + + if ($errorMsg) { + # Investigate: Cannot query custom SITs + $passed = $false + $customStatus = 'Investigate' + } + elseif ($null -eq $customSITs) { + # Investigate: Cannot determine custom SIT status + $passed = $false + $customStatus = 'Investigate' + } + elseif ($customSITs.Count -ge 1) { + # Pass: Custom SITs are configured + $passed = $true + } + else { + # Fail: No custom SITs configured + $passed = $false + } + #endregion Assessment Logic + + #region Report Generation + $testResultMarkdown = '' + + if ($customStatus -eq 'Investigate') { + $testResultMarkdown = "### Investigate`n`n" + $testResultMarkdown += "Unable to determine custom SIT status due to permissions issues or service connection failure." + } + elseif ($passed) { + $testResultMarkdown = "✅ Custom Sensitive Information Types are configured, enabling detection of organization-specific sensitive data patterns.`n`n" + } + else { + $testResultMarkdown = "❌ No custom Sensitive Information Types are configured; relying solely on built-in SIT patterns.`n`n" + } + + # Build detailed information if we have data + if ($customSITs -and $customSITs.Count -gt 0) { + $testResultMarkdown += "## [Custom Sensitive Information Types](https://purview.microsoft.com/informationprotection/dataclassification/sensinfoTypes)`n`n" + $testResultMarkdown += "| Name | Description | Publisher |`n" + $testResultMarkdown += "| :--- | :--- | :--- |`n" + + foreach ($sit in $customSITs | Sort-Object Name) { + $safeSITName = Get-SafeMarkdown $sit.Name + $safeDescription = if ($sit.Description) { Get-SafeMarkdown $sit.Description } else { 'Not specified' } + $safePublisher = if ($sit.Publisher) { Get-SafeMarkdown $sit.Publisher } else { 'Not specified' } + + $testResultMarkdown += "| $safeSITName | $safeDescription | $safePublisher |`n" + } + + $testResultMarkdown += "`n**Summary:**`n" + $testResultMarkdown += "* Total Custom SITs: $($customSITs.Count)`n" + } + #endregion Report Generation + + $params = @{ + TestId = '35033' + Title = 'Custom Sensitive Information Types (SITs) Configured' + Status = $passed + Result = $testResultMarkdown + } + if ($customStatus) { + $params.CustomStatus = $customStatus + } + Add-ZtTestResultDetail @params +}