From 7b26b118dc51b959f171d16bad737f09ab2dc8d7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:01:06 +0200 Subject: [PATCH 01/68] fix(compare): handle non-catalog null results --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 2 +- .../Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index d4a19075ac9e..fc9902d80823 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -12,7 +12,7 @@ function Compare-CIPPIntuneObject { [Parameter(Mandatory = $false)] [string[]]$CompareType = @() ) - if ($CompareType -ne 'Catalog') { + if ($CompareType -notcontains 'Catalog') { $defaultExcludeProperties = @( 'id', 'createdDateTime', diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index 09158ce20a60..26f924e6e195 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -157,7 +157,8 @@ function Invoke-ExecCompareIntunePolicy { } # Run the comparison - $ComparisonResults = @(Compare-CIPPIntuneObject @CompareParams) + $CompareResult = Compare-CIPPIntuneObject @CompareParams + $ComparisonResults = if ($null -eq $CompareResult) { @() } else { @($CompareResult) } $ResultBody = @{ Results = $ComparisonResults From ece775bd1b3b4aaf518a009b7409555c111faa61 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 1 May 2026 00:52:52 +0200 Subject: [PATCH 02/68] feat(intune): extend ListIntunePolicy for admin templates --- .../Endpoint/MEM/Invoke-ListIntunePolicy.ps1 | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 index fd199a71aa7a..c01f718c2a5f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 @@ -12,12 +12,14 @@ function Invoke-ListIntunePolicy { $TenantFilter = $Request.Query.TenantFilter $id = $Request.Query.ID $URLName = $Request.Query.URLName + $DefinitionIds = $Request.Query.DefinitionIds $UseReportDB = $Request.Query.UseReportDB $IncludeSettingDefinitions = [System.Convert]::ToBoolean($Request.Query.IncludeSettingDefinitions ?? 'false') + $IsGroupPolicyDefinitionLookup = ($URLName -ieq 'GroupPolicyDefinitions') -and -not [string]::IsNullOrWhiteSpace($DefinitionIds) try { # Return cached report data when AllTenants is requested or UseReportDB is set - if ($TenantFilter -eq 'AllTenants' -or $UseReportDB -eq 'true') { + if (-not $IsGroupPolicyDefinitionLookup -and ($TenantFilter -eq 'AllTenants' -or $UseReportDB -eq 'true')) { try { $GraphRequest = Get-CIPPIntunePolicyReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK @@ -31,8 +33,30 @@ function Invoke-ListIntunePolicy { }) } - if ($ID) { - if ($URLName -ieq 'ConfigurationPolicies' -or $URLName -ieq 'configurationPolicies') { + if ($IsGroupPolicyDefinitionLookup) { + $DefinitionIdList = @($DefinitionIds -split ',' | ForEach-Object { $_.Trim() } | Where-Object { Test-IsGuid -String $_ } | Select-Object -Unique) + + if ($DefinitionIdList.Count -eq 0) { + $GraphRequest = @() + } else { + $DefinitionRequests = [System.Collections.Generic.List[object]]::new() + $DefinitionIndex = 0 + + foreach ($DefinitionId in $DefinitionIdList) { + $RequestId = "definition$DefinitionIndex" + $DefinitionRequests.Add([PSCustomObject]@{ + id = $RequestId + method = 'GET' + url = "/deviceManagement/groupPolicyDefinitions('$DefinitionId')?`$expand=presentations" + }) + $DefinitionIndex++ + } + + $DefinitionResults = New-GraphBulkRequest -Requests @($DefinitionRequests) -tenantid $TenantFilter + $GraphRequest = $DefinitionResults | Where-Object { $_.status -eq 200 -and $_.body.id } | ForEach-Object { $_.body } + } + } elseif ($ID) { + if ($URLName -ieq 'ConfigurationPolicies') { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$ID')?`$expand=settings" -tenantid $TenantFilter if ($IncludeSettingDefinitions -and $GraphRequest.settings) { @@ -65,6 +89,47 @@ function Invoke-ListIntunePolicy { } } } + } elseif ($URLName -ieq 'GroupPolicyConfigurations') { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations('$ID')" -tenantid $TenantFilter + + if ($IncludeSettingDefinitions) { + $DefinitionValuesResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations('$ID')/definitionValues?`$expand=definition" -tenantid $TenantFilter + $DefinitionValues = @($DefinitionValuesResponse.value ?? $DefinitionValuesResponse) + $GraphRequest | Add-Member -NotePropertyName definitionValues -NotePropertyValue $DefinitionValues -Force + + if ($DefinitionValues.Count -gt 0) { + $PresentationRequests = [System.Collections.Generic.List[object]]::new() + $DefinitionValueLookup = @{} + $DefinitionValueIdMap = @{} + $DefinitionValueIndex = 0 + + foreach ($DefinitionValue in $DefinitionValues) { + if ($DefinitionValue.id) { + $RequestId = "definitionValue$DefinitionValueIndex" + $DefinitionValueIdMap[$RequestId] = $DefinitionValue.id + $DefinitionValueLookup[$DefinitionValue.id] = $DefinitionValue + $PresentationRequests.Add([PSCustomObject]@{ + id = $RequestId + method = 'GET' + url = "/deviceManagement/groupPolicyConfigurations('$ID')/definitionValues('$($DefinitionValue.id)')/presentationValues?`$expand=presentation" + }) + $DefinitionValueIndex++ + } + } + + if ($PresentationRequests.Count -gt 0) { + $PresentationResults = New-GraphBulkRequest -Requests @($PresentationRequests) -tenantid $TenantFilter + foreach ($PresentationResult in $PresentationResults) { + $DefinitionValueId = $DefinitionValueIdMap[$PresentationResult.id] + $DefinitionValue = $DefinitionValueLookup[$DefinitionValueId] + if ($DefinitionValue) { + $PresentationValues = @($PresentationResult.body.value ?? $PresentationResult.body) + $DefinitionValue | Add-Member -NotePropertyName presentationValues -NotePropertyValue $PresentationValues -Force + } + } + } + } + } } else { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/$($URLName)('$ID')" -tenantid $TenantFilter } From 80f6df79211d2d97b956480039f05fdd48bb457e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 2 May 2026 23:09:35 +0800 Subject: [PATCH 03/68] Redirect url helper scripts --- .../Authentication/Initialize-CIPPAuth.ps1 | 49 +++++++++++++++++ .../Update-CIPPSAMRedirectUri.ps1 | 55 +++++++++++++++++++ .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 12 +++- .../HTTP Functions/Invoke-ExecListAppId.ps1 | 21 +++++-- 4 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 create mode 100644 Modules/CIPPCore/Public/Authentication/Update-CIPPSAMRedirectUri.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 new file mode 100644 index 000000000000..bf0b55902563 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -0,0 +1,49 @@ +function Initialize-CIPPAuth { + <# + .SYNOPSIS + Bootstraps authentication state for CIPP. + + .DESCRIPTION + Loads SAM credentials from Key Vault (or DevSecrets table) + and auto-patches redirect URIs on the SAM app registration. + #> + [CmdletBinding()] + param() + + $AuthState = @{ + IsConfigured = $false + HasKeyVault = $false + HasSAMCredentials = $false + NeedsSetup = $true + } + + # 1. Determine Key Vault name + $KVName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + + # 2. Try loading SAM credentials + if ($KVName -or $env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $AuthState.HasKeyVault = [bool]$KVName + try { + $Auth = Get-CIPPAuthentication + if ($Auth -and $env:ApplicationID -and $env:TenantID) { + $AuthState.HasSAMCredentials = $true + $AuthState.NeedsSetup = $false + $AuthState.IsConfigured = $true + Write-Information "[Auth-Init] SAM credentials loaded (AppID: $($env:ApplicationID))" + } + } catch { + Write-Information "[Auth-Init] Could not load SAM credentials: $_" + } + } + + # 3. Auto-patch redirect URIs if we have credentials + if ($AuthState.HasSAMCredentials) { + try { + Update-CIPPSAMRedirectUri + } catch { + Write-Information "[Auth-Init] Redirect URI patch failed (non-fatal): $_" + } + } + + return $AuthState +} diff --git a/Modules/CIPPCore/Public/Authentication/Update-CIPPSAMRedirectUri.ps1 b/Modules/CIPPCore/Public/Authentication/Update-CIPPSAMRedirectUri.ps1 new file mode 100644 index 000000000000..356e922c876d --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Update-CIPPSAMRedirectUri.ps1 @@ -0,0 +1,55 @@ +function Update-CIPPSAMRedirectUri { + <# + .SYNOPSIS + Ensures the SAM app registration includes the current host's redirect URIs. + + .DESCRIPTION + Checks the SAM app's web.redirectUris and adds any + missing URIs for the current CIPP instance. Requires + $env:ApplicationID, $env:TenantID, and WEBSITE_HOSTNAME to be set. + #> + [CmdletBinding()] + param() + + $CurrentHost = $env:WEBSITE_HOSTNAME + if (-not $CurrentHost) { + Write-Information '[SAM-Redirect] WEBSITE_HOSTNAME not set, skipping redirect URI update' + return + } + + if (-not $env:ApplicationID -or -not $env:TenantID) { + Write-Information '[SAM-Redirect] SAM credentials not loaded, skipping redirect URI update' + return + } + + $CurrentUrl = "https://$CurrentHost" + $RequiredUris = @( + "$CurrentUrl/authredirect", + "$CurrentUrl/.auth/callback" + ) + + try { + $AppResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:ApplicationID)')?`$select=id,web" -tenantid $env:TenantID -NoAuthCheck $true + $ExistingUris = @($AppResponse.web.redirectUris) + $MissingUris = $RequiredUris | Where-Object { $_ -notin $ExistingUris } + + if ($MissingUris.Count -eq 0) { + Write-Information '[SAM-Redirect] All redirect URIs already present' + return + } + + $UpdatedUris = [System.Collections.Generic.List[string]]::new() + $ExistingUris | ForEach-Object { $UpdatedUris.Add($_) } + $MissingUris | ForEach-Object { $UpdatedUris.Add($_) } + + $Body = @{ + web = @{ redirectUris = $UpdatedUris } + } | ConvertTo-Json -Depth 5 + + New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $Body -tenantid $env:TenantID -type PATCH -NoAuthCheck $true + Write-Information "[SAM-Redirect] Added redirect URIs: $($MissingUris -join ', ')" + Write-LogMessage -API 'SAM-Redirect' -message "Added redirect URIs: $($MissingUris -join ', ')" -sev Info + } catch { + Write-LogMessage -API 'SAM-Redirect' -message "Failed to update redirect URIs: $_" -LogData (Get-CippException -Exception $_) -sev Warning + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index 49ed0ac2fd06..c1a8350cbc19 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -14,7 +14,9 @@ function Invoke-ExecCreateSAMApp { try { $Token = $Request.body if ($Token) { - $URL = ($Request.headers.'x-ms-original-url').split('/api') | Select-Object -First 1 + $URL = $Request.headers.origin ?? $Request.headers.referer?.TrimEnd('/') + $RedirectUri = "$URL/authredirect" + $AuthCallbackUri = "$URL/.auth/callback" $TenantId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/organization' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method GET -ContentType 'application/json').value.id #Find Existing app registration $AppId = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq 'CIPP-SAM'" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method GET -ContentType 'application/json').value | Select-Object -Last 1 @@ -25,14 +27,14 @@ function Invoke-ExecCreateSAMApp { #remove the entire web object from the app registration $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') $app = Get-Content $SamManifestFile.FullName | ConvertFrom-Json - $app.web.redirectUris = @("$($url)/authredirect") + $app.web.redirectUris = @($RedirectUri, $AuthCallbackUri) $app = ConvertTo-Json -Depth 15 -Compress -InputObject $app Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method PATCH -Body $app -ContentType 'application/json' } else { $state = 'created' $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') $app = Get-Content $SamManifestFile.FullName | ConvertFrom-Json - $app.web.redirectUris = @("$($url)/authredirect") + $app.web.redirectUris = @($RedirectUri, $AuthCallbackUri) $app = $app | ConvertTo-Json -Depth 15 $AppId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/applications' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body $app -ContentType 'application/json') $attempt = 0 @@ -101,6 +103,10 @@ function Invoke-ExecCreateSAMApp { ApplicationId = $AppId.appId } Add-CIPPAzDataTableEntity @ConfigTable -Entity $NewConfig -Force | Out-Null + + # Reload credentials into env vars + $null = Get-CIPPAuthentication + $Results = @{'message' = "Successfully $state the application registration. The application ID is $($AppId.appid). You may continue to the next step."; severity = 'success' } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 index b5a4c347171c..2c93e87596c1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 @@ -8,7 +8,12 @@ function Invoke-ExecListAppId { [CmdletBinding()] param($Request, $TriggerMetadata) Get-CIPPAuthentication - $ResponseURL = "$(($Request.headers.'x-ms-original-url').replace('/api/ExecListAppId','/api/ExecSAMSetup'))" + $ResponseURL = if ($Request.headers.'x-ms-original-url') { + "$(($Request.headers.'x-ms-original-url').replace('/api/ExecListAppId','/api/ExecSAMSetup'))" + } else { + $origin = $Request.headers.origin ?? $Request.headers.referer?.TrimEnd('/') + "$origin/api/ExecSAMSetup" + } #make sure we get the very latest version of the appid from kv: if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' @@ -67,14 +72,20 @@ function Invoke-ExecListAppId { if ($AppResponse.body) { $AppWeb = $AppResponse.body.web if ($AppWeb.redirectUris) { - # construct new redirect uri with current - $URL = ($Request.headers.'x-ms-original-url').split('/api') | Select-Object -First 1 + # construct new redirect uri with current origin + $URL = if ($Request.headers.'x-ms-original-url') { + ($Request.headers.'x-ms-original-url').split('/api') | Select-Object -First 1 + } else { + $Request.headers.origin ?? $Request.headers.referer?.TrimEnd('/') + } $NewRedirectUri = "$($URL)/authredirect" - if ($AppWeb.redirectUris -notcontains $NewRedirectUri) { + $NewAuthCallbackUri = "$($URL)/.auth/callback" + $MissingUris = @($NewRedirectUri, $NewAuthCallbackUri) | Where-Object { $AppWeb.redirectUris -notcontains $_ } + if ($MissingUris.Count -gt 0) { try { $RedirectUris = [system.collections.generic.list[string]]::new() $AppWeb.redirectUris | ForEach-Object { $RedirectUris.Add($_) } - $RedirectUris.Add($NewRedirectUri) + $MissingUris | ForEach-Object { $RedirectUris.Add($_) } $AppUpdateBody = @{ web = @{ redirectUris = $RedirectUris From 50789ce3d28a4a9d83137ea6dfce835063bd597b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 3 May 2026 00:10:45 +0800 Subject: [PATCH 04/68] queue tweaks --- .../CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 | 5 +++++ .../CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 | 11 +++++++---- .../CIPPCore/Public/CippQueue/Set-CippQueueTask.ps1 | 4 ++++ .../Public/CippQueue/Update-CippQueueEntry.ps1 | 2 ++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 b/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 index 96ba4efb6762..d2cd1aab7f48 100644 --- a/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 @@ -4,6 +4,11 @@ function Get-CIPPQueueData { $QueueId = $Request.Query.QueueId ?? $QueueId $Reference = $Request.Query.Reference ?? $Reference + if ($env:CIPPNG -eq 'true') { + $json = [CRAFT.Services.QueueStatusBridge]::GetRunStatus($Reference, $QueueId) + return ($json | ConvertFrom-Json) + } + $CippQueue = Get-CippTable -TableName 'CippQueue' $CippQueueTasks = Get-CippTable -TableName 'CippQueueTasks' $3HoursAgo = (Get-Date).ToUniversalTime().AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ssZ') diff --git a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 index e5489ba33e37..6302f05994e8 100644 --- a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 @@ -10,8 +10,6 @@ function New-CippQueueEntry { [int]$TotalTasks = 1 ) - $CippQueue = Get-CippTable -TableName CippQueue - $QueueEntry = @{ PartitionKey = 'CippQueue' RowKey = (New-Guid).Guid.ToString() @@ -21,9 +19,14 @@ function New-CippQueueEntry { Status = 'Queued' TotalTasks = $TotalTasks } - $CippQueue.Entity = $QueueEntry + if ($env:CIPPNG -eq 'true') { + return $QueueEntry + } + + $CippQueue = Get-CippTable -TableName CippQueue + $CippQueue.Entity = $QueueEntry Add-CIPPAzDataTableEntity @CippQueue $QueueEntry -} \ No newline at end of file +} diff --git a/Modules/CIPPCore/Public/CippQueue/Set-CippQueueTask.ps1 b/Modules/CIPPCore/Public/CippQueue/Set-CippQueueTask.ps1 index 928c6e06285d..a958eb200a07 100644 --- a/Modules/CIPPCore/Public/CippQueue/Set-CippQueueTask.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/Set-CippQueueTask.ps1 @@ -12,6 +12,10 @@ function Set-CippQueueTask { [string]$Message ) + if ($env:CIPPNG -eq 'true') { + return @{ RowKey = $TaskId; QueueId = $QueueId; Name = $Name; Status = $Status } + } + $CippQueueTasks = Get-CippTable -TableName CippQueueTasks $QueueTaskEntry = @{ diff --git a/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 b/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 index 8184320508a8..55741ee6a2c8 100644 --- a/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 @@ -12,6 +12,8 @@ function Update-CippQueueEntry { [switch]$IncrementTotalTasks ) + if ($env:CIPPNG -eq 'true') { return } + $CippQueue = Get-CippTable -TableName CippQueue if ($RowKey) { From 8594b3d2666b859671b6e0b2ea6bd3d75681d44a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 3 May 2026 01:15:54 +0800 Subject: [PATCH 05/68] Update Start-CIPPOrchestrator.ps1 --- .../Orchestrator Functions/Start-CIPPOrchestrator.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index b9b0458653e1..86a55f98a8bf 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -55,6 +55,12 @@ function Start-CIPPOrchestrator { $InputObject | Add-Member -MemberType NoteProperty -Name 'Batch' -Value $QueueBatch -Force } + # Include QueueId in RunName so the frontend can poll status by QueueId + $BatchQueueId = ($InputObject.Batch | Select-Object -First 1).QueueId + if ($BatchQueueId) { + $OrchestratorName = "$OrchestratorName-$BatchQueueId" + } + $BatchJson = ConvertTo-Json -InputObject @($InputObject.Batch) -Depth 10 -Compress $PostExecFunctionName = $null From 75040833aa6373059348167f9c36b39d82b47332 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 2 May 2026 16:08:18 -0400 Subject: [PATCH 06/68] rename --- .../Public/CippQueue/Get-CIPPQueueData.ps1 | 2 +- .../Start-CIPPOrchestrator.ps1 | 14 +++++++------- .../Public/GraphHelper/Add-CippQueueMessage.ps1 | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 b/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 index d2cd1aab7f48..0e94d7ea8de4 100644 --- a/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 @@ -5,7 +5,7 @@ function Get-CIPPQueueData { $Reference = $Request.Query.Reference ?? $Reference if ($env:CIPPNG -eq 'true') { - $json = [CRAFT.Services.QueueStatusBridge]::GetRunStatus($Reference, $QueueId) + $json = [Craft.Services.QueueStatusBridge]::GetRunStatus($Reference, $QueueId) return ($json | ConvertFrom-Json) } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 86a55f98a8bf..31a13b68a50b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -34,14 +34,14 @@ function Start-CIPPOrchestrator { [switch]$CallerIsQueueTrigger ) - # ─── CRAFT runtime: push batch directly to OrchestratorService ─── + # ─── Craft runtime: push batch directly to OrchestratorService ─── if ($env:CIPPNG -eq 'true' -and $InputObject) { $OrchestratorName = $InputObject.OrchestratorName ?? 'UnnamedOrchestrator' # QueueFunction pattern: call the function first to generate batch items if (-not $InputObject.Batch -and $InputObject.QueueFunction) { $QueueFuncName = "Push-$($InputObject.QueueFunction.FunctionName)" - Write-Information "CRAFT: Calling QueueFunction '$QueueFuncName' to build batch for '$OrchestratorName'" + Write-Information "Craft: Calling QueueFunction '$QueueFuncName' to build batch for '$OrchestratorName'" $QueueItem = [PSCustomObject]@{} if ($InputObject.QueueFunction.Parameters) { $QueueItem = [PSCustomObject]$InputObject.QueueFunction.Parameters @@ -49,8 +49,8 @@ function Start-CIPPOrchestrator { $BatchResult = & $QueueFuncName -Item $QueueItem $QueueBatch = @($BatchResult | Where-Object { $null -ne $_ }) if ($QueueBatch.Count -eq 0) { - Write-Information "CRAFT: QueueFunction '$QueueFuncName' returned 0 tasks for '$OrchestratorName' - skipping" - return "CRAFT-$OrchestratorName-NoTasks" + Write-Information "Craft: QueueFunction '$QueueFuncName' returned 0 tasks for '$OrchestratorName' - skipping" + return "Craft-$OrchestratorName-NoTasks" } $InputObject | Add-Member -MemberType NoteProperty -Name 'Batch' -Value $QueueBatch -Force } @@ -72,15 +72,15 @@ function Start-CIPPOrchestrator { } } - Write-Information "CRAFT: Queuing orchestrator '$OrchestratorName' ($($InputObject.Batch.Count) tasks$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" - [CRAFT.Services.OrchestratorBridge]::QueueOrchestration( + Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($($InputObject.Batch.Count) tasks$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" + [Craft.Services.OrchestratorBridge]::QueueOrchestration( $OrchestratorName, $BatchJson, 4, $PostExecFunctionName, $PostExecParametersJson ) - return "CRAFT-$OrchestratorName" + return "Craft-$OrchestratorName" } $OrchestratorTable = Get-CippTable -TableName 'CippOrchestratorInput' diff --git a/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 b/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 index 1496e53ea9c4..1df44a16cb0e 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 @@ -31,8 +31,8 @@ function Add-CippQueueMessage { try { if ($env:CIPPNG -eq 'true') { $ParametersJson = $Parameters | ConvertTo-Json -Depth 10 -Compress - [CRAFT.Services.QueueBridge]::Enqueue($Cmdlet, $ParametersJson) - Write-Information "CRAFT: Queued $Cmdlet for background execution" + [Craft.Services.QueueBridge]::Enqueue($Cmdlet, $ParametersJson) + Write-Information "Craft: Queued $Cmdlet for background execution" return $true } From 4484ec2e6ddf17702ff25c4c37d0a4542bee51d9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 2 May 2026 23:11:34 -0400 Subject: [PATCH 07/68] Add queueing functions to blocked commands list Update Get-CIPPSchedulerBlockedCommands.ps1 to include queueing-related functions (Add-CippQueueMessage, New-CippQueueEntry, Set-CippQueueTask, Update-CippQueueEntry) in the blocked commands array. --- .../Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index 5f5e997a0eb0..e46e3e82cd91 100644 --- a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -56,5 +56,11 @@ function Get-CIPPSchedulerBlockedCommands { # Backup & restore 'Get-CIPPBackup' + + # Queueing functions - would allow attackers to create new scheduled tasks with blocked commands + 'Add-CippQueueMessage' + 'New-CippQueueEntry' + 'Set-CippQueueTask' + 'Update-CippQueueEntry' ) } From bb3db333e125068c3e0e784299bf2b6befdabdfd Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 4 May 2026 14:38:45 +0800 Subject: [PATCH 08/68] Correct list alerts accounting for tenant allowed tenant groups --- .../Alerts/Invoke-ListAlertsQueue.ps1 | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index b33d1f6e19db..b1c4aa287f2c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -47,11 +47,34 @@ function Invoke-ListAlertsQueue { } if ($AllowedTenants -notcontains 'AllTenants') { + $HasAccess = $false foreach ($Tenant in $Tenants) { - if ($AllowedTenants -contains $Tenant.customerId) { - $AllTasksArrayList.Add($TaskEntry) - break + if ($Tenant.type -eq 'Group') { + try { + $GroupFilter = @([PSCustomObject]@{ + type = 'Group' + value = $Tenant.value + label = $Tenant.label + }) + $ExpandedGroupTenants = Expand-CIPPTenantGroups -TenantFilter $GroupFilter + foreach ($ExpandedTenant in $ExpandedGroupTenants) { + if ($AllowedTenants -contains $ExpandedTenant.addedFields.customerId) { + $HasAccess = $true + break + } + } + } catch { + Write-Warning "Failed to expand tenant group for webhook access check: $($_.Exception.Message)" + } + } else { + if ($AllowedTenants -contains $Tenant.customerId) { + $HasAccess = $true + } } + if ($HasAccess) { break } + } + if ($HasAccess) { + $AllTasksArrayList.Add($TaskEntry) } } else { $AllTasksArrayList.Add($TaskEntry) @@ -133,23 +156,18 @@ function Invoke-ListAlertsQueue { } if ($AllowedTenants -notcontains 'AllTenants') { - # For tenant groups, we need to expand and check access + $HasAccess = $false if ($Task.TenantGroup) { + # Expand legacy TenantGroup field and check access try { $TenantGroupObject = $Task.TenantGroup | ConvertFrom-Json -ErrorAction SilentlyContinue if ($TenantGroupObject) { - # Create a tenant filter object for expansion $TenantFilterForExpansion = @([PSCustomObject]@{ type = 'Group' value = $TenantGroupObject.value label = $TenantGroupObject.label }) - - # Expand the tenant group to individual tenants $ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $TenantFilterForExpansion - - # Check if user has access to any tenant in the group - $HasAccess = $false foreach ($ExpandedTenant in $ExpandedTenants) { $TenantInfo = $TenantList | Where-Object -Property defaultDomainName -EQ $ExpandedTenant.value if ($TenantInfo -and $AllowedTenants -contains $TenantInfo.customerId) { @@ -157,21 +175,49 @@ function Invoke-ListAlertsQueue { break } } - - if ($HasAccess) { - $AllTasksArrayList.Add($TaskEntry) - } } } catch { Write-Warning "Failed to expand tenant group for access check: $($_.Exception.Message)" } + } elseif ($Task.Tenants) { + # Multi-tenant alert - may contain groups or individual tenants + try { + $TenantsParsed = $Task.Tenants | ConvertFrom-Json -ErrorAction Stop + foreach ($TenantItem in $TenantsParsed) { + if ($TenantItem.type -eq 'Group') { + $GroupFilter = @([PSCustomObject]@{ + type = 'Group' + value = $TenantItem.value + label = $TenantItem.label + }) + $ExpandedGroupTenants = Expand-CIPPTenantGroups -TenantFilter $GroupFilter + foreach ($ExpandedTenant in $ExpandedGroupTenants) { + if ($AllowedTenants -contains $ExpandedTenant.addedFields.customerId) { + $HasAccess = $true + break + } + } + } else { + $TenantInfo = $TenantList | Where-Object -Property defaultDomainName -EQ $TenantItem.value + if ($TenantInfo -and $AllowedTenants -contains $TenantInfo.customerId) { + $HasAccess = $true + } + } + if ($HasAccess) { break } + } + } catch { + Write-Warning "Failed to parse Tenants for access check on task $($Task.RowKey): $($_.Exception.Message)" + } } else { - # Regular tenant access check + # Regular single-tenant access check $Tenant = $TenantList | Where-Object -Property defaultDomainName -EQ $Task.Tenant if ($AllowedTenants -contains $Tenant.customerId) { - $AllTasksArrayList.Add($TaskEntry) + $HasAccess = $true } } + if ($HasAccess) { + $AllTasksArrayList.Add($TaskEntry) + } } else { $AllTasksArrayList.Add($TaskEntry) } From f9928aecc9a08a22a2ae6e68ddc4079e0e9b613a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 4 May 2026 20:53:52 +0200 Subject: [PATCH 09/68] CIS Microsoft 365 Foundations Benchmark v6.0.1 --- .../Push-CIPPDBCacheData.ps1 | 42 +++++- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 16 +- .../Set-CIPPDBCacheCsExternalAccessPolicy.ps1 | 40 +++++ ...-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 | 40 +++++ ...-CIPPDBCacheCsTeamsClientConfiguration.ps1 | 41 ++++++ .../Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 | 40 +++++ .../Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 | 41 ++++++ ...DBCacheCsTenantFederationConfiguration.ps1 | 41 ++++++ .../Set-CIPPDBCacheOwaMailboxPolicy.ps1 | 40 +++++ .../Set-CIPPDBCacheReportSubmissionPolicy.ps1 | 40 +++++ .../DBCache/Set-CIPPDBCacheSPOTenant.ps1 | 42 ++++++ ...PDBCacheSPOTenantSyncClientRestriction.ps1 | 51 +++++++ .../CIS/Identity/Invoke-CippTestCIS_1_1_1.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_1_1_1.ps1 | 56 +++++++ .../CIS/Identity/Invoke-CippTestCIS_1_1_2.md | 16 ++ .../CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 | 47 ++++++ .../CIS/Identity/Invoke-CippTestCIS_1_1_3.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 | 41 ++++++ .../CIS/Identity/Invoke-CippTestCIS_1_1_4.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 | 52 +++++++ .../CIS/Identity/Invoke-CippTestCIS_1_2_1.md | 12 ++ .../CIS/Identity/Invoke-CippTestCIS_1_2_1.ps1 | 35 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_2_2.md | 12 ++ .../CIS/Identity/Invoke-CippTestCIS_1_2_2.ps1 | 46 ++++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_1.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_1.ps1 | 34 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_2.md | 16 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_2.ps1 | 42 ++++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_3.ps1 | 35 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_4.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_4.ps1 | 39 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_5.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_5.ps1 | 36 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_6.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_6.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_7.md | 15 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_7.ps1 | 36 +++++ .../CIS/Identity/Invoke-CippTestCIS_1_3_8.md | 12 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_8.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_1_3_9.md | 21 +++ .../CIS/Identity/Invoke-CippTestCIS_1_3_9.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 | 46 ++++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_10.md | 17 +++ .../Identity/Invoke-CippTestCIS_2_1_10.ps1 | 36 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_11.md | 11 ++ .../Identity/Invoke-CippTestCIS_2_1_11.ps1 | 35 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_12.md | 13 ++ .../Identity/Invoke-CippTestCIS_2_1_12.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_13.md | 13 ++ .../Identity/Invoke-CippTestCIS_2_1_13.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_14.md | 13 ++ .../Identity/Invoke-CippTestCIS_2_1_14.ps1 | 34 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_15.md | 13 ++ .../Identity/Invoke-CippTestCIS_2_1_15.ps1 | 42 ++++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_2.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 | 34 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_4.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_5.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 | 43 ++++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_6.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_6.ps1 | 37 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_7.md | 15 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_7.ps1 | 46 ++++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_8.md | 12 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_8.ps1 | 34 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_1_9.md | 15 ++ .../CIS/Identity/Invoke-CippTestCIS_2_1_9.ps1 | 40 +++++ .../CIS/Identity/Invoke-CippTestCIS_2_2_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_2_1.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_1.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_1.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_2.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_2.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_2_4_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_3.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_4.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_2_4_4.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_3_1_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_3_1_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_3_2_1.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_3_2_1.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_3_2_2.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_3_2_2.ps1 | 38 +++++ .../CIS/Identity/Invoke-CippTestCIS_3_3_1.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_3_3_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_4_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_4_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_4_2.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_4_2.ps1 | 46 ++++++ .../Identity/Invoke-CippTestCIS_5_1_2_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_2_1.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_2_2.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_2_2.ps1 | 31 ++++ .../Identity/Invoke-CippTestCIS_5_1_2_3.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_2_3.ps1 | 31 ++++ .../Identity/Invoke-CippTestCIS_5_1_2_4.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_2_4.ps1 | 9 ++ .../Identity/Invoke-CippTestCIS_5_1_2_5.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_2_5.ps1 | 9 ++ .../Identity/Invoke-CippTestCIS_5_1_2_6.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_2_6.ps1 | 9 ++ .../Identity/Invoke-CippTestCIS_5_1_3_1.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_3_1.ps1 | 35 +++++ .../Identity/Invoke-CippTestCIS_5_1_3_2.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_3_2.ps1 | 31 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_4_1.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_2.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_4_2.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_3.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_4_3.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_4.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_4_4.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_5.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_4_5.ps1 | 31 ++++ .../Identity/Invoke-CippTestCIS_5_1_4_6.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_4_6.ps1 | 31 ++++ .../Identity/Invoke-CippTestCIS_5_1_5_1.md | 15 ++ .../Identity/Invoke-CippTestCIS_5_1_5_1.ps1 | 35 +++++ .../Identity/Invoke-CippTestCIS_5_1_5_2.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_5_2.ps1 | 34 +++++ .../Identity/Invoke-CippTestCIS_5_1_6_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_6_1.ps1 | 45 ++++++ .../Identity/Invoke-CippTestCIS_5_1_6_2.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_6_2.ps1 | 34 +++++ .../Identity/Invoke-CippTestCIS_5_1_6_3.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_1_6_3.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_1_8_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_1_8_1.ps1 | 40 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_2_1.ps1 | 41 ++++++ .../Identity/Invoke-CippTestCIS_5_2_2_10.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_2_2_10.ps1 | 41 ++++++ .../Identity/Invoke-CippTestCIS_5_2_2_11.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_2_2_11.ps1 | 41 ++++++ .../Identity/Invoke-CippTestCIS_5_2_2_12.md | 14 ++ .../Identity/Invoke-CippTestCIS_5_2_2_12.ps1 | 37 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_2.md | 14 ++ .../Identity/Invoke-CippTestCIS_5_2_2_2.ps1 | 38 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_3.md | 15 ++ .../Identity/Invoke-CippTestCIS_5_2_2_3.ps1 | 38 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_4.md | 13 ++ .../Identity/Invoke-CippTestCIS_5_2_2_4.ps1 | 44 ++++++ .../Identity/Invoke-CippTestCIS_5_2_2_5.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_2_5.ps1 | 43 ++++++ .../Identity/Invoke-CippTestCIS_5_2_2_6.md | 14 ++ .../Identity/Invoke-CippTestCIS_5_2_2_6.ps1 | 36 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_7.md | 14 ++ .../Identity/Invoke-CippTestCIS_5_2_2_7.ps1 | 36 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_8.md | 14 ++ .../Identity/Invoke-CippTestCIS_5_2_2_8.ps1 | 37 +++++ .../Identity/Invoke-CippTestCIS_5_2_2_9.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_2_9.ps1 | 38 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_3_1.ps1 | 43 ++++++ .../Identity/Invoke-CippTestCIS_5_2_3_2.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_3_2.ps1 | 39 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_3.md | 12 ++ .../Identity/Invoke-CippTestCIS_5_2_3_3.ps1 | 40 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_4.md | 12 ++ .../Identity/Invoke-CippTestCIS_5_2_3_4.ps1 | 40 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_5.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_3_5.ps1 | 36 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_6.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_3_6.ps1 | 34 +++++ .../Identity/Invoke-CippTestCIS_5_2_3_7.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_3_7.ps1 | 32 ++++ .../Identity/Invoke-CippTestCIS_5_2_4_1.md | 11 ++ .../Identity/Invoke-CippTestCIS_5_2_4_1.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_1.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_1.ps1 | 34 +++++ .../CIS/Identity/Invoke-CippTestCIS_5_3_2.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_2.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_3.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_3.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_4.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_4.ps1 | 37 +++++ .../CIS/Identity/Invoke-CippTestCIS_5_3_5.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_5_3_5.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_6_1_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_1_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_1_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_1_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_1_3.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_2_1.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_6_2_1.ps1 | 38 +++++ .../CIS/Identity/Invoke-CippTestCIS_6_2_2.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_6_2_2.ps1 | 39 +++++ .../CIS/Identity/Invoke-CippTestCIS_6_2_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_2_3.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_3_1.md | 19 +++ .../CIS/Identity/Invoke-CippTestCIS_6_3_1.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_5_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_2.ps1 | 36 +++++ .../CIS/Identity/Invoke-CippTestCIS_6_5_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_3.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_5_4.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_4.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_6_5_5.md | 15 ++ .../CIS/Identity/Invoke-CippTestCIS_6_5_5.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_10.md | 13 ++ .../Identity/Invoke-CippTestCIS_7_2_10.ps1 | 33 +++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_11.md | 13 ++ .../Identity/Invoke-CippTestCIS_7_2_11.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_2.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_3.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_4.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_4.ps1 | 35 +++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_5.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_5.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_6.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_6.ps1 | 37 +++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_7.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_2_8.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_8.ps1 | 9 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_9.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_2_9.ps1 | 33 +++++ .../CIS/Identity/Invoke-CippTestCIS_7_3_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_7_3_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_7_3_2.md | 16 ++ .../CIS/Identity/Invoke-CippTestCIS_7_3_2.ps1 | 36 +++++ .../CIS/Identity/Invoke-CippTestCIS_8_1_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_1_1.ps1 | 37 +++++ .../CIS/Identity/Invoke-CippTestCIS_8_1_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_1_2.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_2_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 | 37 +++++ .../CIS/Identity/Invoke-CippTestCIS_8_2_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_2_2.ps1 | 33 +++++ .../CIS/Identity/Invoke-CippTestCIS_8_2_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_2_3.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_2_4.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_8_2_4.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_4_1.md | 11 ++ .../CIS/Identity/Invoke-CippTestCIS_8_4_1.ps1 | 35 +++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_1.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_1.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_2.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_2.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_3.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_3.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_4.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_4.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_5.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_5.ps1 | 32 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_6.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_6.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_7.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_7.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_8.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_8.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_5_9.md | 13 ++ .../CIS/Identity/Invoke-CippTestCIS_8_5_9.ps1 | 31 ++++ .../CIS/Identity/Invoke-CippTestCIS_8_6_1.md | 14 ++ .../CIS/Identity/Invoke-CippTestCIS_8_6_1.ps1 | 37 +++++ .../CIPPTests/Public/Tests/CIS/report.json | 137 ++++++++++++++++++ 269 files changed, 6369 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.md create mode 100644 Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/CIS/report.json diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 2b0574bf40e4..b52647862dc3 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -65,7 +65,23 @@ function Push-CIPPDBCacheData { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } - Write-Information "License capabilities for $TenantFilter - Intune: $IntuneCapable, CA: $ConditionalAccessCapable, P2: $AzureADPremiumP2Capable, Exchange: $ExchangeCapable, Compliance: $ComplianceCapable" + $SharePointCapable = $false + try { + $SharePointCapable = Test-CIPPStandardLicense -StandardName 'SharePointLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('SHAREPOINTWAC', 'SHAREPOINTSTANDARD', 'SHAREPOINTENTERPRISE', 'SHAREPOINTENTERPRISE_EDU', 'ONEDRIVE_BASIC', 'ONEDRIVE_ENTERPRISE') -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SharePoint license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + + $TeamsCapable = $false + try { + $TeamsCapable = Test-CIPPStandardLicense -StandardName 'TeamsLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MCOSTANDARD', 'MCOEV', 'MCOIMP', 'TEAMS1', 'Teams_Room_Standard') -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Teams license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + + Write-Information "License capabilities for $TenantFilter - Intune: $IntuneCapable, CA: $ConditionalAccessCapable, P2: $AzureADPremiumP2Capable, Exchange: $ExchangeCapable, Compliance: $ComplianceCapable, SharePoint: $SharePointCapable, Teams: $TeamsCapable" # Build grouped collection tasks — one activity per license category instead of one per cache type $Tasks = [System.Collections.Generic.List[object]]::new() @@ -174,6 +190,30 @@ function Push-CIPPDBCacheData { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } + if ($SharePointCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'SharePoint' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache SharePoint - $TenantFilter" + }) + } else { + Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" + } + + if ($TeamsCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'Teams' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache Teams - $TenantFilter" + }) + } else { + Write-Host "Skipping Teams data collection for $TenantFilter - no required license" + } + Write-Information "Built $($Tasks.Count) grouped cache tasks for tenant $TenantFilter (down from individual per-type tasks)" # Return the task list — the PostExecution function will aggregate and start a flat orchestrator diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index dd6fe54aef5c..a330027c6aab 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -31,7 +31,7 @@ function Invoke-CIPPDBCacheCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage')] + [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams')] [string]$CollectionType, [Parameter(Mandatory = $true)] @@ -87,6 +87,8 @@ function Invoke-CIPPDBCacheCollection { 'ExoAdminAuditLogConfig' 'ExoPresetSecurityPolicy' 'ExoTenantAllowBlockList' + 'OwaMailboxPolicy' + 'ReportSubmissionPolicy' ) ExchangeData = @( 'CASMailboxes' @@ -127,6 +129,18 @@ function Invoke-CIPPDBCacheCollection { 'CopilotUserCountTrend' 'CopilotReadinessActivity' ) + SharePoint = @( + 'SPOTenant' + 'SPOTenantSyncClientRestriction' + ) + Teams = @( + 'CsTeamsMeetingPolicy' + 'CsTeamsClientConfiguration' + 'CsExternalAccessPolicy' + 'CsTenantFederationConfiguration' + 'CsTeamsMessagingPolicy' + 'CsTeamsAppPermissionPolicy' + ) } $CacheTypes = $Collections[$CollectionType] diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 new file mode 100644 index 000000000000..9889b37b7a6c --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheCsExternalAccessPolicy { + <# + .SYNOPSIS + Caches the Teams External Access Policy (Global) + + .DESCRIPTION + Calls Get-CsExternalAccessPolicy via New-TeamsRequest and writes the + result into the CippReportingDB under Type 'CsExternalAccessPolicy'. + Used by CIS tests 8.2.1 (external domains) and 8.2.2 (unmanaged Teams users). + + .PARAMETER TenantFilter + The tenant to cache the external access policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams External Access Policy' -sev Debug + + $ExternalAccess = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsExternalAccessPolicy' -CmdParams @{ Identity = 'Global' } + + if ($ExternalAccess) { + $Data = @($ExternalAccess) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsExternalAccessPolicy' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsExternalAccessPolicy' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams External Access Policy' -sev Debug + } + $ExternalAccess = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams External Access Policy: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 new file mode 100644 index 000000000000..206c1a806741 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheCsTeamsAppPermissionPolicy { + <# + .SYNOPSIS + Caches the Teams App Permission Policy (all policies) + + .DESCRIPTION + Calls Get-CsTeamsAppPermissionPolicy via New-TeamsRequest and writes + the result into the CippReportingDB under Type + 'CsTeamsAppPermissionPolicy'. Used by CIS test 8.4.1. + + .PARAMETER TenantFilter + The tenant to cache the app permission policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams App Permission Policies' -sev Debug + + $AppPermissionPolicies = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsAppPermissionPolicy' + + if ($AppPermissionPolicies) { + $Data = @($AppPermissionPolicies) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsAppPermissionPolicy' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsAppPermissionPolicy' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Data.Count) Teams App Permission Policies" -sev Debug + } + $AppPermissionPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams App Permission Policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 new file mode 100644 index 000000000000..de1d018ff9c9 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheCsTeamsClientConfiguration { + <# + .SYNOPSIS + Caches the Teams Client Configuration (Global) + + .DESCRIPTION + Calls Get-CsTeamsClientConfiguration via New-TeamsRequest and writes + the result into the CippReportingDB under Type 'CsTeamsClientConfiguration'. + Used by CIS tests 8.1.1 (external file sharing storage providers) and + 8.1.2 (channel email). + + .PARAMETER TenantFilter + The tenant to cache the client configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Client Configuration' -sev Debug + + $ClientConfig = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{ Identity = 'Global' } + + if ($ClientConfig) { + $Data = @($ClientConfig) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsClientConfiguration' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsClientConfiguration' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Client Configuration' -sev Debug + } + $ClientConfig = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Client Configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 new file mode 100644 index 000000000000..7dcea7b968ee --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheCsTeamsMeetingPolicy { + <# + .SYNOPSIS + Caches the Teams Global Meeting Policy + + .DESCRIPTION + Calls Get-CsTeamsMeetingPolicy via New-TeamsRequest and writes the + result into the CippReportingDB under Type 'CsTeamsMeetingPolicy'. + Used by CIS tests 8.5.1 - 8.5.9. + + .PARAMETER TenantFilter + The tenant to cache the meeting policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Meeting Policy' -sev Debug + + $MeetingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{ Identity = 'Global' } + + if ($MeetingPolicy) { + $Data = @($MeetingPolicy) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMeetingPolicy' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMeetingPolicy' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Meeting Policy' -sev Debug + } + $MeetingPolicy = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Meeting Policy: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 new file mode 100644 index 000000000000..e3696593c918 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheCsTeamsMessagingPolicy { + <# + .SYNOPSIS + Caches the Teams Messaging Policy (Global) + + .DESCRIPTION + Calls Get-CsTeamsMessagingPolicy via New-TeamsRequest and writes the + result into the CippReportingDB under Type 'CsTeamsMessagingPolicy'. + Used by CIS tests 8.2.3 (external Teams users initiating chat) and + 8.6.1 (security reporting in Teams). + + .PARAMETER TenantFilter + The tenant to cache the messaging policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Messaging Policy' -sev Debug + + $MessagingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{ Identity = 'Global' } + + if ($MessagingPolicy) { + $Data = @($MessagingPolicy) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingPolicy' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingPolicy' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Messaging Policy' -sev Debug + } + $MessagingPolicy = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Messaging Policy: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 new file mode 100644 index 000000000000..b5d76c0f89e5 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheCsTenantFederationConfiguration { + <# + .SYNOPSIS + Caches the Teams Tenant Federation Configuration + + .DESCRIPTION + Calls Get-CsTenantFederationConfiguration via New-TeamsRequest and + writes the result into the CippReportingDB under Type + 'CsTenantFederationConfiguration'. Used by CIS tests 8.2.1 (external + domains allow/block list) and 8.2.4 (trial Teams tenants). + + .PARAMETER TenantFilter + The tenant to cache the federation configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Tenant Federation Configuration' -sev Debug + + $Federation = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{ Identity = 'Global' } + + if ($Federation) { + $Data = @($Federation) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTenantFederationConfiguration' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTenantFederationConfiguration' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Tenant Federation Configuration' -sev Debug + } + $Federation = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Tenant Federation Configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 new file mode 100644 index 000000000000..6bb55b05780f --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOwaMailboxPolicy.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheOwaMailboxPolicy { + <# + .SYNOPSIS + Caches Exchange Online OWA Mailbox Policies + + .DESCRIPTION + Calls Get-OwaMailboxPolicy via New-ExoRequest and writes the result + into the CippReportingDB under Type 'OwaMailboxPolicy'. Used by CIS + test 6.5.3 (additional storage providers in OWA) and the manual form + of 1.3.9 (BookingsMailboxCreationEnabled). + + .PARAMETER TenantFilter + The tenant to cache OWA mailbox policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching OWA Mailbox Policies' -sev Debug + + $OwaMailboxPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-OwaMailboxPolicy' + + if ($OwaMailboxPolicies) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OwaMailboxPolicy' -Data $OwaMailboxPolicies + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OwaMailboxPolicy' -Data $OwaMailboxPolicies -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OwaMailboxPolicies.Count) OWA Mailbox Policies" -sev Debug + } + $OwaMailboxPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache OWA Mailbox Policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 new file mode 100644 index 000000000000..b3de00a9591b --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionPolicy.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheReportSubmissionPolicy { + <# + .SYNOPSIS + Caches Defender Report Submission Policies + + .DESCRIPTION + Calls Get-ReportSubmissionPolicy via New-ExoRequest and writes the + result into the CippReportingDB under Type 'ReportSubmissionPolicy'. + Used by CIS test 8.6.1 (security reporting destinations). + + .PARAMETER TenantFilter + The tenant to cache the report submission policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Report Submission Policies' -sev Debug + + $ReportSubmissionPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ReportSubmissionPolicy' + + if ($ReportSubmissionPolicies) { + $Data = @($ReportSubmissionPolicies) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Data.Count) Report Submission Policies" -sev Debug + } + $ReportSubmissionPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Report Submission Policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 new file mode 100644 index 000000000000..3181f7399c60 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 @@ -0,0 +1,42 @@ +function Set-CIPPDBCacheSPOTenant { + <# + .SYNOPSIS + Caches SharePoint Online tenant configuration + + .DESCRIPTION + Wraps Get-CIPPSPOTenant (which uses the SPO admin SOAP endpoint via + New-GraphPostRequest) and writes the result into the CippReportingDB + under Type 'SPOTenant'. The single configuration object is wrapped in + an array for consistency with the other ExoOrganizationConfig-style + single-row caches. + + .PARAMETER TenantFilter + The tenant to cache SPO tenant configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint Online tenant configuration' -sev Debug + + $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter -SkipCache + + if ($SPOTenant) { + $SPOTenantArray = @($SPOTenant) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenant' -Data $SPOTenantArray + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenant' -Data $SPOTenantArray -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint Online tenant configuration' -sev Debug + } + $SPOTenant = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SPO tenant configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 new file mode 100644 index 000000000000..1c74c5c3fa3f --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 @@ -0,0 +1,51 @@ +function Set-CIPPDBCacheSPOTenantSyncClientRestriction { + <# + .SYNOPSIS + Caches SharePoint Online tenant sync client restriction configuration + + .DESCRIPTION + Queries the SPO admin endpoint for the tenant sync client restriction + properties (TenantRestrictionEnabled, AllowedDomainList, BlockMacSync) + and writes the result into the CippReportingDB under Type + 'SPOTenantSyncClientRestriction'. These properties are part of the + SPOTenant object returned by Get-CIPPSPOTenant, so we surface them as + their own cache type for clarity and to mirror the cmdlet boundary in + Get-SPOTenantSyncClientRestriction. + + .PARAMETER TenantFilter + The tenant to cache the sync restriction configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint sync client restriction' -sev Debug + + $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter + + if ($SPOTenant) { + $SyncRestriction = [PSCustomObject]@{ + TenantRestrictionEnabled = $SPOTenant.TenantRestrictionEnabled + AllowedDomainList = $SPOTenant.AllowedDomainList + BlockMacSync = $SPOTenant.BlockMacSync + ConditionalAccessPolicy = $SPOTenant.ConditionalAccessPolicy + TenantFilter = $TenantFilter + } + $Data = @($SyncRestriction) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenantSyncClientRestriction' -Data $Data + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenantSyncClientRestriction' -Data $Data -Count + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint sync client restriction' -sev Debug + } + $SPOTenant = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SPO sync client restriction: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.md new file mode 100644 index 000000000000..e4dff0042d61 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.md @@ -0,0 +1,14 @@ +Microsoft 365 administrators should authenticate using cloud-only identities. Synchronized on-premises accounts inherit the security posture of the on-premises Active Directory; if the directory is compromised, every privileged identity is compromised with it. Privileged accounts should also avoid productivity licenses to reduce the attack surface (mail, Teams, browsing) on highly-targeted identities. + +**Remediation Action** + +1. Create a dedicated cloud-only account on the `.onmicrosoft.com` domain for each administrator. +2. Remove `onPremisesSyncEnabled` accounts from privileged role assignments. +3. Strip productivity licenses from administrative accounts (Entra ID P2 only is sufficient for most cases). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.1.1](https://www.cisecurity.org/benchmark/microsoft_365) +- [Protect M365 admin accounts](https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/protect-global-admins) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.ps1 new file mode 100644 index 000000000000..8fb0530f76f1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_1.ps1 @@ -0,0 +1,56 @@ +function Invoke-CippTestCIS_1_1_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.1.1) - Administrative accounts SHALL be cloud-only + + .DESCRIPTION + Privileged role holders should be cloud-only accounts (no onPremisesSyncEnabled), + use a *.onmicrosoft.com UPN, and have no licenses assigned that grant access to user + productivity applications. + #> + param($Tenant) + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignments = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignments' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $RoleAssignments -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, RoleAssignments, or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Administrative accounts are cloud-only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $PrivilegedRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + $PrivilegedAssignments = $RoleAssignments | Where-Object { $_.roleDefinitionId -in $PrivilegedRoleIds } + $PrivilegedUserIds = $PrivilegedAssignments.principalId | Select-Object -Unique + $PrivilegedUsers = $Users | Where-Object { $_.id -in $PrivilegedUserIds } + + if (-not $PrivilegedUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_1' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name 'Administrative accounts are cloud-only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $NonCompliant = $PrivilegedUsers | Where-Object { + $_.onPremisesSyncEnabled -eq $true -or + $_.userPrincipalName -notlike '*onmicrosoft.com' -or + ($_.assignedLicenses -and $_.assignedLicenses.Count -gt 0) + } + + if ($NonCompliant.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($PrivilegedUsers.Count) privileged users are cloud-only and unlicensed." + } else { + $Status = 'Failed' + $Result = "$($NonCompliant.Count) of $($PrivilegedUsers.Count) privileged user(s) are not cloud-only or are licensed:`n`n" + $Result += "| UPN | Synced | Licensed |`n| :-- | :----- | :------- |`n" + foreach ($U in ($NonCompliant | Select-Object -First 25)) { + $Result += "| $($U.userPrincipalName) | $([bool]$U.onPremisesSyncEnabled) | $([bool]($U.assignedLicenses.Count -gt 0)) |`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Administrative accounts are cloud-only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Administrative accounts are cloud-only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.md new file mode 100644 index 000000000000..54268d11c7b4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.md @@ -0,0 +1,16 @@ +If MFA, Conditional Access, or federation outage locks every administrator out of the tenant, an emergency access (break-glass) account is the only path back in. CIS recommends maintaining at least two such accounts to provide redundancy. + +**Remediation Action** + +1. Create at least two cloud-only Global Administrator accounts on `.onmicrosoft.com`. +2. Use long, randomly generated passwords stored in a physical safe. +3. Exclude these accounts from all Conditional Access policies *except* a CA policy that monitors and alerts on their use. +4. Register strong MFA (FIDO2) for the accounts but plan for MFA-disable scenarios. +5. Use a recognisable naming pattern (e.g. `breakglass1@.onmicrosoft.com`). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.1.2](https://www.cisecurity.org/benchmark/microsoft_365) +- [Manage emergency access accounts](https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 new file mode 100644 index 000000000000..81849198254d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestCIS_1_1_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.1.2) - At least two emergency access (break-glass) accounts SHALL be defined + + .DESCRIPTION + Identifies likely break-glass accounts by looking for cloud-only Global Administrator + accounts whose UPN contains common break-glass keywords. Manual verification is required. + #> + param($Tenant) + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignments = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignments' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $RoleAssignments -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, RoleAssignments, or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Two emergency access accounts have been defined' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $GA = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown 'Global Administrator role not found in tenant role definitions.' -Risk 'High' -Name 'Two emergency access accounts have been defined' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $GAUserIds = ($RoleAssignments | Where-Object { $_.roleDefinitionId -eq $GA.id }).principalId + $GAUsers = $Users | Where-Object { $_.id -in $GAUserIds } + $BreakGlassPattern = 'breakglass|break-glass|emergency|cipp-bg|bg-admin' + $LikelyBG = $GAUsers | Where-Object { $_.userPrincipalName -match $BreakGlassPattern -and $_.onPremisesSyncEnabled -ne $true } + + if ($LikelyBG.Count -ge 2) { + $Status = 'Passed' + $Result = "Found $($LikelyBG.Count) likely emergency access accounts. Verify they meet break-glass requirements (excluded from CA, monitored, MFA-registered).`n`n" + $Result += ($LikelyBG | ForEach-Object { "- $($_.userPrincipalName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = "Found $($LikelyBG.Count) cloud-only Global Administrator(s) matching break-glass naming. Required: at least 2.`n`nNote: This test only flags GA accounts whose UPN matches common break-glass keywords. If your break-glass accounts use a different naming convention this test will report a false negative." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Two emergency access accounts have been defined' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Two emergency access accounts have been defined' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.md new file mode 100644 index 000000000000..19c2a16a7e98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.md @@ -0,0 +1,14 @@ +Global Administrator is the most powerful role in Microsoft 365. Too few GA accounts removes redundancy when an admin is unavailable; too many widens the blast radius of a compromise. + +**Remediation Action** + +1. Audit the current GA list and remove any redundant assignments. +2. Migrate role-specific tasks to least-privileged roles (Exchange Admin, User Admin, etc.). +3. Use Privileged Identity Management (PIM) so GA is *eligible*, not *active*, for most accounts. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.1.3](https://www.cisecurity.org/benchmark/microsoft_365) +- [Microsoft Entra built-in roles](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 new file mode 100644 index 000000000000..d3463e10057c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestCIS_1_1_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.1.3) - Between two and four global admins SHALL be designated + #> + param($Tenant) + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignments = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignments' + + if (-not $Roles -or -not $RoleAssignments) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or RoleAssignments) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Between two and four global admins are designated' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $GA = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown 'Global Administrator role not found in tenant role definitions.' -Risk 'High' -Name 'Between two and four global admins are designated' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $GACount = (($RoleAssignments | Where-Object { $_.roleDefinitionId -eq $GA.id }).principalId | Select-Object -Unique).Count + + if ($GACount -ge 2 -and $GACount -le 4) { + $Status = 'Passed' + $Result = "Tenant has $GACount Global Administrator(s) — within the recommended 2–4 range." + } elseif ($GACount -lt 2) { + $Status = 'Failed' + $Result = "Tenant has only $GACount Global Administrator(s). At least 2 are required for redundancy." + } else { + $Status = 'Failed' + $Result = "Tenant has $GACount Global Administrator(s). Maximum recommended is 4 — reduce role spread to lower the attack surface." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Between two and four global admins are designated' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Between two and four global admins are designated' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.md new file mode 100644 index 000000000000..b0bd7c393915 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.md @@ -0,0 +1,14 @@ +Administrative accounts that hold productivity licenses (Exchange Online, SharePoint, Teams) expose far more attack surface than identity-only accounts. A single phishing email or a malicious browser extension can compromise an admin if their account also reads mail and browses Teams. + +**Remediation Action** + +1. Strip productivity licenses (Exchange, SharePoint, Teams, Office) from administrative accounts. +2. Assign only an Entra ID P1 / P2 (or EMS) license, sufficient for management activity. +3. Block sign-in to mailbox / OWA / Teams for these accounts via Conditional Access if licenses cannot be removed. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.1.4](https://www.cisecurity.org/benchmark/microsoft_365) +- [Tiered admin model](https://learn.microsoft.com/security/privileged-access-workstations/privileged-access-access-model) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 new file mode 100644 index 000000000000..a0e446fbbef4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 @@ -0,0 +1,52 @@ +function Invoke-CippTestCIS_1_1_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.1.4) - Administrative accounts SHALL use licenses with a reduced application footprint + #> + param($Tenant) + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignments = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignments' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $RoleAssignments -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, RoleAssignments, or Users) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Administrative accounts use licenses with a reduced application footprint' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + # SkuPartNumbers that are acceptable for admin accounts: Entra ID P1/P2 only + $AcceptableSkus = @('AAD_PREMIUM', 'AAD_PREMIUM_P2', 'EMS', 'EMSPREMIUM') + + $PrivilegedRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + $PrivilegedUserIds = ($RoleAssignments | Where-Object { $_.roleDefinitionId -in $PrivilegedRoleIds }).principalId | Select-Object -Unique + $PrivilegedUsers = $Users | Where-Object { $_.id -in $PrivilegedUserIds } + + $LicensedAdmins = $PrivilegedUsers | Where-Object { + $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 + } + + $NonCompliant = $LicensedAdmins | Where-Object { + $skus = ($_.assignedPlans | ForEach-Object { $_.servicePlanId }) -join ',' + $hasProductivity = $_.assignedPlans | Where-Object { $_.service -in @('exchange', 'SharePoint', 'MicrosoftCommunicationsOnline', 'TeamspaceAPI') -and $_.capabilityStatus -eq 'Enabled' } + [bool]$hasProductivity + } + + if (-not $LicensedAdmins) { + $Status = 'Passed' + $Result = 'No privileged users have licenses assigned.' + } elseif (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($LicensedAdmins.Count) licensed privileged user(s) hold only identity-only licenses (no productivity workloads enabled)." + } else { + $Status = 'Failed' + $Result = "$($NonCompliant.Count) privileged user(s) have productivity workloads (Exchange/SharePoint/Teams/Skype) enabled on their administrative accounts.`n`n" + $Result += ($NonCompliant | Select-Object -First 25 | ForEach-Object { "- $($_.userPrincipalName)" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Administrative accounts use licenses with a reduced application footprint' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_1_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Administrative accounts use licenses with a reduced application footprint' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.md new file mode 100644 index 000000000000..63ee089c2e32 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.md @@ -0,0 +1,12 @@ +Public Microsoft 365 groups expose their contents (files, conversations, calendar) to every user in the tenant. Without governance, sensitive material can be exposed to anyone with a tenant account. + +**Remediation Action** + +1. Audit each public group and confirm its contents are intentional. +2. Set unapproved groups to Private: `Set-UnifiedGroup -Identity -AccessType Private`. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.ps1 new file mode 100644 index 000000000000..d35d3a2e9d8a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_1.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_1_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.2.1) - Only organizationally managed/approved public groups SHALL exist + #> + param($Tenant) + + try { + $Groups = Get-CIPPTestData -TenantFilter $Tenant -Type 'Groups' + + if (-not $Groups) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Groups cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Only organizationally managed/approved public groups exist' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + return + } + + $PublicGroups = $Groups | Where-Object { $_.visibility -eq 'Public' -and ($_.groupTypes -contains 'Unified') } + + if (-not $PublicGroups -or $PublicGroups.Count -eq 0) { + $Status = 'Passed' + $Result = 'No public Microsoft 365 (Unified) groups found in the tenant.' + } else { + $Status = 'Failed' + $Result = "Found $($PublicGroups.Count) public Microsoft 365 group(s). Each public group's contents are visible to every user in the tenant — convert them to Private unless explicitly approved.`n`n" + $Result += "| Display Name | Mail |`n| :----------- | :--- |`n" + foreach ($G in ($PublicGroups | Select-Object -First 25)) { + $Result += "| $($G.displayName) | $($G.mail) |`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Only organizationally managed/approved public groups exist' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Only organizationally managed/approved public groups exist' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.md new file mode 100644 index 000000000000..5c1aeb5dccfc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.md @@ -0,0 +1,12 @@ +Shared mailboxes have a backing user account. If that account is enabled, an attacker who recovers or resets the password can sign in directly. Microsoft's recommendation is to keep shared mailbox sign-in disabled and access them through delegation only. + +**Remediation Action** + +1. Disable sign-in: `Update-MgUser -UserId -AccountEnabled:$false` for every SharedMailbox account. +2. Access shared mailboxes via delegated permissions or SendAs/SendOnBehalf. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.ps1 new file mode 100644 index 000000000000..4cfb17c8f705 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_2_2.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestCIS_1_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.2.2) - Sign-in to shared mailboxes SHALL be blocked + #> + param($Tenant) + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Mailboxes -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Mailboxes or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Sign-in to shared mailboxes is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + return + } + + $SharedMailboxes = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'SharedMailbox' } + + if (-not $SharedMailboxes -or $SharedMailboxes.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_2' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No shared mailboxes found.' -Risk 'High' -Name 'Sign-in to shared mailboxes is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + return + } + + $EnabledShared = @() + foreach ($SM in $SharedMailboxes) { + $User = $Users | Where-Object { $_.userPrincipalName -eq $SM.UserPrincipalName -or $_.id -eq $SM.ExternalDirectoryObjectId } | Select-Object -First 1 + if ($User -and $User.accountEnabled -eq $true) { + $EnabledShared += $User + } + } + + if ($EnabledShared.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($SharedMailboxes.Count) shared mailbox account(s) have sign-in blocked." + } else { + $Status = 'Failed' + $Result = "$($EnabledShared.Count) of $($SharedMailboxes.Count) shared mailbox(es) have sign-in enabled:`n`n" + $Result += ($EnabledShared | Select-Object -First 25 | ForEach-Object { "- $($_.userPrincipalName)" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Sign-in to shared mailboxes is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Sign-in to shared mailboxes is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.md new file mode 100644 index 000000000000..e185b0977917 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.md @@ -0,0 +1,14 @@ +Forced password rotation drives users to predictable patterns (Summer2025!, Summer2026!) that are easier for attackers to guess. NIST SP 800-63B and Microsoft now recommend never expiring passwords, paired with strong MFA and breach detection. + +**Remediation Action** + +```powershell +Update-MgDomain -DomainId -PasswordValidityPeriodInDays 2147483647 -PasswordNotificationWindowInDays 14 +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.1](https://www.cisecurity.org/benchmark/microsoft_365) +- [Set passwords to never expire](https://learn.microsoft.com/microsoft-365/admin/manage/set-password-to-never-expire) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.ps1 new file mode 100644 index 000000000000..3b62354ad116 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_1.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_1_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.1) - Password expiration policy SHALL be set to 'never expire' + #> + param($Tenant) + + try { + $Domains = Get-CIPPTestData -TenantFilter $Tenant -Type 'Domains' + + if (-not $Domains) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Domains cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'Password expiration policy' is set to never expire" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + return + } + + $Failing = $Domains | Where-Object { $_.passwordValidityPeriodInDays -lt 2147483647 -and $_.passwordValidityPeriodInDays -gt 0 } + + if (-not $Failing -or $Failing.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Domains.Count) domain(s) have password expiration disabled (passwordValidityPeriodInDays = 2147483647)." + } else { + $Status = 'Failed' + $Result = "$($Failing.Count) domain(s) still expire passwords:`n`n| Domain | Validity (days) |`n| :----- | :-------------- |`n" + foreach ($D in $Failing) { + $Result += "| $($D.id) | $($D.passwordValidityPeriodInDays) |`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Password expiration policy' is set to never expire" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Password expiration policy' is set to never expire" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.md new file mode 100644 index 000000000000..c23b86182aea --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.md @@ -0,0 +1,16 @@ +A long-lived session token on an unmanaged device is a high-value target. CIS recommends Conditional Access enforce a sign-in frequency of 3 hours or less for these devices. + +**Remediation Action** + +Create a Conditional Access policy: +- Users: All users (or pilot group) +- Cloud apps: All +- Conditions: Device filter — exclude compliant / hybrid joined +- Session: Sign-in frequency 3 hours, persistent browser = never + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.2](https://www.cisecurity.org/benchmark/microsoft_365) +- [Configure sign-in frequency](https://learn.microsoft.com/entra/identity/conditional-access/concept-session-lifetime) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.ps1 new file mode 100644 index 000000000000..5c62307bce01 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_2.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestCIS_1_3_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.2) - Idle session timeout SHALL be 3 hours or less for unmanaged devices + #> + param($Tenant) + + try { + $CAPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CAPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'Idle session timeout' is set to '3 hours or less' for unmanaged devices" -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Session Management' + return + } + + # CIS recommends a CA policy for unmanaged devices that enforces session signInFrequency <= 3 hours + $Matching = $CAPolicies | Where-Object { + $_.state -eq 'enabled' -and + $_.sessionControls -and + $_.sessionControls.signInFrequency -and + $_.sessionControls.signInFrequency.isEnabled -eq $true -and + ( + ($_.sessionControls.signInFrequency.type -eq 'hours' -and [int]$_.sessionControls.signInFrequency.value -le 3) -or + ($_.sessionControls.signInFrequency.type -eq 'days' -and [int]$_.sessionControls.signInFrequency.value -eq 0) + ) + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies enforce sign-in frequency of 3 hours or less:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy enforces a sign-in frequency of 3 hours or less. Create a CA policy targeting unmanaged devices with signInFrequency configured.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Idle session timeout' is set to '3 hours or less' for unmanaged devices" -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Session Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Idle session timeout' is set to '3 hours or less' for unmanaged devices" -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Session Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.md new file mode 100644 index 000000000000..645b2677573d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.md @@ -0,0 +1,13 @@ +External calendar sharing exposes meeting subjects, attendees and locations to people outside the organisation. CIS recommends disabling the default sharing policy. + +**Remediation Action** + +```powershell +Get-SharingPolicy | Set-SharingPolicy -Enabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.ps1 new file mode 100644 index 000000000000..bf9bb37c1c92 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_3.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_1_3_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.3) - 'External sharing' of calendars SHALL NOT be available + #> + param($Tenant) + + try { + $SharingPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoSharingPolicy' + + if (-not $SharingPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSharingPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'External sharing' of calendars is not available" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $DefaultPolicy = $SharingPolicies | Where-Object { $_.Default -eq $true } | Select-Object -First 1 + if (-not $DefaultPolicy) { $DefaultPolicy = $SharingPolicies | Select-Object -First 1 } + + $CalendarSharing = $DefaultPolicy.Domains | Where-Object { $_ -match 'CalendarSharing' } + + if (-not $CalendarSharing -or $DefaultPolicy.Enabled -eq $false) { + $Status = 'Passed' + $Result = "Default sharing policy '$($DefaultPolicy.Name)' does not allow external calendar sharing (Enabled: $($DefaultPolicy.Enabled))." + } else { + $Status = 'Failed' + $Result = "Default sharing policy '$($DefaultPolicy.Name)' is enabled and allows external calendar sharing.`n`n**Domains entries:**`n" + $Result += ($DefaultPolicy.Domains | ForEach-Object { "- $_" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'External sharing' of calendars is not available" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'External sharing' of calendars is not available" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.md new file mode 100644 index 000000000000..a6aa307e1791 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.md @@ -0,0 +1,14 @@ +Allowing users to access the Office Store and start trials lets non-vetted add-ins enter the tenant. Trial subscriptions can also bypass procurement and create unmanaged data sprawl. + +**Remediation Action** + +```powershell +$body = @{ Settings = @{ isAppAndServicesTrialEnabled = $false; isOfficeStoreEnabled = $false } } | ConvertTo-Json +Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/beta/admin/appsAndServices' -Body $body +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.ps1 new file mode 100644 index 000000000000..e5a3e2f9dce7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_4.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestCIS_1_3_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.4) - 'User owned apps and services' SHALL be restricted + #> + param($Tenant) + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + + if (-not $Settings) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Settings cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'User owned apps and services' is restricted" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + return + } + + $AppsAndServices = $Settings | Where-Object { $_.id -eq 'appsAndServices' -or $_.PSObject.Properties.Name -contains 'isOfficeStoreEnabled' } | Select-Object -First 1 + + if (-not $AppsAndServices) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'appsAndServices settings not present in the Settings cache.' -Risk 'Medium' -Name "'User owned apps and services' is restricted" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + return + } + + $StoreEnabled = $AppsAndServices.isOfficeStoreEnabled + $TrialsEnabled = $AppsAndServices.isAppAndServicesTrialEnabled + + if ($StoreEnabled -eq $false -and $TrialsEnabled -eq $false) { + $Status = 'Passed' + $Result = "Office Store and trials are both disabled.`n`n- isOfficeStoreEnabled: false`n- isAppAndServicesTrialEnabled: false" + } else { + $Status = 'Failed' + $Result = "User owned apps and services are not fully restricted.`n`n- isOfficeStoreEnabled: $StoreEnabled (expected: false)`n- isAppAndServicesTrialEnabled: $TrialsEnabled (expected: false)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'User owned apps and services' is restricted" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'User owned apps and services' is restricted" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.md new file mode 100644 index 000000000000..b6bf57a397be --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.md @@ -0,0 +1,11 @@ +Microsoft Forms is regularly abused for credential phishing. Internal phishing scanning detects forms that ask for sensitive information (passwords, MFA codes) and blocks delivery. + +**Remediation Action** + +PATCH `https://graph.microsoft.com/beta/admin/forms` with `{ "settings": { "isInOrgFormsPhishingScanEnabled": true } }`. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.ps1 new file mode 100644 index 000000000000..5b4a9f8f90e2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_5.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_1_3_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.5) - Internal phishing protection for Forms SHALL be enabled + #> + param($Tenant) + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + + if (-not $Settings) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Settings cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Internal phishing protection for Forms is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Phishing Protection' + return + } + + $Forms = $Settings | Where-Object { $_.PSObject.Properties.Name -contains 'isInOrgFormsPhishingScanEnabled' } | Select-Object -First 1 + + if (-not $Forms) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Forms phishing scan setting not in cache.' -Risk 'Medium' -Name 'Internal phishing protection for Forms is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Phishing Protection' + return + } + + if ($Forms.isInOrgFormsPhishingScanEnabled -eq $true) { + $Status = 'Passed' + $Result = 'Internal Forms phishing scan is enabled.' + } else { + $Status = 'Failed' + $Result = "Forms phishing scan is disabled (isInOrgFormsPhishingScanEnabled: $($Forms.isInOrgFormsPhishingScanEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Internal phishing protection for Forms is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Phishing Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Internal phishing protection for Forms is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Phishing Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.md new file mode 100644 index 000000000000..8818cc84236d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.md @@ -0,0 +1,13 @@ +Customer Lockbox requires explicit organisational approval before Microsoft engineers can access tenant data during a support engagement. Without it, an engineer can access content silently. + +**Remediation Action** + +```powershell +Set-OrganizationConfig -CustomerLockBoxEnabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.ps1 new file mode 100644 index 000000000000..e8222109ffd8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_6.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_1_3_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.6) - Customer Lockbox SHALL be enabled + #> + param($Tenant) + + try { + $OrgConfig = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $OrgConfig) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Customer Lockbox is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $OrgConfig | Select-Object -First 1 + + if ($Cfg.CustomerLockBoxEnabled -eq $true) { + $Status = 'Passed' + $Result = 'Customer Lockbox is enabled.' + } else { + $Status = 'Failed' + $Result = "Customer Lockbox is disabled (CustomerLockBoxEnabled: $($Cfg.CustomerLockBoxEnabled)). Requires E5 or Compliance add-on." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Customer Lockbox is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Customer Lockbox is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.md new file mode 100644 index 000000000000..78c8b4e4212e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.md @@ -0,0 +1,15 @@ +Third-party providers (Dropbox, Box, Google Drive) integrated with Microsoft 365 on the web allow data to leave the tenant boundary. Disable the integration unless explicitly required. + +**Remediation Action** + +Disable the `Microsoft 365 on the web` service principal (appId `c1f33bc0-bdb4-4248-ba9b-096807ddb43e`): + +```powershell +Update-MgServicePrincipal -ServicePrincipalId -AccountEnabled:$false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.ps1 new file mode 100644 index 000000000000..8b78b2779132 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_7.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_1_3_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.7) - 'Third-party storage services' SHALL be restricted in 'Microsoft 365 on the web' + #> + param($Tenant) + + try { + $ServicePrincipals = Get-CIPPTestData -TenantFilter $Tenant -Type 'ServicePrincipals' + + if (-not $ServicePrincipals) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ServicePrincipals cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'Third-party storage services' are restricted in 'Microsoft 365 on the web'" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + # appId of "Microsoft 365 on the web" service principal + $AppId = 'c1f33bc0-bdb4-4248-ba9b-096807ddb43e' + $SP = $ServicePrincipals | Where-Object { $_.appId -eq $AppId } | Select-Object -First 1 + + if (-not $SP) { + $Status = 'Passed' + $Result = 'The Microsoft 365 on the web service principal is not present in the tenant — third-party storage cannot be enabled.' + } elseif ($SP.accountEnabled -eq $false) { + $Status = 'Passed' + $Result = "The Microsoft 365 on the web service principal exists but is disabled (accountEnabled: $($SP.accountEnabled))." + } else { + $Status = 'Failed' + $Result = 'The Microsoft 365 on the web service principal is enabled. Disable it to restrict third-party storage providers.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Third-party storage services' are restricted in 'Microsoft 365 on the web'" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Third-party storage services' are restricted in 'Microsoft 365 on the web'" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.md new file mode 100644 index 000000000000..d25727c0730e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.md @@ -0,0 +1,12 @@ +Sway can publish content to the public web. CIS recommends restricting external sharing. + +**Remediation Action** + +1. Microsoft 365 admin centre > Settings > Org settings > Sway. +2. Uncheck "Let people in your organization share their sways with people outside your organization". + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.8](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.ps1 new file mode 100644 index 000000000000..f6719d06461a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_8.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_1_3_8 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.8) - Sways SHALL NOT be shared with people outside of the organization + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_8' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Sways cannot be shared with people outside of your organization' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.md new file mode 100644 index 000000000000..2d8606ab44c4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.md @@ -0,0 +1,21 @@ +Microsoft Bookings can be abused to create authoritative-looking external email addresses (e.g. a compromised user creating a fake `ceo@.com` Bookings mailbox to impersonate the CEO). CIS recommends restricting Bookings to a small set of approved users. + +**Remediation Action** + +Restrict via the default OWA policy: + +```powershell +Set-OwaMailboxPolicy "OwaMailboxPolicy-Default" -BookingsMailboxCreationEnabled:$false +``` + +Or disable organisation-wide (more restrictive, also passes): + +```powershell +Set-OrganizationConfig -BookingsEnabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 1.3.9](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.ps1 new file mode 100644 index 000000000000..4314964a0ddf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_3_9.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_1_3_9 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (1.3.9) - Shared bookings pages SHALL be restricted to select users + #> + param($Tenant) + + try { + $OrgConfig = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $OrgConfig) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_9' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Shared bookings pages are restricted to select users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Data Protection' + return + } + + $Cfg = $OrgConfig | Select-Object -First 1 + + if ($Cfg.BookingsEnabled -eq $false) { + $Status = 'Passed' + $Result = 'Bookings is disabled at the organisation level (BookingsEnabled: false) — a more restrictive and compliant configuration.' + } else { + $Status = 'Failed' + $Result = "Bookings is enabled at the organisation level (BookingsEnabled: true). Either disable it organisation-wide, or set BookingsMailboxCreationEnabled = $false on the default OWA mailbox policy and assign Bookings access to specific users via a separate policy." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_9' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Shared bookings pages are restricted to select users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_1_3_9' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Shared bookings pages are restricted to select users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.md new file mode 100644 index 000000000000..627a3a2df691 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.md @@ -0,0 +1,13 @@ +Safe Links scans URLs in email, Teams, and Office documents at click time, blocking access to malicious URLs even if they were safe at delivery. + +**Remediation Action** + +```powershell +New-SafeLinksPolicy -Name 'Default Safe Links' -EnableSafeLinksForEmail $true -EnableSafeLinksForTeams $true -EnableSafeLinksForOffice $true -ScanUrls $true -TrackClicks $true -AllowClickThrough $false -DisableUrlRewrite $false -DeliverMessageAfterScan $true -EnableForInternalSenders $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 new file mode 100644 index 000000000000..eb17dd415e58 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_1.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestCIS_2_1_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.1) - Safe Links for Office Applications SHALL be enabled + #> + param($Tenant) + + try { + $SafeLinks = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoSafeLinksPolicies' + + if (-not $SafeLinks) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeLinksPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Compliant = $SafeLinks | Where-Object { + $_.EnableSafeLinksForEmail -eq $true -and + $_.EnableSafeLinksForTeams -eq $true -and + $_.EnableSafeLinksForOffice -eq $true -and + $_.TrackClicks -eq $true -and + $_.AllowClickThrough -eq $false -and + $_.ScanUrls -eq $true -and + $_.EnableForInternalSenders -eq $true -and + $_.DeliverMessageAfterScan -eq $true -and + $_.DisableUrlRewrite -eq $false + } + + if ($Compliant) { + $Status = 'Passed' + $Result = "$($Compliant.Count) Safe Links policy/policies meet all CIS requirements:`n`n" + $Result += ($Compliant | ForEach-Object { "- $($_.Name)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = "No Safe Links policy meets every CIS requirement (Email/Teams/Office on, ScanUrls/TrackClicks on, AllowClickThrough off, DisableUrlRewrite off, DeliverMessageAfterScan on, EnableForInternalSenders on)." + if ($SafeLinks) { + $Result += "`n`n**Existing policies:**`n" + $Result += ($SafeLinks | ForEach-Object { "- $($_.Name): SafeLinksForEmail=$($_.EnableSafeLinksForEmail), Office=$($_.EnableSafeLinksForOffice), Teams=$($_.EnableSafeLinksForTeams)" }) -join "`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Safe Links for Office Applications is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.md new file mode 100644 index 000000000000..8118a139d999 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.md @@ -0,0 +1,17 @@ +DMARC tells receiving servers what to do with mail that fails SPF/DKIM and provides reporting of authentication failures. Without DMARC the protective value of SPF and DKIM is significantly reduced. + +**Remediation Action** + +Publish a TXT record at `_dmarc.` with at least: + +``` +v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@ +``` + +Move to `p=reject` after monitoring DMARC reports. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.10](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.ps1 new file mode 100644 index 000000000000..4e0c321a5f93 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_10.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_2_1_10 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.10) - DMARC Records for all Exchange Online domains SHALL be published + #> + param($Tenant) + + try { + $Results = Get-CIPPDomainAnalyser -TenantFilter $Tenant + + if (-not $Results) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_10' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No Domain Analyser results found for this tenant. Run the CIPP Domain Analyser to populate domain health data.' -Risk 'High' -Name 'DMARC Records for all Exchange Online domains are published' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + # CIS expects a DMARC record with p=quarantine or p=reject + $Acceptable = @('quarantine', 'reject') + $Failing = $Results | Where-Object { $_.DMARCPresent -ne $true -or $_.DMARCActionPolicy -notin $Acceptable } + + if (-not $Failing -or $Failing.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Results.Count) domain(s) have a DMARC record with p=quarantine or p=reject." + } else { + $Status = 'Failed' + $Result = "$($Failing.Count) of $($Results.Count) domain(s) are missing a compliant DMARC record:`n`n| Domain | DMARCPresent | DMARCActionPolicy |`n| :----- | :----------- | :---------------- |`n" + foreach ($D in ($Failing | Select-Object -First 25)) { + $Result += "| $($D.Domain) | $($D.DMARCPresent) | $($D.DMARCActionPolicy) |`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_10' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'DMARC Records for all Exchange Online domains are published' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_10' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'DMARC Records for all Exchange Online domains are published' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.md new file mode 100644 index 000000000000..d5e37d98ee99 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.md @@ -0,0 +1,11 @@ +CIS provides a comprehensive list of ~96 file extensions that should be blocked by the malware filter. The default Microsoft list is much smaller. + +**Remediation Action** + +Use the CIS comprehensive file types list as the `FileTypes` parameter on the malware filter policy. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.11](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 new file mode 100644 index 000000000000..514175528d8b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_11.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_2_1_11 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.11) - Comprehensive attachment filtering SHALL be applied + #> + param($Tenant) + + try { + $Malware = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoMalwareFilterPolicies' + + if (-not $Malware) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_11' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoMalwareFilterPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Comprehensive attachment filtering is applied' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Default = $Malware | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Malware | Select-Object -First 1 } + + # CIS recommends a minimum of 96 file types (the comprehensive list) + $FileTypeCount = ($Default.FileTypes | Measure-Object).Count + + if ($Default.EnableFileFilter -eq $true -and $FileTypeCount -ge 96) { + $Status = 'Passed' + $Result = "Comprehensive attachment filtering is applied — $FileTypeCount file types blocked on '$($Default.Identity)'." + } else { + $Status = 'Failed' + $Result = "Attachment filter on '$($Default.Identity)' is not comprehensive (EnableFileFilter: $($Default.EnableFileFilter), FileTypes count: $FileTypeCount, expected >= 96)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_11' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Comprehensive attachment filtering is applied' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_11' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Comprehensive attachment filtering is applied' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.md new file mode 100644 index 000000000000..5c1efee024ae --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.md @@ -0,0 +1,13 @@ +IPs on the connection filter allow list bypass spam, spoof and authentication checks. CIS recommends keeping this list empty. + +**Remediation Action** + +```powershell +Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @() +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.12](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.ps1 new file mode 100644 index 000000000000..f7faeaa648e1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_12.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_1_12 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.12) - Connection filter IP allow list SHALL NOT be used + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_12' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Connection filter IP allow list is not used' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.md new file mode 100644 index 000000000000..1990039cb7f4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.md @@ -0,0 +1,13 @@ +The connection filter safe list bypasses content filtering for senders Microsoft considers reputable. CIS recommends turning it off so all mail is filtered consistently. + +**Remediation Action** + +```powershell +Set-HostedConnectionFilterPolicy -Identity Default -EnableSafeList $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.13](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.ps1 new file mode 100644 index 000000000000..4e03bcd89592 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_13.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_1_13 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.13) - Connection filter safe list SHALL be off + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_13' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Connection filter safe list is off' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.md new file mode 100644 index 000000000000..9fd5737a6fed --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.md @@ -0,0 +1,13 @@ +An allowed sender domain bypasses spam, malware and phishing checks for that domain. Attackers regularly spoof allowlisted domains. + +**Remediation Action** + +```powershell +Set-HostedContentFilterPolicy -Identity Default -AllowedSenderDomains @() +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.14](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.ps1 new file mode 100644 index 000000000000..6906ce5a406a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_14.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_2_1_14 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.14) - Inbound anti-spam policies SHALL NOT contain allowed domains + #> + param($Tenant) + + try { + $Inbound = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedContentFilterPolicy' + + if (-not $Inbound) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_14' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoHostedContentFilterPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Inbound anti-spam policies do not contain allowed domains' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Offending = $Inbound | Where-Object { $_.AllowedSenderDomains -and $_.AllowedSenderDomains.Count -gt 0 } + + if (-not $Offending) { + $Status = 'Passed' + $Result = "All $($Inbound.Count) inbound anti-spam policy/policies have no allowed sender domains." + } else { + $Status = 'Failed' + $Result = "$($Offending.Count) inbound anti-spam policy/policies have allowed sender domains configured:`n`n" + foreach ($P in $Offending) { + $Result += "- **$($P.Identity)**: $($P.AllowedSenderDomains -join ', ')`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_14' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Inbound anti-spam policies do not contain allowed domains' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_14' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Inbound anti-spam policies do not contain allowed domains' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.md new file mode 100644 index 000000000000..a6106ac41ff4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.md @@ -0,0 +1,13 @@ +A compromised account that bursts thousands of messages will damage the tenant's outbound reputation. Recipient limits + a BlockUser action contain the blast radius automatically. + +**Remediation Action** + +```powershell +Set-HostedOutboundSpamFilterPolicy -Identity Default -RecipientLimitExternalPerHour 500 -RecipientLimitInternalPerHour 1000 -RecipientLimitPerDay 1000 -ActionWhenThresholdReached BlockUser +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.15](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.ps1 new file mode 100644 index 000000000000..b6a4bbb8f8da --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_15.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestCIS_2_1_15 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.15) - Outbound anti-spam message limits SHALL be in place + #> + param($Tenant) + + try { + $Outbound = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedOutboundSpamFilterPolicy' + + if (-not $Outbound) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_15' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoHostedOutboundSpamFilterPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Outbound anti-spam message limits are in place' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Default = $Outbound | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Outbound | Select-Object -First 1 } + + $External = [int]$Default.RecipientLimitExternalPerHour + $Internal = [int]$Default.RecipientLimitInternalPerHour + $Daily = [int]$Default.RecipientLimitPerDay + $Action = $Default.ActionWhenThresholdReached + + $Pass = $External -gt 0 -and $External -le 500 -and + $Internal -gt 0 -and $Internal -le 1000 -and + $Daily -gt 0 -and $Daily -le 1000 -and + $Action -in @('BlockUser', 'BlockUserForToday') + + if ($Pass) { + $Status = 'Passed' + $Result = "Outbound anti-spam limits are within CIS recommendations on '$($Default.Identity)'.`n`n- External/hr: $External`n- Internal/hr: $Internal`n- Daily: $Daily`n- Action: $Action" + } else { + $Status = 'Failed' + $Result = "Outbound limits on '$($Default.Identity)' do not meet CIS recommended values (External<=500/hr, Internal<=1000/hr, Daily<=1000, Action=BlockUser):`n`n- External/hr: $External`n- Internal/hr: $Internal`n- Daily: $Daily`n- Action: $Action" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_15' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Outbound anti-spam message limits are in place' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_15' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Outbound anti-spam message limits are in place' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.md new file mode 100644 index 000000000000..ba645fd493d5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.md @@ -0,0 +1,13 @@ +The Common Attachment Types Filter blocks attachments with extensions known to be commonly used for malware (`.exe`, `.dll`, `.ace`, `.bat`, etc.). + +**Remediation Action** + +```powershell +Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.ps1 new file mode 100644 index 000000000000..da8173c28853 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_2.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_2_1_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.2) - Common Attachment Types Filter SHALL be enabled + #> + param($Tenant) + + try { + $Malware = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoMalwareFilterPolicies' + + if (-not $Malware) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoMalwareFilterPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Common Attachment Types Filter is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Default = $Malware | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Malware | Select-Object -First 1 } + + if ($Default.EnableFileFilter -eq $true) { + $Status = 'Passed' + $Result = "Common Attachment Types Filter is enabled on '$($Default.Identity)' with $($Default.FileTypes.Count) file types blocked." + } else { + $Status = 'Failed' + $Result = "Common Attachment Types Filter is disabled on '$($Default.Identity)' (EnableFileFilter: $($Default.EnableFileFilter))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Common Attachment Types Filter is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Common Attachment Types Filter is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.md new file mode 100644 index 000000000000..b10b89eca5f0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.md @@ -0,0 +1,13 @@ +An internal user sending malware almost always means a compromised account. Admin notifications make compromise visible immediately. + +**Remediation Action** + +```powershell +Set-MalwareFilterPolicy -Identity Default -EnableInternalSenderAdminNotifications $true -InternalSenderAdminAddress 'soc@contoso.com' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 new file mode 100644 index 000000000000..9e6a04221997 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_3.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_2_1_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.3) - Notifications for internal users sending malware SHALL be enabled + #> + param($Tenant) + + try { + $Malware = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoMalwareFilterPolicies' + + if (-not $Malware) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoMalwareFilterPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Notifications for internal users sending malware is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Default = $Malware | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Malware | Select-Object -First 1 } + + $HasRecipients = $Default.EnableInternalSenderAdminNotifications -eq $true -and -not [string]::IsNullOrWhiteSpace($Default.InternalSenderAdminAddress) + + if ($HasRecipients) { + $Status = 'Passed' + $Result = "Internal sender admin notifications enabled on '$($Default.Identity)'. Recipient: $($Default.InternalSenderAdminAddress)." + } else { + $Status = 'Failed' + $Result = "Internal sender admin notifications are not configured on '$($Default.Identity)'.`n`n- EnableInternalSenderAdminNotifications: $($Default.EnableInternalSenderAdminNotifications)`n- InternalSenderAdminAddress: '$($Default.InternalSenderAdminAddress)'" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Notifications for internal users sending malware is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Notifications for internal users sending malware is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.md new file mode 100644 index 000000000000..c13563d32200 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.md @@ -0,0 +1,14 @@ +Safe Attachments detonates email attachments in a sandbox before delivery and blocks malware that signature scanning misses. + +**Remediation Action** + +```powershell +New-SafeAttachmentPolicy -Name 'Default Safe Attachments' -Enable $true -Action Block +New-SafeAttachmentRule -Name 'Default Safe Attachments' -SafeAttachmentPolicy 'Default Safe Attachments' -RecipientDomainIs +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 new file mode 100644 index 000000000000..f2635d253a58 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_4.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_2_1_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.4) - Safe Attachments policy SHALL be enabled + #> + param($Tenant) + + try { + $SA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoSafeAttachmentPolicies' + + if (-not $SA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoSafeAttachmentPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Compliant = $SA | Where-Object { $_.Enable -eq $true -and $_.Action -in @('Block', 'Replace', 'DynamicDelivery') } + + if ($Compliant) { + $Status = 'Passed' + $Result = "$($Compliant.Count) Safe Attachments policy/policies are enabled with a blocking action:`n`n" + $Result += ($Compliant | ForEach-Object { "- $($_.Name) (Action: $($_.Action))" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Safe Attachments policy with a blocking action (Block/Replace/DynamicDelivery) was found.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Safe Attachments policy is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.md new file mode 100644 index 000000000000..b1cb41752bd4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.md @@ -0,0 +1,13 @@ +Files uploaded to SharePoint, OneDrive and Teams should be scanned by Safe Attachments to prevent malware spread within the collaboration platform. + +**Remediation Action** + +```powershell +Set-AtpPolicyForO365 -EnableATPForSPOTeamsODB $true -EnableSafeDocs $true -AllowSafeDocsOpen $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 new file mode 100644 index 000000000000..25d6134d0eb0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_5.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestCIS_2_1_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.5) - Safe Attachments for SharePoint, OneDrive, and Microsoft Teams SHALL be enabled + #> + param($Tenant) + + try { + $Atp = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAtpPolicyForO365' + + if (-not $Atp) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoAtpPolicyForO365 cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Cfg = $Atp | Select-Object -First 1 + + $Required = @{ + EnableATPForSPOTeamsODB = $true + EnableSafeDocs = $true + AllowSafeDocsOpen = $false + } + $Failures = @() + foreach ($key in $Required.Keys) { + if ($Cfg.$key -ne $Required[$key]) { + $Failures += "$key = $($Cfg.$key) (expected $($Required[$key]))" + } + } + + if ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = 'Safe Attachments for SharePoint, OneDrive and Teams is fully enabled.' + } else { + $Status = 'Failed' + $Result = "Configuration mismatch on ATP policy:`n`n" + (($Failures | ForEach-Object { "- $_" }) -join "`n") + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Safe Attachments for SharePoint, OneDrive, and Teams is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.md new file mode 100644 index 000000000000..fcd3c4ac1143 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.md @@ -0,0 +1,13 @@ +A user that suddenly starts sending spam is almost always compromised. Admin notifications surface this immediately. + +**Remediation Action** + +```powershell +Set-HostedOutboundSpamFilterPolicy -Identity Default -NotifyOutboundSpam $true -NotifyOutboundSpamRecipients 'soc@contoso.com' -BccSuspiciousOutboundMail $true -BccSuspiciousOutboundAdditionalRecipients 'soc@contoso.com' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.ps1 new file mode 100644 index 000000000000..c98b48c42893 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_6.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_2_1_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.6) - Exchange Online Spam Policies SHALL be set to notify administrators + #> + param($Tenant) + + try { + $Outbound = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedOutboundSpamFilterPolicy' + + if (-not $Outbound) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoHostedOutboundSpamFilterPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Exchange Online Spam Policies are set to notify administrators' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Default = $Outbound | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Outbound | Select-Object -First 1 } + + $Compliant = $Default.NotifyOutboundSpam -eq $true -and + $Default.BccSuspiciousOutboundMail -eq $true -and + $Default.NotifyOutboundSpamRecipients -and ($Default.NotifyOutboundSpamRecipients.Count -gt 0) -and + $Default.BccSuspiciousOutboundAdditionalRecipients -and ($Default.BccSuspiciousOutboundAdditionalRecipients.Count -gt 0) + + if ($Compliant) { + $Status = 'Passed' + $Result = "Outbound spam notifications are configured on '$($Default.Identity)'. Notify recipients: $($Default.NotifyOutboundSpamRecipients -join ', ')." + } else { + $Status = 'Failed' + $Result = "Outbound spam notifications are not fully configured on '$($Default.Identity)':`n`n- NotifyOutboundSpam: $($Default.NotifyOutboundSpam)`n- BccSuspiciousOutboundMail: $($Default.BccSuspiciousOutboundMail)`n- NotifyOutboundSpamRecipients: $($Default.NotifyOutboundSpamRecipients -join ', ')`n- BccSuspiciousOutboundAdditionalRecipients: $($Default.BccSuspiciousOutboundAdditionalRecipients -join ', ')" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Exchange Online Spam Policies are set to notify administrators' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Exchange Online Spam Policies are set to notify administrators' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.md new file mode 100644 index 000000000000..c24b59c56e4e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.md @@ -0,0 +1,15 @@ +A custom anti-phishing policy enables impersonation, mailbox intelligence and spoof intelligence protections beyond the defaults. + +**Remediation Action** + +Use the CIPP `AntiPhishPolicy` standard or: + +```powershell +New-AntiPhishPolicy -Name 'Default Anti-Phishing' -Enabled $true -PhishThresholdLevel 2 -EnableMailboxIntelligence $true -EnableMailboxIntelligenceProtection $true -EnableSpoofIntelligence $true -EnableFirstContactSafetyTips $true -EnableSimilarUsersSafetyTips $true -EnableSimilarDomainsSafetyTips $true -EnableUnusualCharactersSafetyTips $true -TargetedUserProtectionAction Quarantine -MailboxIntelligenceProtectionAction Quarantine -TargetedDomainProtectionAction Quarantine -AuthenticationFailAction Quarantine +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.ps1 new file mode 100644 index 000000000000..568d6de110d1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_7.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestCIS_2_1_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.7) - An anti-phishing policy SHALL be created + #> + param($Tenant) + + try { + $AntiPhish = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAntiPhishPolicies' + + if (-not $AntiPhish) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoAntiPhishPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'An anti-phishing policy has been created' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + return + } + + $Compliant = $AntiPhish | Where-Object { + $_.Enabled -eq $true -and + $_.PhishThresholdLevel -ge 2 -and + $_.EnableMailboxIntelligenceProtection -eq $true -and + $_.EnableMailboxIntelligence -eq $true -and + $_.EnableSpoofIntelligence -eq $true -and + $_.TargetedUserProtectionAction -in @('Quarantine', 'MoveToJmf') -and + $_.MailboxIntelligenceProtectionAction -in @('Quarantine', 'MoveToJmf') -and + $_.TargetedDomainProtectionAction -in @('Quarantine', 'MoveToJmf') -and + $_.AuthenticationFailAction -in @('Quarantine', 'MoveToJmf') -and + $_.EnableFirstContactSafetyTips -eq $true -and + $_.EnableSimilarUsersSafetyTips -eq $true -and + $_.EnableSimilarDomainsSafetyTips -eq $true -and + $_.EnableUnusualCharactersSafetyTips -eq $true + } + + if ($Compliant) { + $Status = 'Passed' + $Result = "$($Compliant.Count) anti-phishing policy/policies meet CIS L2 requirements:`n`n" + $Result += ($Compliant | ForEach-Object { "- $($_.Name)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No anti-phishing policy meets every CIS requirement (PhishThreshold>=2, all impersonation/intelligence/safety tips on, quarantine actions configured).' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'An anti-phishing policy has been created' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'An anti-phishing policy has been created' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.md new file mode 100644 index 000000000000..a160398f7b5f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.md @@ -0,0 +1,12 @@ +SPF lists the IPs / hosts authorised to send mail for a domain. Without SPF, attackers can spoof mail from your domain and pass at receiving servers. + +**Remediation Action** + +Publish a TXT record at `` with `v=spf1 include:spf.protection.outlook.com -all` (or your appropriate include list). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.8](https://www.cisecurity.org/benchmark/microsoft_365) +- [CIPP Domain Analyser](https://docs.cipp.app/user-documentation/tenant/standards/list-standards/domains-analyser) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.ps1 new file mode 100644 index 000000000000..82d0881cbe1a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_8.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_2_1_8 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.8) - SPF records SHALL be published for all Exchange Domains + #> + param($Tenant) + + try { + $Results = Get-CIPPDomainAnalyser -TenantFilter $Tenant + + if (-not $Results) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_8' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No Domain Analyser results found for this tenant. Run the CIPP Domain Analyser to populate domain health data.' -Risk 'High' -Name 'SPF records are published for all Exchange Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + $Failing = $Results | Where-Object { [string]::IsNullOrWhiteSpace($_.ActualSPFRecord) -or $_.ActualSPFRecord -notmatch 'v=spf1' } + + if (-not $Failing -or $Failing.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Results.Count) domain(s) have an SPF record published." + } else { + $Status = 'Failed' + $Result = "$($Failing.Count) of $($Results.Count) domain(s) are missing a valid SPF record:`n`n| Domain | SPF Record |`n| :----- | :--------- |`n" + foreach ($D in ($Failing | Select-Object -First 25)) { + $Result += "| $($D.Domain) | $($D.ActualSPFRecord) |`n" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_8' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'SPF records are published for all Exchange Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_8' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'SPF records are published for all Exchange Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.md new file mode 100644 index 000000000000..19ed1076b68e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.md @@ -0,0 +1,15 @@ +DKIM cryptographically signs outbound mail so receivers can verify the message hasn't been tampered with and originated from your domain. + +**Remediation Action** + +```powershell +Set-DkimSigningConfig -Identity -Enabled $true +``` + +Publish the two CNAME records Microsoft provides before enabling. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.1.9](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.ps1 new file mode 100644 index 000000000000..3547c7403431 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_1_9.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestCIS_2_1_9 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.1.9) - DKIM SHALL be enabled for all Exchange Online Domains + #> + param($Tenant) + + try { + $Dkim = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoDkimSigningConfig' + $Accepted = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAcceptedDomains' + + if (-not $Dkim -or -not $Accepted) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_9' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ExoDkimSigningConfig or ExoAcceptedDomains) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'DKIM is enabled for all Exchange Online Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + $Sending = $Accepted | Where-Object { -not $_.SendingFromDomainDisabled -and $_.DomainName -notlike '*onmicrosoft.com' } + $Failed = @() + foreach ($D in $Sending) { + $Cfg = $Dkim | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 + if (-not $Cfg -or $Cfg.Enabled -ne $true) { + $Failed += [PSCustomObject]@{ Domain = $D.DomainName; Enabled = $Cfg.Enabled } + } + } + + if ($Failed.Count -eq 0) { + $Status = 'Passed' + $Result = "DKIM is enabled for all $($Sending.Count) sending domain(s)." + } else { + $Status = 'Failed' + $Result = "DKIM is not enabled for $($Failed.Count) sending domain(s):`n`n| Domain | DKIM Enabled |`n| :----- | :----------- |`n" + foreach ($F in $Failed) { $Result += "| $($F.Domain) | $($F.Enabled) |`n" } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_9' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'DKIM is enabled for all Exchange Online Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_1_9' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'DKIM is enabled for all Exchange Online Domains' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.md new file mode 100644 index 000000000000..efad5f49a408 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.md @@ -0,0 +1,13 @@ +Break-glass accounts must only be used in actual emergencies. Every sign-in needs to trigger an alert so unauthorised use is detected immediately. + +**Remediation Action** + +1. Create a Defender alert policy with severity High that triggers on any sign-in by the break-glass UPNs. +2. Forward to SIEM and email distribution list. +3. Tabletop the alert path quarterly. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.ps1 new file mode 100644 index 000000000000..1ea09c48f151 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_2_1.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.2.1) - Emergency access account activity SHALL be monitored + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_2_1' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Emergency access account activity is monitored' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.md new file mode 100644 index 000000000000..13a6bdede8a2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.md @@ -0,0 +1,11 @@ +Priority Account Protection applies extra scrutiny to high-value mailboxes (executives, finance) and surfaces them in Defender for triage. + +**Remediation Action** + +Tag executives, finance and IT admins as Priority Accounts in the Microsoft 365 admin centre under **Setup > Priority Accounts**. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.4.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.ps1 new file mode 100644 index 000000000000..4f9d796d19ba --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_1.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_4_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.4.1) - Priority account protection SHALL be enabled and configured + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_1' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Priority account protection is enabled and configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.md new file mode 100644 index 000000000000..2b649e0c3940 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.md @@ -0,0 +1,11 @@ +The Strict preset adds the most aggressive Defender for Office settings. Priority accounts are the most-targeted users and should run with Strict, not Standard. + +**Remediation Action** + +Microsoft 365 Defender > Email & collaboration > Policies & rules > Threat policies > Preset security policies > Strict — toggle to On and scope to Priority Accounts. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.4.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.ps1 new file mode 100644 index 000000000000..d5ce0ee4a7cb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_2_4_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.4.2) - Priority accounts SHALL have 'Strict protection' presets applied + #> + param($Tenant) + + try { + $Preset = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoPresetSecurityPolicy' + + if (-not $Preset) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoPresetSecurityPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name "Priority accounts have 'Strict protection' presets applied" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + return + } + + $Strict = $Preset | Where-Object { $_.Identity -like '*Strict Preset Security Policy*' -and $_.State -eq 'Enabled' } + + if ($Strict) { + $Status = 'Passed' + $Result = "Strict preset security policy is enabled. Confirm priority accounts are scoped into the rule (`Get-EOPProtectionPolicyRule -Identity 'Strict Preset Security Policy'`)." + } else { + $Status = 'Failed' + $Result = 'Strict preset security policy is not enabled. Enable it in the Microsoft 365 Defender portal and scope priority accounts into the rule.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "Priority accounts have 'Strict protection' presets applied" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "Priority accounts have 'Strict protection' presets applied" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.md new file mode 100644 index 000000000000..2ba17cdcbe2b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.md @@ -0,0 +1,13 @@ +Defender for Cloud Apps is the CASB component of the Microsoft security stack — it discovers shadow IT, governs OAuth grants, and provides session controls. + +**Remediation Action** + +1. Acquire Defender for Cloud Apps licensing (E5 / M365 E5 Security). +2. Configure connectors for Microsoft 365 and any other SaaS in scope. +3. Enable session controls via Conditional Access App Control. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.4.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.ps1 new file mode 100644 index 000000000000..e57072bd622c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_3.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_4_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.4.3) - Microsoft Defender for Cloud Apps SHALL be enabled and configured + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_3' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Microsoft Defender for Cloud Apps is enabled and configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Cloud Apps' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.md new file mode 100644 index 000000000000..15a0f4919aa0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.md @@ -0,0 +1,13 @@ +ZAP for Teams retroactively purges malicious chats already delivered to Teams. Without it, malicious links and files persist in conversations even after detection. + +**Remediation Action** + +```powershell +Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 2.4.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.ps1 new file mode 100644 index 000000000000..160a580245a8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_2_4_4.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_2_4_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (2.4.4) - Zero-hour auto purge for Microsoft Teams SHALL be on + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_2_4_4' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Zero-hour auto purge for Microsoft Teams is on' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.md new file mode 100644 index 000000000000..c7c69958d432 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.md @@ -0,0 +1,13 @@ +Without the unified audit log enabled, the tenant has no forensic record of admin or user activity. Every IR investigation depends on this. + +**Remediation Action** + +```powershell +Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 3.1.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.ps1 new file mode 100644 index 000000000000..64b1e774c244 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_1_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_3_1_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (3.1.1) - Microsoft 365 audit log search SHALL be enabled + #> + param($Tenant) + + try { + $Audit = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAdminAuditLogConfig' + + if (-not $Audit) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoAdminAuditLogConfig cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Microsoft 365 audit log search is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + return + } + + $Cfg = $Audit | Select-Object -First 1 + + if ($Cfg.UnifiedAuditLogIngestionEnabled -eq $true) { + $Status = 'Passed' + $Result = 'Unified Audit Log ingestion is enabled.' + } else { + $Status = 'Failed' + $Result = "Unified Audit Log ingestion is disabled (UnifiedAuditLogIngestionEnabled: $($Cfg.UnifiedAuditLogIngestionEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_1_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Microsoft 365 audit log search is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Microsoft 365 audit log search is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.md new file mode 100644 index 000000000000..a98ea0630988 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.md @@ -0,0 +1,11 @@ +Microsoft Purview DLP detects and prevents accidental disclosure of sensitive data (PII, financial, health) across Exchange, SharePoint, OneDrive and Teams. + +**Remediation Action** + +Use the Purview compliance portal to create at least one DLP policy targeting your sensitive information types and switch the policy to *Turn on*. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 3.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.ps1 new file mode 100644 index 000000000000..06cc44371594 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_1.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_3_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (3.2.1) - DLP policies SHALL be enabled + #> + param($Tenant) + + try { + $Dlp = Get-CIPPTestData -TenantFilter $Tenant -Type 'DlpCompliancePolicies' + + if (-not $Dlp) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DlpCompliancePolicies cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'DLP policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + return + } + + $Enabled = $Dlp | Where-Object { $_.Mode -eq 'Enable' -and $_.Enabled -eq $true } + + if ($Enabled.Count -gt 0) { + $Status = 'Passed' + $Result = "$($Enabled.Count) of $($Dlp.Count) DLP policy/policies are enabled and in Enforce mode:`n`n" + $Result += ($Enabled | ForEach-Object { "- $($_.Name)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = "No DLP policies are enabled in Enforce mode. $(($Dlp | Measure-Object).Count) policy/policies exist but are in test/disabled state." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'DLP policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'DLP policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.md new file mode 100644 index 000000000000..c819f2f52c0a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.md @@ -0,0 +1,11 @@ +Sensitive content shared in Teams chats and channels (credit card numbers, NHS numbers, source code) is missed by mail-only DLP. Teams must be a target location. + +**Remediation Action** + +Edit each enforced DLP policy and add `TeamsLocation` so messages and shared files are inspected. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 3.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.ps1 new file mode 100644 index 000000000000..f7186930a7fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_2_2.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestCIS_3_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (3.2.2) - DLP policies SHALL be enabled for Microsoft Teams + #> + param($Tenant) + + try { + $Dlp = Get-CIPPTestData -TenantFilter $Tenant -Type 'DlpCompliancePolicies' + + if (-not $Dlp) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DlpCompliancePolicies cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'DLP policies are enabled for Microsoft Teams' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + return + } + + $TeamsDlp = $Dlp | Where-Object { + $_.Mode -eq 'Enable' -and $_.Enabled -eq $true -and ( + $_.TeamsLocation -or + $_.Workload -match 'Teams' -or + $_.TeamsLocationException -ne $null + ) + } + + if ($TeamsDlp.Count -gt 0) { + $Status = 'Passed' + $Result = "$($TeamsDlp.Count) DLP policy/policies cover Microsoft Teams:`n`n" + $Result += ($TeamsDlp | ForEach-Object { "- $($_.Name)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled DLP policy currently covers Microsoft Teams. Add Teams to the locations of at least one enforced DLP policy.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'DLP policies are enabled for Microsoft Teams' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'DLP policies are enabled for Microsoft Teams' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.md new file mode 100644 index 000000000000..b3b863434146 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.md @@ -0,0 +1,11 @@ +Sensitivity labels apply visual marking, encryption and access restrictions to documents and emails. Without published labels, end users have no mechanism to classify content. + +**Remediation Action** + +Purview > Information protection > Labels — create at minimum Public / Internal / Confidential / Highly Confidential, then publish under Label policies. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 3.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.ps1 new file mode 100644 index 000000000000..b5038c4c6a3e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_3_3_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_3_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (3.3.1) - Information Protection sensitivity label policies SHALL be published + #> + param($Tenant) + + try { + $Labels = Get-CIPPTestData -TenantFilter $Tenant -Type 'SensitivityLabels' + + if (-not $Labels) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SensitivityLabels cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Information Protection sensitivity label policies are published' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Information Protection' + return + } + + $Published = $Labels | Where-Object { $_.IsValid -eq $true -or $_.PolicyName } + + if ($Published.Count -gt 0) { + $Status = 'Passed' + $Result = "$($Published.Count) sensitivity label(s) appear to be published in the tenant." + } else { + $Status = 'Failed' + $Result = "No published sensitivity labels were found. Create and publish a label set covering at least Public / Internal / Confidential." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Information Protection sensitivity label policies are published' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Information Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_3_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Information Protection sensitivity label policies are published' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Information Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.md new file mode 100644 index 000000000000..3e981c1866cb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.md @@ -0,0 +1,13 @@ +If devices without a compliance policy default to *Compliant*, any unmanaged device satisfies a Conditional Access policy that requires compliance — defeating the purpose. Default to *Not compliant* so the policy must explicitly opt in. + +**Remediation Action** + +```powershell +Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/deviceManagement' -Body (@{ settings = @{ secureByDefault = $true } } | ConvertTo-Json) +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 4.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.ps1 new file mode 100644 index 000000000000..1f11a7bae040 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_4_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (4.1) - Devices without a compliance policy SHALL be marked 'not compliant' + #> + param($Tenant) + + try { + $DeviceSettings = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceSettings' + + if (-not $DeviceSettings) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceSettings cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "Devices without a compliance policy are marked 'not compliant'" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + return + } + + $Cfg = $DeviceSettings | Select-Object -First 1 + + if ($Cfg.secureByDefault -eq $true) { + $Status = 'Passed' + $Result = 'Devices without a compliance policy are marked Not compliant (secureByDefault: true).' + } else { + $Status = 'Failed' + $Result = "Devices without a compliance policy are marked Compliant by default (secureByDefault: $($Cfg.secureByDefault))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Devices without a compliance policy are marked 'not compliant'" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Devices without a compliance policy are marked 'not compliant'" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.md new file mode 100644 index 000000000000..f4f82b37b9c6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.md @@ -0,0 +1,11 @@ +Personal device enrollment can give attackers a foothold via lost / stolen / unmanaged devices. CIS recommends blocking by default and requiring explicit allowlisting. + +**Remediation Action** + +Intune > Devices > Enrollment > Device platform restrictions > Default policy — set Personally owned to Block on every platform. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 4.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.ps1 new file mode 100644 index 000000000000..c96bb67f1dce --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_4_2.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestCIS_4_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (4.2) - Device enrollment for personally owned devices SHALL be blocked by default + #> + param($Tenant) + + try { + $Enrollment = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceEnrollmentConfigurations' + + if (-not $Enrollment) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'IntuneDeviceEnrollmentConfigurations cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Device enrollment for personally owned devices is blocked by default' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + return + } + + $DefaultPlatform = $Enrollment | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration' -and $_.priority -eq 0 -or $_.displayName -eq 'All Users' } | Select-Object -First 1 + if (-not $DefaultPlatform) { $DefaultPlatform = $Enrollment | Where-Object { $_.PSObject.Properties.Name -contains 'androidRestriction' } | Select-Object -First 1 } + + if (-not $DefaultPlatform) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Default Device Platform Restriction policy not found in cache.' -Risk 'Medium' -Name 'Device enrollment for personally owned devices is blocked by default' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + return + } + + $Failures = @() + foreach ($P in @('androidForWorkRestriction', 'androidRestriction', 'iosRestriction', 'macOSRestriction', 'windowsRestriction')) { + $r = $DefaultPlatform.$P + if ($r -and $r.personalDeviceEnrollmentBlocked -ne $true -and $r.platformBlocked -ne $true) { + $Failures += "$P : personal enrollment NOT blocked" + } + } + + if ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = 'All platforms block personally-owned device enrollment in the default policy.' + } else { + $Status = 'Failed' + $Result = "Personal enrollment is allowed for one or more platforms in the default Device Platform Restriction policy:`n`n" + $Result += ($Failures | ForEach-Object { "- $_" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Device enrollment for personally owned devices is blocked by default' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_4_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Device enrollment for personally owned devices is blocked by default' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.md new file mode 100644 index 000000000000..6d84bdb2d7b1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.md @@ -0,0 +1,11 @@ +Per-user MFA (the legacy setting on a user object) bypasses Conditional Access logic and creates inconsistent enforcement. Microsoft and CIS recommend disabling per-user MFA and managing MFA exclusively through Conditional Access. + +**Remediation Action** + +Use Graph or the legacy `Set-MsolUser` to set `StrongAuthenticationRequirements` to an empty array on every user. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.ps1 new file mode 100644 index 000000000000..ed260404ad02 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_1.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.1) - 'Per-user MFA' SHALL be disabled + #> + param($Tenant) + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'Per-user MFA' is disabled" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Enabled = $MFA | Where-Object { $_.PerUserMFAState -in @('Enabled', 'Enforced') } + + if (-not $Enabled -or $Enabled.Count -eq 0) { + $Status = 'Passed' + $Result = 'No users have legacy per-user MFA enabled or enforced.' + } else { + $Status = 'Failed' + $Result = "$($Enabled.Count) user(s) still have per-user MFA enabled or enforced — migrate them to Conditional Access:`n`n" + $Result += ($Enabled | Select-Object -First 25 | ForEach-Object { "- $($_.userPrincipalName) ($($_.PerUserMFAState))" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Per-user MFA' is disabled" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Per-user MFA' is disabled" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.md new file mode 100644 index 000000000000..219ae2f95ce4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.md @@ -0,0 +1,13 @@ +Allowing standard users to register applications enables a compromised account to plant persistent OAuth backdoors. CIS recommends restricting this to administrators. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateApps = $false } +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.ps1 new file mode 100644 index 000000000000..b8944fcd9393 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_5_1_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.2) - Third party integrated applications SHALL NOT be allowed + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Third party integrated applications are not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToCreateApps -eq $false) { + $Status = 'Passed' + $Result = 'Users cannot create app registrations (allowedToCreateApps: false).' + } else { + $Status = 'Failed' + $Result = "Users can register applications (allowedToCreateApps: $($Cfg.defaultUserRolePermissions.allowedToCreateApps))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Third party integrated applications are not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Third party integrated applications are not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.md new file mode 100644 index 000000000000..b306d32b83de --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.md @@ -0,0 +1,13 @@ +Standard users can create new Microsoft Entra tenants by default and inherit Global Administrator inside that new tenant. This bypasses governance, allows shadow IT, and may pose a data exfiltration risk. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateTenants = $false } +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.ps1 new file mode 100644 index 000000000000..c1da8c124e05 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_3.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_5_1_2_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.3) - 'Restrict non-admin users from creating tenants' SHALL be 'Yes' + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "'Restrict non-admin users from creating tenants' is set to Yes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToCreateTenants -eq $false) { + $Status = 'Passed' + $Result = 'Non-admin users cannot create new tenants (allowedToCreateTenants: false).' + } else { + $Status = 'Failed' + $Result = "Non-admin users can create new tenants (allowedToCreateTenants: $($Cfg.defaultUserRolePermissions.allowedToCreateTenants))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Restrict non-admin users from creating tenants' is set to Yes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Restrict non-admin users from creating tenants' is set to Yes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.md new file mode 100644 index 000000000000..5c00773baf9b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.md @@ -0,0 +1,13 @@ +The Entra admin centre exposes directory contents (users, groups, configuration) to anyone signed in. Standard users do not need access and should be blocked from the portal. + +**Remediation Action** + +```powershell +Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/beta/admin/entra/uxSetting' -Body '{ "restrictNonAdminAccess": true }' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.ps1 new file mode 100644 index 000000000000..5c6a1b5ff26a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_4.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_1_2_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.4) - Access to the Entra admin center SHALL be restricted + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_4' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Access to the Entra admin center is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.md new file mode 100644 index 000000000000..053a01a8bd68 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.md @@ -0,0 +1,11 @@ +"Stay signed in" extends sign-in lifetimes which is fine on managed devices but high-risk on shared / unmanaged devices. CIS recommends hiding the option globally and granting persistent sessions only to compliant devices via Conditional Access. + +**Remediation Action** + +Microsoft Entra admin centre > User experiences > Company branding > Default sign-in > Show option to remain signed-in: **No**. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.ps1 new file mode 100644 index 000000000000..18a8add13ba3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_5.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_1_2_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.5) - The option to remain signed in SHALL be hidden + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_5' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'The option to remain signed in is hidden' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.md new file mode 100644 index 000000000000..5449ba4cff63 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.md @@ -0,0 +1,11 @@ +LinkedIn account connections share user profile information with LinkedIn. CIS recommends disabling unless there's an explicit business need. + +**Remediation Action** + +Microsoft 365 admin centre > Settings > Org settings > LinkedIn account connections — set to **Do not allow**. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.2.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.ps1 new file mode 100644 index 000000000000..e31a7ab6d28e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_2_6.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_1_2_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.2.6) - 'LinkedIn account connections' SHALL be disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_2_6' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name "'LinkedIn account connections' is disabled" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Identity' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.md new file mode 100644 index 000000000000..6aaf0117f3f7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.md @@ -0,0 +1,13 @@ +A dynamic group containing every guest enables guest-aware Conditional Access policies, access reviews and lifecycle automation without manual maintenance. + +**Remediation Action** + +```powershell +New-MgGroup -DisplayName 'All Guest Users' -SecurityEnabled:$true -MailEnabled:$false -MailNickname 'allguests' -GroupTypes 'DynamicMembership' -MembershipRule '(user.userType -eq "Guest")' -MembershipRuleProcessingState 'On' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.ps1 new file mode 100644 index 000000000000..b4538c5aff98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_1.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_5_1_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.3.1) - A dynamic group for guest users SHALL be created + #> + param($Tenant) + + try { + $Groups = Get-CIPPTestData -TenantFilter $Tenant -Type 'Groups' + + if (-not $Groups) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Groups cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'A dynamic group for guest users is created' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + return + } + + $GuestDynamic = $Groups | Where-Object { + $_.groupTypes -contains 'DynamicMembership' -and + $_.membershipRule -match "userType\s*-eq\s*['""]Guest['""]" + } + + if ($GuestDynamic.Count -gt 0) { + $Status = 'Passed' + $Result = "$($GuestDynamic.Count) dynamic group(s) target guest users:`n`n" + $Result += ($GuestDynamic | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No dynamic security group targeting `userType -eq "Guest"` was found. Create one so guest-targeted Conditional Access can use it.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'A dynamic group for guest users is created' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'A dynamic group for guest users is created' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Group Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.md new file mode 100644 index 000000000000..47345a8315e1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.md @@ -0,0 +1,13 @@ +Security groups grant access to resources. Allowing standard users to create security groups bypasses access governance and is a common privilege escalation path. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateSecurityGroups = $false } +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.3.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.ps1 new file mode 100644 index 000000000000..b360366b00c9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_3_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_5_1_3_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.3.2) - Users SHALL NOT be able to create security groups + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Users cannot create security groups' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Group Management' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups -eq $false) { + $Status = 'Passed' + $Result = 'Users cannot create security groups (allowedToCreateSecurityGroups: false).' + } else { + $Status = 'Failed' + $Result = "Users can create security groups (allowedToCreateSecurityGroups: $($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Users cannot create security groups' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Group Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_3_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Users cannot create security groups' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Group Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.md new file mode 100644 index 000000000000..c9f17ddb90b5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.md @@ -0,0 +1,11 @@ +A compromised user can register a rogue device under their identity, satisfy compliance, and use the device as a foothold. Restrict joins to a small set of helpdesk / provisioning accounts. + +**Remediation Action** + +Microsoft Entra admin centre > Devices > Device settings > Users may join devices to Microsoft Entra: Selected (and add the helpdesk group) or None. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.ps1 new file mode 100644 index 000000000000..173efbf8e0dd --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_1.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_4_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.1) - Ability to join devices to Entra SHALL be restricted + #> + param($Tenant) + + try { + $DRP = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + + if (-not $DRP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceRegistrationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Ability to join devices to Entra is restricted' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + return + } + + $Cfg = $DRP | Select-Object -First 1 + $JoinType = $Cfg.azureADJoin.allowedToJoin.'@odata.type' + + if ($JoinType -in @('#microsoft.graph.enumeratedDeviceRegistrationMembership', '#microsoft.graph.noDeviceRegistrationMembership')) { + $Status = 'Passed' + $Result = "Entra device join is restricted (allowedToJoin type: $JoinType)." + } else { + $Status = 'Failed' + $Result = "Entra device join is open to All users (allowedToJoin type: $JoinType). Restrict to Selected or None." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Ability to join devices to Entra is restricted' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Ability to join devices to Entra is restricted' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.md new file mode 100644 index 000000000000..a484d774c1e9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.md @@ -0,0 +1,11 @@ +A high device quota lets a compromised user enroll multiple rogue devices to maintain persistence. CIS recommends 20 or fewer. + +**Remediation Action** + +Microsoft Entra admin centre > Devices > Device settings > Maximum number of devices per user: 20 (or less). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.ps1 new file mode 100644 index 000000000000..acecf5f6d85e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_2.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_4_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.2) - Maximum number of devices per user SHALL be limited + #> + param($Tenant) + + try { + $DRP = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + + if (-not $DRP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceRegistrationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Maximum number of devices per user is limited' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Device Management' + return + } + + $Cfg = $DRP | Select-Object -First 1 + $Quota = [int]$Cfg.userDeviceQuota + + if ($Quota -gt 0 -and $Quota -le 20) { + $Status = 'Passed' + $Result = "userDeviceQuota is set to $Quota (CIS recommends 20 or less)." + } else { + $Status = 'Failed' + $Result = "userDeviceQuota is $Quota (CIS recommends 20 or less)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Maximum number of devices per user is limited' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Maximum number of devices per user is limited' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.md new file mode 100644 index 000000000000..3e6a3d68f04a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.md @@ -0,0 +1,11 @@ +Granting Global Administrator local admin on every Entra-joined device dramatically increases the impact of GA credential theft. + +**Remediation Action** + +Microsoft Entra admin centre > Devices > Device settings > Global administrator role is added as local administrator on the device during Microsoft Entra join: **No**. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.ps1 new file mode 100644 index 000000000000..a24dbcb099ab --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_4_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.3) - GA role SHALL NOT be added as local administrator during Entra join + #> + param($Tenant) + + try { + $DRP = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + + if (-not $DRP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceRegistrationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'GA role is not added as local administrator during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $Cfg = $DRP | Select-Object -First 1 + $EnableGA = [bool]$Cfg.azureADJoin.localAdmins.enableGlobalAdmins + + if (-not $EnableGA) { + $Status = 'Passed' + $Result = 'Global Administrators are not granted local admin during Entra join (enableGlobalAdmins: false).' + } else { + $Status = 'Failed' + $Result = "Global Administrators are granted local admin during Entra join (enableGlobalAdmins: true). Use the Microsoft Entra Joined Device Local Administrator role instead." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'GA role is not added as local administrator during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'GA role is not added as local administrator during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.md new file mode 100644 index 000000000000..ea353bf67422 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.md @@ -0,0 +1,11 @@ +By default the user who registers a device gets local admin on that device. CIS recommends restricting this to specific users / groups (or None) and using Intune to push admin assignments centrally. + +**Remediation Action** + +Microsoft Entra admin centre > Devices > Device settings > Registering user is added as local administrator on the device during Microsoft Entra join: Selected or None. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.ps1 new file mode 100644 index 000000000000..1123427144a7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_4.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_4_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.4) - Local administrator assignment SHALL be limited during Entra join + #> + param($Tenant) + + try { + $DRP = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + + if (-not $DRP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceRegistrationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Local administrator assignment is limited during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $Cfg = $DRP | Select-Object -First 1 + $RegType = $Cfg.azureADJoin.localAdmins.registeringUsers.'@odata.type' + + if ($RegType -in @('#microsoft.graph.enumeratedDeviceRegistrationMembership', '#microsoft.graph.noDeviceRegistrationMembership')) { + $Status = 'Passed' + $Result = "Local admin assignment for registering users is restricted (type: $RegType)." + } else { + $Status = 'Failed' + $Result = "Local admin assignment for registering users is set to All (type: $RegType). Restrict to Selected or None." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Local administrator assignment is limited during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Local administrator assignment is limited during Entra join' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.md new file mode 100644 index 000000000000..6d02eebd73f5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.md @@ -0,0 +1,11 @@ +Without LAPS, the same local administrator password is often used on every device, turning a single compromised endpoint into lateral movement across the fleet. + +**Remediation Action** + +Microsoft Entra admin centre > Devices > Device settings > Enable Microsoft Entra Local Administrator Password Solution: **Yes**. Then deploy an Intune Account Protection LAPS policy. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.ps1 new file mode 100644 index 000000000000..4f84abbede46 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_5.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_5_1_4_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.5) - Local Administrator Password Solution (LAPS) SHALL be enabled + #> + param($Tenant) + + try { + $DRP = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + + if (-not $DRP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'DeviceRegistrationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Local Administrator Password Solution is enabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $Cfg = $DRP | Select-Object -First 1 + + if ($Cfg.localAdminPassword.isEnabled -eq $true) { + $Status = 'Passed' + $Result = 'LAPS (cloud) is enabled at the tenant (localAdminPassword.isEnabled: true). Ensure an Intune Account Protection policy is also rotating local admin passwords on devices.' + } else { + $Status = 'Failed' + $Result = "LAPS is disabled at the tenant (localAdminPassword.isEnabled: $($Cfg.localAdminPassword.isEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Local Administrator Password Solution is enabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Local Administrator Password Solution is enabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.md new file mode 100644 index 000000000000..a5068db58168 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.md @@ -0,0 +1,13 @@ +If a compromised user can read their own device's BitLocker recovery key, an attacker with stolen credentials can also unlock the disk on a stolen device. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToReadBitLockerKeysForOwnedDevice = $false } +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.4.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.ps1 new file mode 100644 index 000000000000..251fe3a9fbe0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_4_6.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_5_1_4_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.4.6) - Users SHALL be restricted from recovering BitLocker keys + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Users are restricted from recovering BitLocker keys' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToReadBitLockerKeysForOwnedDevice -eq $false) { + $Status = 'Passed' + $Result = 'Users cannot self-service recover BitLocker keys (allowedToReadBitLockerKeysForOwnedDevice: false).' + } else { + $Status = 'Failed' + $Result = "Users can self-service recover BitLocker keys (allowedToReadBitLockerKeysForOwnedDevice: $($Cfg.defaultUserRolePermissions.allowedToReadBitLockerKeysForOwnedDevice))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Users are restricted from recovering BitLocker keys' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_4_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Users are restricted from recovering BitLocker keys' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.md new file mode 100644 index 000000000000..71985d5f95ad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.md @@ -0,0 +1,15 @@ +OAuth phishing tricks users into consenting to malicious applications, granting attackers persistent Graph access without ever stealing a password. Disabling user consent eliminates this attack class. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ PermissionGrantPoliciesAssigned = @() } +``` + +(Or assign `ManagePermissionGrantsForSelf.microsoft-user-default-low` if low-risk consent is acceptable.) + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.5.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.ps1 new file mode 100644 index 000000000000..768ce0f1eb39 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_1.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_5_1_5_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.5.1) - User consent to apps accessing company data SHALL NOT be allowed + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'User consent to apps accessing company data on their behalf is not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + return + } + + $Cfg = $Auth | Select-Object -First 1 + $ConsentPolicies = $Cfg.defaultUserRolePermissions.permissionGrantPoliciesAssigned + + $RestrictedConsent = $ConsentPolicies -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low' -or + ($ConsentPolicies | Where-Object { $_ -like '*low*' }) + + if (-not $ConsentPolicies -or $ConsentPolicies.Count -eq 0 -or ($ConsentPolicies -notcontains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy')) { + $Status = 'Passed' + $Result = "User consent to apps is restricted. Permission grant policies: $($ConsentPolicies -join ', ')" + } else { + $Status = 'Failed' + $Result = "User consent to apps is open (legacy policy assigned). Permission grant policies: $($ConsentPolicies -join ', ')" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'User consent to apps accessing company data on their behalf is not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'User consent to apps accessing company data on their behalf is not allowed' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.md new file mode 100644 index 000000000000..b9659376b5fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.md @@ -0,0 +1,13 @@ +When user consent is restricted (5.1.5.1) users still need a way to ask for legitimate apps. The admin consent workflow gives them a self-service request path with administrative review. + +**Remediation Action** + +```powershell +Update-MgPolicyAdminConsentRequestPolicy -IsEnabled $true -NotifyReviewers $true -RemindersEnabled $true -RequestDurationInDays 30 -Reviewers @(@{query='/users/'; queryType='MicrosoftGraph'; queryRoot=$null}) +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.5.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.ps1 new file mode 100644 index 000000000000..263c028b1104 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_5_2.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_5_1_5_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.5.2) - The admin consent workflow SHALL be enabled + #> + param($Tenant) + + try { + $Policy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AdminConsentRequestPolicy' + + if (-not $Policy) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AdminConsentRequestPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'The admin consent workflow is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Application Management' + return + } + + $Cfg = $Policy | Select-Object -First 1 + + if ($Cfg.isEnabled -eq $true -and $Cfg.reviewers -and $Cfg.reviewers.Count -gt 0) { + $Status = 'Passed' + $Result = "Admin consent workflow is enabled with $($Cfg.reviewers.Count) reviewer(s)." + } elseif ($Cfg.isEnabled -eq $true) { + $Status = 'Failed' + $Result = 'Admin consent workflow is enabled but no reviewers are configured. Add at least one reviewer.' + } else { + $Status = 'Failed' + $Result = "Admin consent workflow is disabled (isEnabled: $($Cfg.isEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'The admin consent workflow is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Application Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_5_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'The admin consent workflow is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.md new file mode 100644 index 000000000000..7990d1607f8e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.md @@ -0,0 +1,11 @@ +An open guest invitation policy lets users invite anyone with any email — a frequent path for data exfiltration via shared links to attacker-controlled mailboxes. Restrict invitations to known partner domains. + +**Remediation Action** + +Configure `invitationsAllowedAndBlockedDomainsPolicy` on the cross-tenant access / B2B settings, with an explicit `allowedDomains` allowlist (or `blockedDomains` denylist). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.6.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.ps1 new file mode 100644 index 000000000000..37e36fd9b18b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_1.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestCIS_5_1_6_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.6.1) - Collaboration invitations SHALL be sent to allowed domains only + #> + param($Tenant) + + try { + $Cross = Get-CIPPTestData -TenantFilter $Tenant -Type 'CrossTenantAccessPolicy' + $B2B = Get-CIPPTestData -TenantFilter $Tenant -Type 'B2BManagementPolicy' + + if (-not $Cross -and -not $B2B) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (CrossTenantAccessPolicy or B2BManagementPolicy) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Collaboration invitations are sent to allowed domains only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + return + } + + # Inspect B2B management policy AllowedDomains / BlockedDomains + $Cfg = $B2B | Select-Object -First 1 + if ($Cfg) { + $Allowed = $Cfg.allowInvitesFrom + $Domains = $Cfg.invitationsAllowedAndBlockedDomainsPolicy + + $Pass = $Domains -and ( + ($Domains.allowedDomains -and $Domains.allowedDomains.Count -gt 0) -or + ($Domains.blockedDomains -and $Domains.blockedDomains.Count -gt 0) + ) + + if ($Pass) { + $Status = 'Passed' + $Result = "B2B invitations are scoped by an allow/block list (allowed: $($Domains.allowedDomains -join ', '); blocked: $($Domains.blockedDomains -join ', '))." + } else { + $Status = 'Failed' + $Result = 'B2B invitations are not constrained by an allow / block list. Configure invitationsAllowedAndBlockedDomainsPolicy.' + } + } else { + $Status = 'Failed' + $Result = 'No B2B management policy with domain restrictions was found.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Collaboration invitations are sent to allowed domains only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Collaboration invitations are sent to allowed domains only' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.md new file mode 100644 index 000000000000..f1e29f387531 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.md @@ -0,0 +1,13 @@ +By default, guest users can read other users' profiles, group memberships, and many directory objects. The Restricted Guest role removes this directory enumeration ability. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.6.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.ps1 new file mode 100644 index 000000000000..ccee7f0f1c3a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_2.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_5_1_6_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.6.2) - Guest user access SHALL be restricted + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Guest user access is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + # Restricted guest user role template ID + $RestrictedGuest = '2af84b1e-32c8-42b7-82bc-daa82404023b' + + if ($Cfg.guestUserRoleId -eq $RestrictedGuest) { + $Status = 'Passed' + $Result = 'Guest users are assigned the most restricted role (Restricted Guest).' + } else { + $Status = 'Failed' + $Result = "Guest users are not on the Restricted Guest role (current guestUserRoleId: $($Cfg.guestUserRoleId), expected: $RestrictedGuest)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Guest user access is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Guest user access is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.md new file mode 100644 index 000000000000..7a01851e0494 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.md @@ -0,0 +1,13 @@ +Limiting guest invitations to administrators and a dedicated Guest Inviter role provides oversight and a clear audit trail for who is bringing externals into the tenant. + +**Remediation Action** + +```powershell +Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom 'adminsAndGuestInviters' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.6.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.ps1 new file mode 100644 index 000000000000..a2e61c4b27cc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_6_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_1_6_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.6.3) - Guest user invitations SHALL be limited to the Guest Inviter role + #> + param($Tenant) + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Guest user invitations are limited to the Guest Inviter role' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $Auth | Select-Object -First 1 + $Allow = $Cfg.allowInvitesFrom + + if ($Allow -eq 'adminsAndGuestInviters') { + $Status = 'Passed' + $Result = "Guest invitations restricted to Admins and Guest Inviters (allowInvitesFrom: $Allow)." + } else { + $Status = 'Failed' + $Result = "Guest invitations are not limited to the Guest Inviter role (allowInvitesFrom: $Allow). Set to 'adminsAndGuestInviters'." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Guest user invitations are limited to the Guest Inviter role' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_6_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Guest user invitations are limited to the Guest Inviter role' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.md new file mode 100644 index 000000000000..e8c95cb230a6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.md @@ -0,0 +1,11 @@ +PHS enables Microsoft to detect leaked credentials, supports break-glass when on-prem AD or federation is offline, and is required for several Identity Protection features. + +**Remediation Action** + +Entra Connect Sync > Configure > Customize Synchronization Options > Optional Features > Password Hash Synchronization. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.1.8.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.ps1 new file mode 100644 index 000000000000..34b0426f7dc5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_1_8_1.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestCIS_5_1_8_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.1.8.1) - Password hash sync SHALL be enabled for hybrid deployments (Manual) + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'Organization' + $Domains = Get-CIPPTestData -TenantFilter $Tenant -Type 'Domains' + + if (-not $Org -or -not $Domains) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_8_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Organization or Domains) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Password hash sync is enabled for hybrid deployments' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Identity' + return + } + + $OrgCfg = $Org | Select-Object -First 1 + $IsHybrid = $OrgCfg.onPremisesSyncEnabled -eq $true + + if (-not $IsHybrid) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_8_1' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'Tenant is cloud-only (onPremisesSyncEnabled: false) — recommendation does not apply.' -Risk 'Medium' -Name 'Password hash sync is enabled for hybrid deployments' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Identity' + return + } + + # PHS state isn't directly readable via Graph; surface as Skipped + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_8_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown @' +Tenant has on-prem sync enabled, but password hash sync (PHS) state is not exposed via Graph and must be verified manually. + +```powershell +# On the Entra Connect Sync server: +Get-ADSyncAADCompanyFeature +``` + +`PasswordHashSync` should be `True`. +'@ -Risk 'Medium' -Name 'Password hash sync is enabled for hybrid deployments' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Identity' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_1_8_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Password hash sync is enabled for hybrid deployments' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Identity' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.md new file mode 100644 index 000000000000..d5582a961d01 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.md @@ -0,0 +1,11 @@ +Privileged accounts are the highest-value target. MFA is the single most effective control against credential theft. + +**Remediation Action** + +Create a Conditional Access policy that targets all privileged role IDs and requires MFA (or, preferably, phishing-resistant MFA — see 5.2.2.5). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 new file mode 100644 index 000000000000..7ea446f7a56f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestCIS_5_2_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.1) - MFA SHALL be enabled for all users in administrative roles + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.grantControls -and + ($_.grantControls.builtInControls -contains 'mfa' -or $_.grantControls.authenticationStrength) -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies enforce MFA on privileged roles:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with MFA. Create a policy with includeRoles = (privileged role IDs) and grant control = MFA.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.md new file mode 100644 index 000000000000..59303766e2a7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.md @@ -0,0 +1,13 @@ +An attacker who steals a password but not the device can still register their own MFA method during initial setup, gaining persistent control. Requiring a managed device or trusted location for security info registration closes this gap. + +**Remediation Action** + +Conditional Access policy: +- Cloud apps > User actions: Register security information +- Grant: Require compliant device (or limit by trusted locations) + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.10](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.ps1 new file mode 100644 index 000000000000..cb35274f04eb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_10.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestCIS_5_2_2_10 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.10) - A managed device SHALL be required to register security information + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_10' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name 'A managed device is required to register security information' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + return + } + + # User Action GUID for "Register security information" + $RegSecInfo = 'cb1d5f30-e5dc-4d70-b3f1-5ab8e3c9d3c0' + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.applications.includeUserActions -contains 'urn:user:registersecurityinfo' -and + ($_.grantControls.builtInControls -contains 'compliantDevice' -or + $_.grantControls.builtInControls -contains 'domainJoinedDevice' -or + ($_.conditions.locations -and $_.conditions.locations.includeLocations)) + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies guard the Register security info user action:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets the Register security info user action with a managed-device or trusted-location requirement.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_10' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'A managed device is required to register security information' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_10' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'A managed device is required to register security information' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.md new file mode 100644 index 000000000000..a784e10b0c0f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.md @@ -0,0 +1,13 @@ +Forcing fresh authentication every time an Intune enrollment is initiated prevents an attacker who already has a session token from enrolling a malicious device. + +**Remediation Action** + +Conditional Access policy: +- Cloud apps: Microsoft Intune Enrollment (`d4ebce55-015a-49b5-a083-c84d1797ae8c`) +- Session > Sign-in frequency: Every time + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.11](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.ps1 new file mode 100644 index 000000000000..a96f53f0eb90 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_11.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestCIS_5_2_2_11 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.11) - Sign-in frequency for Intune Enrollment SHALL be 'Every time' + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_11' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'Medium' -Name "Sign-in frequency for Intune Enrollment is 'Every time'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device Management' + return + } + + # Microsoft Intune Enrollment app GUID + $IntuneEnrollmentApp = 'd4ebce55-015a-49b5-a083-c84d1797ae8c' + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.applications.includeApplications -contains $IntuneEnrollmentApp -and + $_.sessionControls -and + $_.sessionControls.signInFrequency -and + $_.sessionControls.signInFrequency.frequencyInterval -eq 'everyTime' + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies require sign-in every time for Intune Enrollment:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets Microsoft Intune Enrollment with sign-in frequency Every time.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_11' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Sign-in frequency for Intune Enrollment is 'Every time'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_11' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Sign-in frequency for Intune Enrollment is 'Every time'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.md new file mode 100644 index 000000000000..2043faf699db --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.md @@ -0,0 +1,14 @@ +The device code flow is the most common phishing vector now in use against M365 — attackers send a victim a code and a URL and exfiltrate the resulting tokens. Block the flow unless explicitly required. + +**Remediation Action** + +Conditional Access policy: +- Users: All users (exclude break-glass and any service accounts that need device code flow) +- Conditions > Authentication flows > Transfer methods: Device code flow +- Grant: Block + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.12](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.ps1 new file mode 100644 index 000000000000..a12b73f3b461 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_12.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_5_2_2_12 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.12) - The device code sign-in flow SHALL be blocked + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_12' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name 'The device code sign-in flow is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.authenticationFlows -and + $_.conditions.authenticationFlows.transferMethods -match 'deviceCodeFlow' -and + $_.grantControls.builtInControls -contains 'block' + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies block the device code flow:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy blocks the deviceCodeFlow authentication flow.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_12' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'The device code sign-in flow is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_12' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'The device code sign-in flow is blocked' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.md new file mode 100644 index 000000000000..553a49f1e350 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.md @@ -0,0 +1,14 @@ +MFA is the baseline control for every user account, not just admins. Microsoft data shows MFA blocks the vast majority of identity-based attacks. + +**Remediation Action** + +Create a Conditional Access policy: +- Users: All users (exclude break-glass accounts) +- Cloud apps: All +- Grant: Require MFA + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.ps1 new file mode 100644 index 000000000000..5504a6bc1a09 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_2.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestCIS_5_2_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.2) - MFA SHALL be enabled for all users + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'MFA is enabled for all users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Authentication' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeUsers -contains 'All' -and + $_.grantControls -and + ($_.grantControls.builtInControls -contains 'mfa' -or $_.grantControls.authenticationStrength) -and + $_.conditions.applications.includeApplications -contains 'All' + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies require MFA for all users on all cloud apps:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets All users / All cloud apps with MFA.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'MFA is enabled for all users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'MFA is enabled for all users' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.md new file mode 100644 index 000000000000..80a74bf6f2ee --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.md @@ -0,0 +1,15 @@ +Legacy authentication protocols (POP, IMAP, SMTP AUTH, EAS, older clients) cannot enforce MFA. Almost all password-spray and credential-stuffing attacks target these protocols. + +**Remediation Action** + +Conditional Access policy with: +- Users: All users (exclude break-glass) +- Cloud apps: All +- Conditions > Client apps: Exchange ActiveSync clients + Other clients +- Grant: Block + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.ps1 new file mode 100644 index 000000000000..0f1f431783c4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_3.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestCIS_5_2_2_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.3) - Conditional Access policies SHALL block legacy authentication + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Conditional Access policies block legacy authentication' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $LegacyClients = @('exchangeActiveSync', 'other') + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.grantControls.builtInControls -contains 'block' -and + $_.conditions.clientAppTypes -and + ($_.conditions.clientAppTypes | Where-Object { $_ -in $LegacyClients }).Count -gt 0 + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies block legacy authentication:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets legacy authentication client app types with a Block grant.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Conditional Access policies block legacy authentication' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Conditional Access policies block legacy authentication' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.md new file mode 100644 index 000000000000..4b8dc5968e78 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.md @@ -0,0 +1,13 @@ +Long-lived session tokens stolen from an administrator's browser provide an attacker with persistent privileged access. Forcing frequent re-authentication and disabling browser persistence shrinks that window. + +**Remediation Action** + +Conditional Access policy targeting privileged roles with: +- Session > Sign-in frequency: 4 hours (or less) +- Session > Persistent browser: Never persistent + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 new file mode 100644 index 000000000000..5725d23ddd79 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 @@ -0,0 +1,44 @@ +function Invoke-CippTestCIS_5_2_2_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.4) - Sign-in frequency SHALL be enabled and browser sessions not persistent for administrative users + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' + return + } + + $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + $_.sessionControls -and + $_.sessionControls.signInFrequency -and + $_.sessionControls.signInFrequency.isEnabled -eq $true -and + $_.sessionControls.persistentBrowser -and + $_.sessionControls.persistentBrowser.mode -eq 'never' + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies enforce admin sign-in frequency + non-persistent browser:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled CA policy enforces sign-in frequency AND non-persistent browser for privileged roles.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.md new file mode 100644 index 000000000000..a9d9cfcc4014 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.md @@ -0,0 +1,11 @@ +Number-matched push, SMS and voice MFA can all be defeated by AiTM phishing kits. Phishing-resistant MFA (FIDO2, Windows Hello for Business, certificate-based) prevents this attack class entirely. + +**Remediation Action** + +Conditional Access policy targeting privileged roles, with grant control = authentication strength = "Phishing-resistant MFA". + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 new file mode 100644 index 000000000000..0b8e1b288ce7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestCIS_5_2_2_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.5) - 'Phishing-resistant MFA strength' SHALL be required for Administrators + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Strengths = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationStrengths' + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name "'Phishing-resistant MFA strength' is required for Administrators" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + return + } + + $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + $PhishResistantId = '00000000-0000-0000-0000-000000000004' # Built-in 'Phishing-resistant MFA' strength + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies require phishing-resistant MFA for privileged roles:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy enforces phishing-resistant MFA strength for privileged roles.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "'Phishing-resistant MFA strength' is required for Administrators" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "'Phishing-resistant MFA strength' is required for Administrators" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.md new file mode 100644 index 000000000000..48535387cdf3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.md @@ -0,0 +1,14 @@ +Identity Protection's User Risk score reflects credential leaks, anomalous behaviour, and other compromise indicators. Acting on User Risk = High by forcing password change blunts credential-theft attacks. + +**Remediation Action** + +Conditional Access policy: +- Users: All users (exclude break-glass) +- Conditions > User risk: High +- Grant: Require password change + MFA + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.ps1 new file mode 100644 index 000000000000..efbd1b399175 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_6.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_5_2_2_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.6) - Identity Protection user risk policies SHALL be enabled + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name 'Identity Protection user risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.userRiskLevels -and + $_.conditions.userRiskLevels.Count -gt 0 + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies act on user risk:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName) (risk: $($_.conditions.userRiskLevels -join ', '))" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy uses userRiskLevels. Create a policy that requires password change (or blocks) on High user risk.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Identity Protection user risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Identity Protection user risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.md new file mode 100644 index 000000000000..ab1605470019 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.md @@ -0,0 +1,14 @@ +Sign-in risk reflects per-session anomalies (impossible travel, malicious IP, anonymous proxy). A CA policy that requires MFA on Medium+ risk catches in-progress attacks. + +**Remediation Action** + +Conditional Access policy: +- Users: All users (exclude break-glass) +- Conditions > Sign-in risk: Medium, High +- Grant: Require MFA + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.ps1 new file mode 100644 index 000000000000..985192b7ae2a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_7.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_5_2_2_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.7) - Identity Protection sign-in risk policies SHALL be enabled + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name 'Identity Protection sign-in risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.signInRiskLevels -and + $_.conditions.signInRiskLevels.Count -gt 0 + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies act on sign-in risk:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName) (risk: $($_.conditions.signInRiskLevels -join ', '))" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy uses signInRiskLevels. Create a policy that requires MFA (or blocks) on Medium/High sign-in risk.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Identity Protection sign-in risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Identity Protection sign-in risk policies are enabled' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.md new file mode 100644 index 000000000000..057d51a8a1b4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.md @@ -0,0 +1,14 @@ +CIS L2 hardening: instead of requiring MFA on risky sign-ins, block them outright. This trades a small productivity hit for high security on the most-targeted accounts. + +**Remediation Action** + +Conditional Access policy: +- Users: privileged roles (or all users for L2) +- Conditions > Sign-in risk: Medium, High +- Grant: Block + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.8](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.ps1 new file mode 100644 index 000000000000..ebd0894284e2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_8.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_5_2_2_8 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.8) - 'sign-in risk' SHALL be blocked for medium and high risk + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_8' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name "'sign-in risk' is blocked for medium and high risk" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Identity Protection' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.signInRiskLevels -contains 'medium' -and + $_.conditions.signInRiskLevels -contains 'high' -and + $_.grantControls.builtInControls -contains 'block' + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies block medium+high sign-in risk:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy blocks both medium and high sign-in risk.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_8' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "'sign-in risk' is blocked for medium and high risk" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_8' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "'sign-in risk' is blocked for medium and high risk" -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Identity Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.md new file mode 100644 index 000000000000..afa4a7c89382 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.md @@ -0,0 +1,11 @@ +Requiring a compliant or hybrid-joined device for authentication tightly couples access to a known device, defeating credential theft scenarios where attackers operate from their own infrastructure. + +**Remediation Action** + +Conditional Access policy with grant: Require device to be marked as compliant OR hybrid Microsoft Entra joined. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.2.9](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.ps1 new file mode 100644 index 000000000000..2610d62f8d68 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_9.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestCIS_5_2_2_9 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.2.9) - A managed device SHALL be required for authentication + #> + param($Tenant) + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_9' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name 'A managed device is required for authentication' -UserImpact 'High' -ImplementationEffort 'High' -Category 'Device Management' + return + } + + $Matching = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.grantControls -and ( + $_.grantControls.builtInControls -contains 'compliantDevice' -or + $_.grantControls.builtInControls -contains 'domainJoinedDevice' + ) + } + + if ($Matching) { + $Status = 'Passed' + $Result = "$($Matching.Count) Conditional Access policy/policies require a compliant or domain-joined device:`n`n" + $Result += ($Matching | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy requires a compliant or hybrid-joined device.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_9' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'A managed device is required for authentication' -UserImpact 'High' -ImplementationEffort 'High' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_9' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'A managed device is required for authentication' -UserImpact 'High' -ImplementationEffort 'High' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.md new file mode 100644 index 000000000000..6125324d5e37 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.md @@ -0,0 +1,11 @@ +MFA fatigue (push-bombing) succeeds when users approve a request without context. Surfacing the application name and login location turns Authenticator approvals into informed decisions and breaks the attack pattern. + +**Remediation Action** + +Microsoft Entra > Authentication methods > Microsoft Authenticator > Configure: enable "Show application name in push and passwordless notifications" and "Show geographic location in push and passwordless notifications" for All users. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.ps1 new file mode 100644 index 000000000000..80d44fe1f053 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_1.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestCIS_5_2_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.1) - Microsoft Authenticator SHALL be configured to protect against MFA fatigue + #> + param($Tenant) + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'High' -Name 'Microsoft Authenticator is configured to protect against MFA fatigue' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $Authenticator = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'MicrosoftAuthenticator' } | Select-Object -First 1 + + if (-not $Authenticator) { + $Status = 'Failed' + $Result = 'MicrosoftAuthenticator authentication method configuration not found.' + } else { + $Inc = $Authenticator.featureSettings.displayAppInformationRequiredState.includeTarget.id + $Geo = $Authenticator.featureSettings.displayLocationInformationRequiredState.includeTarget.id + + if ($Authenticator.state -eq 'enabled' -and + $Authenticator.featureSettings.displayAppInformationRequiredState.state -eq 'enabled' -and + $Authenticator.featureSettings.displayLocationInformationRequiredState.state -eq 'enabled' -and + $Inc -eq 'all_users' -and $Geo -eq 'all_users') { + $Status = 'Passed' + $Result = 'Microsoft Authenticator has app context + geographic location enabled for all users.' + } else { + $Status = 'Failed' + $Result = "Microsoft Authenticator is not fully hardened.`n`n- state: $($Authenticator.state)`n- displayAppInformation: $($Authenticator.featureSettings.displayAppInformationRequiredState.state) (target: $Inc)`n- displayLocation: $($Authenticator.featureSettings.displayLocationInformationRequiredState.state) (target: $Geo)" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Microsoft Authenticator is configured to protect against MFA fatigue' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Microsoft Authenticator is configured to protect against MFA fatigue' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.md new file mode 100644 index 000000000000..197f6bb1ea99 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.md @@ -0,0 +1,11 @@ +Microsoft's global banned password list catches obvious bad passwords. The custom list lets the organisation block company-specific passwords (company name, product names, local landmarks). + +**Remediation Action** + +Microsoft Entra > Protection > Authentication methods > Password protection: Enforce custom list = Yes, Custom banned password list = (organisation-specific words). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.ps1 new file mode 100644 index 000000000000..f0e0032f552e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_2.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestCIS_5_2_3_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.2) - Custom banned passwords lists SHALL be used + #> + param($Tenant) + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + + if (-not $Settings) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Settings cache not found.' -Risk 'Medium' -Name 'Custom banned passwords lists are used' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $PwdSetting = $Settings | Where-Object { $_.templateId -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' -or $_.displayName -eq 'Password Rule Settings' } | Select-Object -First 1 + + if (-not $PwdSetting) { + $Status = 'Failed' + $Result = 'Password Rule Settings not found in directory settings — custom banned passwords have not been configured.' + } else { + $Enforce = ($PwdSetting.values | Where-Object { $_.name -eq 'EnableBannedPasswordCheck' }).value + $Custom = ($PwdSetting.values | Where-Object { $_.name -eq 'BannedPasswordList' }).value + + if ($Enforce -eq 'True' -and -not [string]::IsNullOrWhiteSpace($Custom)) { + $Status = 'Passed' + $Result = "Custom banned passwords are enforced ($([int](($Custom -split '\t').Count)) words)." + } else { + $Status = 'Failed' + $Result = "Custom banned passwords not fully configured. EnableBannedPasswordCheck: $Enforce; BannedPasswordList length: $([int]($Custom).Length)" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Custom banned passwords lists are used' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Custom banned passwords lists are used' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.md new file mode 100644 index 000000000000..b3eab6fbee1a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.md @@ -0,0 +1,12 @@ +Without on-prem password protection, weak passwords created in AD never reach Microsoft's banned-password engine. Enforce mode rejects bad passwords at change time on every domain controller. + +**Remediation Action** + +1. Install the Entra Password Protection proxy and DC agent on every domain controller. +2. Microsoft Entra > Authentication methods > Password protection: Enable password protection on Windows Server Active Directory + Mode = Enforced. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.ps1 new file mode 100644 index 000000000000..3931d96602ee --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_3.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestCIS_5_2_3_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.3) - Password protection SHALL be enabled for on-prem Active Directory + #> + param($Tenant) + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'Organization' + + if (-not $Settings -or -not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Settings or Organization) not found.' -Risk 'Medium' -Name 'Password protection is enabled for on-prem Active Directory' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Authentication' + return + } + + $OrgCfg = $Org | Select-Object -First 1 + if ($OrgCfg.onPremisesSyncEnabled -ne $true) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_3' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'Tenant is cloud-only — recommendation does not apply.' -Risk 'Medium' -Name 'Password protection is enabled for on-prem Active Directory' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Authentication' + return + } + + $PwdSetting = $Settings | Where-Object { $_.displayName -eq 'Password Rule Settings' -or $_.templateId -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' } | Select-Object -First 1 + $EnableForOnPrem = ($PwdSetting.values | Where-Object { $_.name -eq 'EnableBannedPasswordCheckOnPremises' }).value + $Mode = ($PwdSetting.values | Where-Object { $_.name -eq 'BannedPasswordCheckOnPremisesMode' }).value + + if ($EnableForOnPrem -eq 'True' -and $Mode -eq 'Enforce') { + $Status = 'Passed' + $Result = 'On-prem password protection is enabled in Enforce mode.' + } else { + $Status = 'Failed' + $Result = "On-prem password protection is not in Enforce mode. EnableBannedPasswordCheckOnPremises: $EnableForOnPrem; Mode: $Mode." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Password protection is enabled for on-prem Active Directory' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Password protection is enabled for on-prem Active Directory' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.md new file mode 100644 index 000000000000..7e95c03feb6a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.md @@ -0,0 +1,12 @@ +A user is "MFA capable" once they have at least one strong authentication method registered. CA policies that require MFA fail-open for users without a registered method, so every member must be MFA capable before MFA enforcement gives full coverage. + +**Remediation Action** + +1. Use the Microsoft Entra Authentication Methods registration campaign to nudge users. +2. Run the User Registration Details report (`/reports/authenticationMethods/userRegistrationDetails`) and chase any user with `isMfaCapable = false`. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.ps1 new file mode 100644 index 000000000000..6a9dfef02f6b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_4.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestCIS_5_2_3_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.4) - All member users SHALL be 'MFA capable' + #> + param($Tenant) + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found.' -Risk 'High' -Name "All member users are 'MFA capable'" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Authentication' + return + } + + $Members = $Users | Where-Object { $_.userType -eq 'Member' -and $_.accountEnabled -eq $true } + $NotCapable = @() + foreach ($U in $Members) { + $R = $Reg | Where-Object { $_.id -eq $U.id -or $_.userPrincipalName -eq $U.userPrincipalName } | Select-Object -First 1 + if (-not $R -or $R.isMfaCapable -ne $true) { + $NotCapable += $U + } + } + + if ($NotCapable.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Members.Count) enabled member users are MFA capable." + } else { + $Status = 'Failed' + $Result = "$($NotCapable.Count) of $($Members.Count) enabled member user(s) are not MFA capable.`n`n" + $Result += ($NotCapable | Select-Object -First 25 | ForEach-Object { "- $($_.userPrincipalName)" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "All member users are 'MFA capable'" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "All member users are 'MFA capable'" -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.md new file mode 100644 index 000000000000..5557ca4e731b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.md @@ -0,0 +1,11 @@ +SMS and Voice MFA are vulnerable to SIM swapping, telco intercepts, and call forwarding attacks. CIS recommends disabling them entirely. + +**Remediation Action** + +Microsoft Entra > Authentication methods > Policies — disable SMS and Voice call. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.ps1 new file mode 100644 index 000000000000..5e031ae70fe8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_5.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_5_2_3_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.5) - Weak authentication methods SHALL be disabled (SMS, Voice) + #> + param($Tenant) + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'High' -Name 'Weak authentication methods are disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $Sms = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Sms' } | Select-Object -First 1 + $Voice = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Voice' } | Select-Object -First 1 + + $SmsDisabled = -not $Sms -or $Sms.state -eq 'disabled' + $VoiceDisabled = -not $Voice -or $Voice.state -eq 'disabled' + + if ($SmsDisabled -and $VoiceDisabled) { + $Status = 'Passed' + $Result = 'SMS and Voice authentication methods are both disabled.' + } else { + $Status = 'Failed' + $Result = "Weak methods are still enabled.`n`n- SMS state: $($Sms.state)`n- Voice state: $($Voice.state)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Weak authentication methods are disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Weak authentication methods are disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.md new file mode 100644 index 000000000000..e1bdd80df37e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.md @@ -0,0 +1,11 @@ +System-preferred MFA always offers the user their strongest registered method, defeating attacks that try to downgrade them to a weaker option (SMS, voice). + +**Remediation Action** + +Microsoft Entra > Authentication methods > Settings: System-preferred multifactor authentication = Enabled, Include = All users. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.ps1 new file mode 100644 index 000000000000..877445736a51 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_6.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_5_2_3_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.6) - System-preferred multifactor authentication SHALL be enabled + #> + param($Tenant) + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name 'System-preferred multifactor authentication is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $State = $Cfg.systemCredentialPreferences.state + $TargetType = $Cfg.systemCredentialPreferences.includeTargets.targetType + $TargetId = $Cfg.systemCredentialPreferences.includeTargets.id + + if ($State -eq 'enabled' -and ($TargetId -eq 'all_users' -or $TargetType -eq 'group')) { + $Status = 'Passed' + $Result = "System-preferred MFA is enabled (target: $TargetId)." + } else { + $Status = 'Failed' + $Result = "System-preferred MFA is not enabled. state: $State, target: $TargetId" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'System-preferred multifactor authentication is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'System-preferred multifactor authentication is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.md new file mode 100644 index 000000000000..594fb9c347df --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.md @@ -0,0 +1,11 @@ +Email OTP delivers MFA codes to a guest's mailbox — if that mailbox is compromised, the second factor is too. CIS recommends disabling email OTP and forcing guests to create a Microsoft account. + +**Remediation Action** + +Microsoft Entra > Authentication methods > Email OTP — set to Disabled. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.3.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.ps1 new file mode 100644 index 000000000000..c5fa41c270bf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_3_7.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_5_2_3_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.3.7) - The email OTP authentication method SHALL be disabled + #> + param($Tenant) + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name 'The email OTP authentication method is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $Email = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Email' } | Select-Object -First 1 + + if (-not $Email -or $Email.state -eq 'disabled') { + $Status = 'Passed' + $Result = 'Email OTP authentication method is disabled.' + } else { + $Status = 'Failed' + $Result = "Email OTP authentication method is enabled (state: $($Email.state))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'The email OTP authentication method is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_3_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'The email OTP authentication method is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.md new file mode 100644 index 000000000000..8d76f47c147d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.md @@ -0,0 +1,11 @@ +SSPR enables users to reset their own passwords without going through the helpdesk. With strong MFA, this also reduces social engineering risk targeting the helpdesk. + +**Remediation Action** + +Microsoft Entra > Protection > Password reset > Properties — set Self service password reset enabled = All. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.2.4.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.ps1 new file mode 100644 index 000000000000..18e30ed05d89 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_4_1.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_2_4_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.2.4.1) - 'Self service password reset enabled' SHALL be set to 'All' + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_4_1' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name "'Self service password reset enabled' is set to 'All'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.md new file mode 100644 index 000000000000..2a5474fce5f5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.md @@ -0,0 +1,11 @@ +PIM moves privileged roles from "always active" to "just-in-time" — admins activate the role for a limited time, with MFA + justification, leaving an audit trail. + +**Remediation Action** + +Microsoft Entra > Identity governance > Privileged Identity Management — assign privileged roles as Eligible (not Active) and configure activation settings: MFA, justification, ticket info. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.ps1 new file mode 100644 index 000000000000..cd591efbb4aa --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_1.ps1 @@ -0,0 +1,34 @@ +function Invoke-CippTestCIS_5_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.3.1) - 'Privileged Identity Management' SHALL be used to manage roles + #> + param($Tenant) + + try { + $Eligibility = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleEligibilitySchedules' + $Active = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Roles cache not found.' -Risk 'Medium' -Name "'Privileged Identity Management' is used to manage roles" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Privileged Access' + return + } + + $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + $EligibleAssignmentsForPriv = $Eligibility | Where-Object { $_.roleDefinitionId -in $PrivRoleIds } + + if ($EligibleAssignmentsForPriv -and $EligibleAssignmentsForPriv.Count -gt 0) { + $Status = 'Passed' + $Result = "$($EligibleAssignmentsForPriv.Count) PIM eligible assignment(s) cover privileged roles. Confirm activation requires MFA, justification, and ticket # in the role settings." + } else { + $Status = 'Failed' + $Result = 'No PIM eligible assignments found for privileged roles. PIM is not in use, or every privileged user holds an active assignment.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "'Privileged Identity Management' is used to manage roles" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "'Privileged Identity Management' is used to manage roles" -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.md new file mode 100644 index 000000000000..9950c1a957db --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.md @@ -0,0 +1,11 @@ +Guest accounts proliferate over time. A recurring access review catches stale guests and revokes access automatically. + +**Remediation Action** + +Microsoft Entra > Identity governance > Access reviews > New: target = All Guests dynamic group, recurrence = quarterly, decision = remove access if not reviewed. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.3.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.ps1 new file mode 100644 index 000000000000..b56cfd4362e1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_2.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_3_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.3.2) - 'Access reviews' for Guest Users SHALL be configured + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_2' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name "'Access reviews' for Guest Users are configured" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.md new file mode 100644 index 000000000000..e0eae6ec9efe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.md @@ -0,0 +1,11 @@ +Privileged role membership should not be permanent. Recurring access reviews force re-justification and remove role drift. + +**Remediation Action** + +Microsoft Entra > PIM > Microsoft Entra roles > Access reviews — create a recurring review per privileged role, max duration 14 days. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.3.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.ps1 new file mode 100644 index 000000000000..78a1cdcab3aa --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_3.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_3_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.3.3) - 'Access reviews' for privileged roles SHALL be configured + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_3' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name "'Access reviews' for privileged roles are configured" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.md new file mode 100644 index 000000000000..2df9e34e48dd --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.md @@ -0,0 +1,11 @@ +Approval-gated GA activation prevents a single compromised admin from activating the role unilaterally. A second approver is required, raising the bar for an attacker. + +**Remediation Action** + +Microsoft Entra > PIM > Microsoft Entra roles > Global Administrator > Settings: Require approval to activate = Yes, approvers = (named GA list). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.3.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.ps1 new file mode 100644 index 000000000000..251325e42275 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_4.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_5_3_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.3.4) - Approval SHALL be required for Global Administrator role activation + #> + param($Tenant) + + try { + $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + + if (-not $Policies -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (RoleManagementPolicies or Roles) not found.' -Risk 'High' -Name 'Approval is required for Global Administrator role activation' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + return + } + + $GA = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + + $GAPolicy = $Policies | Where-Object { + $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and + ($_.rules | Where-Object { $_.id -eq 'Approval_EndUser_Assignment' -and $_.setting.isApprovalRequired -eq $true }) + } + + if ($GAPolicy) { + $Status = 'Passed' + $Result = 'A PIM role management policy requires approval for activation. Verify it is scoped to Global Administrator.' + } else { + $Status = 'Failed' + $Result = 'No PIM role management policy with isApprovalRequired = true was found for the GA scope. Configure approval in PIM role settings for Global Administrator.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Approval is required for Global Administrator role activation' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Approval is required for Global Administrator role activation' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.md new file mode 100644 index 000000000000..b80dcf06564f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.md @@ -0,0 +1,11 @@ +Privileged Role Administrator can hand out *any* Entra role. An attacker who activates this role unilaterally can grant themselves Global Administrator. Approval gating is essential. + +**Remediation Action** + +Microsoft Entra > PIM > Microsoft Entra roles > Privileged Role Administrator > Settings: Require approval = Yes. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 5.3.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.ps1 new file mode 100644 index 000000000000..c85e72e9d0b5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_3_5.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_5_3_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (5.3.5) - Approval SHALL be required for Privileged Role Administrator activation + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_3_5' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Approval is required for Privileged Role Administrator activation' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.md new file mode 100644 index 000000000000..ee19878e35b0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.md @@ -0,0 +1,13 @@ +If `AuditDisabled` is true at the organisation level, no mailbox actions are recorded — even with the Unified Audit Log on. + +**Remediation Action** + +```powershell +Set-OrganizationConfig -AuditDisabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.1.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.ps1 new file mode 100644 index 000000000000..1c231cde17bf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_6_1_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.1.1) - 'AuditDisabled' organizationally SHALL be 'False' + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'High' -Name "'AuditDisabled' organizationally is set to 'False'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + return + } + + $Cfg = $Org | Select-Object -First 1 + + if ($Cfg.AuditDisabled -eq $false) { + $Status = 'Passed' + $Result = 'Mailbox auditing is enabled organisation-wide (AuditDisabled: false).' + } else { + $Status = 'Failed' + $Result = "Mailbox auditing is disabled organisation-wide (AuditDisabled: $($Cfg.AuditDisabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "'AuditDisabled' organizationally is set to 'False'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "'AuditDisabled' organizationally is set to 'False'" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.md new file mode 100644 index 000000000000..fc8f34726508 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.md @@ -0,0 +1,13 @@ +Per-mailbox audit actions (AuditOwner / AuditDelegate / AuditAdmin) determine which mailbox events are written to the audit log. Without explicit actions, key forensic events (MailItemsAccessed, SoftDelete, etc.) may not be recorded. + +**Remediation Action** + +```powershell +Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited | Set-Mailbox -AuditEnabled $true -AuditOwner @{Add='MailboxLogin','HardDelete','MoveToDeletedItems','SoftDelete','UpdateFolderPermissions','UpdateInboxRules','MailItemsAccessed'} +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.1.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 new file mode 100644 index 000000000000..28dbec69dc90 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_2.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_6_1_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.1.2) - Mailbox audit actions SHALL be configured + #> + param($Tenant) + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Mailboxes cache not found.' -Risk 'High' -Name 'Mailbox audit actions are configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + return + } + + $User = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'UserMailbox' } + $Failures = $User | Where-Object { $_.AuditEnabled -eq $false -or -not $_.AuditOwner -or $_.AuditOwner.Count -eq 0 } + + if ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($User.Count) user mailbox(es) have auditing enabled with audit actions configured." + } else { + $Status = 'Failed' + $Result = "$($Failures.Count) of $($User.Count) user mailbox(es) have auditing disabled or no audit actions configured." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Mailbox audit actions are configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Mailbox audit actions are configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.md new file mode 100644 index 000000000000..dd5abf42d8df --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.md @@ -0,0 +1,13 @@ +`AuditBypassEnabled` is intended for service accounts that would otherwise generate audit noise. It is also a powerful evasion control if applied to a regular mailbox — actions taken on a bypassed mailbox are not audited. + +**Remediation Action** + +```powershell +Get-MailboxAuditBypassAssociation | Where-Object AuditBypassEnabled | Set-MailboxAuditBypassAssociation -AuditBypassEnabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.1.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.ps1 new file mode 100644 index 000000000000..c5e24dc4edae --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_1_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_6_1_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.1.3) - 'AuditBypassEnabled' SHALL NOT be enabled on mailboxes + #> + param($Tenant) + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Mailboxes cache not found.' -Risk 'High' -Name "'AuditBypassEnabled' is not enabled on mailboxes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + return + } + + $Bypassed = $Mailboxes | Where-Object { $_.AuditBypassEnabled -eq $true } + + if (-not $Bypassed -or $Bypassed.Count -eq 0) { + $Status = 'Passed' + $Result = 'No mailboxes have AuditBypassEnabled set to true.' + } else { + $Status = 'Failed' + $Result = "$($Bypassed.Count) mailbox(es) have audit bypass enabled:`n`n" + $Result += ($Bypassed | Select-Object -First 25 | ForEach-Object { "- $($_.UserPrincipalName)" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name "'AuditBypassEnabled' is not enabled on mailboxes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_1_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name "'AuditBypassEnabled' is not enabled on mailboxes" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Audit & Compliance' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.md new file mode 100644 index 000000000000..a93a8b2b56bb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.md @@ -0,0 +1,14 @@ +Compromised mailboxes are routinely used to set inbox forwarding rules that exfiltrate every email to attacker infrastructure. Block forwarding at the tenant level. + +**Remediation Action** + +```powershell +Set-HostedOutboundSpamFilterPolicy -Identity Default -AutoForwardingMode Off +Set-RemoteDomain -Identity Default -AutoForwardEnabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.ps1 new file mode 100644 index 000000000000..0903f6fecf10 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_1.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestCIS_6_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.2.1) - All forms of mail forwarding SHALL be blocked and/or disabled + #> + param($Tenant) + + try { + $Outbound = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedOutboundSpamFilterPolicy' + $RemoteDomain = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoRemoteDomain' + + if (-not $Outbound) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoHostedOutboundSpamFilterPolicy cache not found.' -Risk 'High' -Name 'All forms of mail forwarding are blocked and/or disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Default = $Outbound | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Outbound | Select-Object -First 1 } + + $AutoForwardOff = $Default.AutoForwardingMode -eq 'Off' + + $RemoteDefault = $RemoteDomain | Where-Object { $_.Name -eq 'Default' } | Select-Object -First 1 + $RemoteForwardOff = -not $RemoteDefault -or $RemoteDefault.AutoForwardEnabled -eq $false + + if ($AutoForwardOff -and $RemoteForwardOff) { + $Status = 'Passed' + $Result = "Auto-forwarding is blocked at the outbound spam filter (AutoForwardingMode: Off) and disabled on the default remote domain." + } else { + $Status = 'Failed' + $Result = "Auto-forwarding is not fully blocked.`n`n- Outbound spam filter AutoForwardingMode: $($Default.AutoForwardingMode)`n- Default remote domain AutoForwardEnabled: $($RemoteDefault.AutoForwardEnabled)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'All forms of mail forwarding are blocked and/or disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'All forms of mail forwarding are blocked and/or disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.md new file mode 100644 index 000000000000..d4b54e8adaf2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.md @@ -0,0 +1,11 @@ +A transport rule that sets SCL to -1 bypasses spam, phish and malware filtering for matching senders. Spoofed messages from those senders pass straight to inboxes. + +**Remediation Action** + +Audit transport rules for `SetSCL = -1`. Replace with sender-based allow lists in the Tenant Allow/Block List or, better, fix the underlying authentication issue (SPF/DKIM). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.ps1 new file mode 100644 index 000000000000..d8bee085a30d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_2.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestCIS_6_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.2.2) - Mail transport rules SHALL NOT whitelist specific domains + #> + param($Tenant) + + try { + $Rules = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoTransportRules' + + if (-not $Rules) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoTransportRules cache not found.' -Risk 'High' -Name 'Mail transport rules do not whitelist specific domains' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Whitelisting = $Rules | Where-Object { + $_.State -eq 'Enabled' -and ( + $_.SetSCL -eq -1 -or + ($_.SetHeaderName -eq 'X-Forefront-Antispam-Report' -and $_.SetHeaderValue -match 'IPV:CAL') -or + ($_.SetHeaderName -eq 'X-MS-Exchange-Organization-BypassClutter') -or + $_.SetSpamConfidenceLevel -eq -1 + ) + } + + if (-not $Whitelisting -or $Whitelisting.Count -eq 0) { + $Status = 'Passed' + $Result = 'No enabled transport rule whitelists senders by setting SCL to -1.' + } else { + $Status = 'Failed' + $Result = "$($Whitelisting.Count) transport rule(s) whitelist senders by SCL=-1:`n`n" + $Result += ($Whitelisting | Select-Object -First 25 | ForEach-Object { "- $($_.Name)" }) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Mail transport rules do not whitelist specific domains' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Mail transport rules do not whitelist specific domains' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.md new file mode 100644 index 000000000000..f73393f177a7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.md @@ -0,0 +1,13 @@ +A clear "External" badge on inbound mail makes display-name impersonation visible to users immediately, breaking the most common phishing attack pattern. + +**Remediation Action** + +```powershell +Set-ExternalInOutlook -Enabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.2.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.ps1 new file mode 100644 index 000000000000..c79bae495e58 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_2_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_6_2_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.2.3) - Email from external senders SHALL be identified + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'Medium' -Name 'Email from external senders is identified' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Cfg = $Org | Select-Object -First 1 + $External = $Cfg.ExternalInOutlookEnabled + + if ($External -eq $true) { + $Status = 'Passed' + $Result = 'External sender callouts are enabled in Outlook (ExternalInOutlookEnabled: true).' + } else { + $Status = 'Failed' + $Result = "External sender callouts are disabled (ExternalInOutlookEnabled: $External). Run Set-ExternalInOutlook -Enabled $true." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Email from external senders is identified' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_2_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Email from external senders is identified' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.md new file mode 100644 index 000000000000..c205130a9caf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.md @@ -0,0 +1,19 @@ +Outlook add-ins can read mailbox contents and exfiltrate data via outbound HTTP. Removing self-service add-in installation forces every add-in through admin review. + +**Remediation Action** + +Remove the three add-in roles from every role assignment policy: + +```powershell +Get-RoleAssignmentPolicy | ForEach-Object { + Remove-ManagementRoleAssignment -Identity ("$($_.Name)\My Custom Apps") -Confirm:$false + Remove-ManagementRoleAssignment -Identity ("$($_.Name)\My Marketplace Apps") -Confirm:$false + Remove-ManagementRoleAssignment -Identity ("$($_.Name)\My ReadWriteMailbox Apps") -Confirm:$false +} +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.ps1 new file mode 100644 index 000000000000..e0218f01d6b9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_3_1.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_6_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.3.1) - Users installing Outlook add-ins SHALL NOT be allowed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_3_1' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'Users installing Outlook add-ins is not allowed' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.md new file mode 100644 index 000000000000..09aa62e2c7a6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.md @@ -0,0 +1,13 @@ +Modern auth (OAuth2) is required for MFA enforcement on Outlook 2013/2016 connecting to Exchange Online. + +**Remediation Action** + +```powershell +Set-OrganizationConfig -OAuth2ClientProfileEnabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.5.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.ps1 new file mode 100644 index 000000000000..ae898e66277f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_6_5_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.5.1) - Modern authentication for Exchange Online SHALL be enabled + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'High' -Name 'Modern authentication for Exchange Online is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $Org | Select-Object -First 1 + + if ($Cfg.OAuth2ClientProfileEnabled -eq $true) { + $Status = 'Passed' + $Result = 'Modern authentication is enabled for Exchange Online (OAuth2ClientProfileEnabled: true).' + } else { + $Status = 'Failed' + $Result = "Modern authentication is disabled (OAuth2ClientProfileEnabled: $($Cfg.OAuth2ClientProfileEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Modern authentication for Exchange Online is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Modern authentication for Exchange Online is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.md new file mode 100644 index 000000000000..1e0f63323dbd --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.md @@ -0,0 +1,13 @@ +MailTips warn senders before they leak data — large audience, external recipient, restricted recipient, etc. They are a low-cost user-education control. + +**Remediation Action** + +```powershell +Set-OrganizationConfig -MailTipsAllTipsEnabled $true -MailTipsExternalRecipientsTipsEnabled $true -MailTipsGroupMetricsEnabled $true -MailTipsLargeAudienceThreshold 25 +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.5.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.ps1 new file mode 100644 index 000000000000..ce53e5cee435 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_2.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_6_5_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.5.2) - MailTips SHALL be enabled for end users + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'Medium' -Name 'MailTips are enabled for end users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + return + } + + $Cfg = $Org | Select-Object -First 1 + + $Compliant = $Cfg.MailTipsAllTipsEnabled -eq $true -and + $Cfg.MailTipsExternalRecipientsTipsEnabled -eq $true -and + $Cfg.MailTipsGroupMetricsEnabled -eq $true -and + [int]$Cfg.MailTipsLargeAudienceThreshold -ge 1 -and [int]$Cfg.MailTipsLargeAudienceThreshold -le 25 + + if ($Compliant) { + $Status = 'Passed' + $Result = "All MailTips are enabled (LargeAudienceThreshold: $($Cfg.MailTipsLargeAudienceThreshold))." + } else { + $Status = 'Failed' + $Result = "MailTips are not fully enabled.`n`n- AllTipsEnabled: $($Cfg.MailTipsAllTipsEnabled)`n- ExternalRecipientsTipsEnabled: $($Cfg.MailTipsExternalRecipientsTipsEnabled)`n- GroupMetricsEnabled: $($Cfg.MailTipsGroupMetricsEnabled)`n- LargeAudienceThreshold: $($Cfg.MailTipsLargeAudienceThreshold)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'MailTips are enabled for end users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'MailTips are enabled for end users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Email Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.md new file mode 100644 index 000000000000..1b86070f3694 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.md @@ -0,0 +1,13 @@ +OWA "Additional storage providers" lets users open and attach files from Dropbox, Box, Google Drive, etc. directly inside the web client — a direct data exfiltration path. + +**Remediation Action** + +```powershell +Get-OwaMailboxPolicy | Set-OwaMailboxPolicy -AdditionalStorageProvidersAvailable $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.5.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.ps1 new file mode 100644 index 000000000000..60e2d4ac79ba --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_6_5_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.5.3) - Additional storage providers SHALL be restricted in Outlook on the web + #> + param($Tenant) + + try { + $Owa = Get-CIPPTestData -TenantFilter $Tenant -Type 'OwaMailboxPolicy' + + if (-not $Owa) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'OwaMailboxPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Additional storage providers are restricted in Outlook on the web' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Default = $Owa | Where-Object { $_.Identity -eq 'OwaMailboxPolicy-Default' -or $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Default) { $Default = $Owa | Select-Object -First 1 } + + if ($Default.AdditionalStorageProvidersAvailable -eq $false) { + $Status = 'Passed' + $Result = "Additional storage providers are disabled on '$($Default.Identity)'." + } else { + $Status = 'Failed' + $Result = "Additional storage providers are enabled on '$($Default.Identity)' (AdditionalStorageProvidersAvailable: $($Default.AdditionalStorageProvidersAvailable))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Additional storage providers are restricted in Outlook on the web' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Additional storage providers are restricted in Outlook on the web' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.md new file mode 100644 index 000000000000..6b193b7f81e9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.md @@ -0,0 +1,13 @@ +SMTP basic auth bypasses MFA and is a frequent vector for password spray and credential stuffing. Disable tenant-wide. + +**Remediation Action** + +```powershell +Set-TransportConfig -SmtpClientAuthenticationDisabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.5.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.ps1 new file mode 100644 index 000000000000..69384d24eacc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_4.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_6_5_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.5.4) - SMTP AUTH SHALL be disabled + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'High' -Name 'SMTP AUTH is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $Org | Select-Object -First 1 + + if ($Cfg.SmtpClientAuthenticationDisabled -eq $true) { + $Status = 'Passed' + $Result = 'SMTP AUTH is disabled organisation-wide (SmtpClientAuthenticationDisabled: true).' + } else { + $Status = 'Failed' + $Result = "SMTP AUTH is enabled organisation-wide (SmtpClientAuthenticationDisabled: $($Cfg.SmtpClientAuthenticationDisabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'SMTP AUTH is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'SMTP AUTH is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.md new file mode 100644 index 000000000000..67ef02842831 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.md @@ -0,0 +1,15 @@ +Direct Send lets unauthenticated SMTP clients send email *as your domain* to your tenant mailboxes. It is a frequent vector for internal phishing because the spoof passes implicit trust checks. + +**Remediation Action** + +Audit which devices currently rely on Direct Send (printers, line-of-business apps), migrate them to authenticated SMTP relay, then: + +```powershell +Set-OrganizationConfig -RejectDirectSend $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 6.5.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.ps1 new file mode 100644 index 000000000000..6ac7fa662d66 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_6_5_5.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_6_5_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (6.5.5) - Direct Send submissions SHALL be rejected + #> + param($Tenant) + + try { + $Org = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoOrganizationConfig' + + if (-not $Org) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ExoOrganizationConfig cache not found.' -Risk 'Medium' -Name 'Direct Send submissions are rejected' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + $Cfg = $Org | Select-Object -First 1 + + if ($Cfg.RejectDirectSend -eq $true) { + $Status = 'Passed' + $Result = 'Direct Send submissions are rejected (RejectDirectSend: true).' + } else { + $Status = 'Failed' + $Result = "Direct Send submissions are accepted (RejectDirectSend: $($Cfg.RejectDirectSend)). Migrate scan-to-mail / app senders to authenticated SMTP relay or to a connector before enabling." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Direct Send submissions are rejected' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_6_5_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Direct Send submissions are rejected' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.md new file mode 100644 index 000000000000..e11b6c81b8c5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.md @@ -0,0 +1,13 @@ +Legacy auth into SharePoint bypasses MFA. Disable to ensure modern OAuth2 flows. + +**Remediation Action** + +```powershell +Set-SPOTenant -LegacyAuthProtocolsEnabled $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.ps1 new file mode 100644 index 000000000000..9760daaa1b4a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_7_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.1) - Modern authentication for SharePoint applications SHALL be required + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Modern authentication for SharePoint applications is required' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $SPO | Select-Object -First 1 + + if ($Cfg.LegacyAuthProtocolsEnabled -eq $false) { + $Status = 'Passed' + $Result = 'SharePoint legacy auth protocols are disabled (LegacyAuthProtocolsEnabled: false).' + } else { + $Status = 'Failed' + $Result = "SharePoint legacy auth protocols are enabled (LegacyAuthProtocolsEnabled: $($Cfg.LegacyAuthProtocolsEnabled))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Modern authentication for SharePoint applications is required' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Modern authentication for SharePoint applications is required' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.md new file mode 100644 index 000000000000..0bbade427d0e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.md @@ -0,0 +1,13 @@ +Email-based verification codes for guest sign-ins should re-prompt regularly so a stale link / forwarded code can't be used indefinitely. + +**Remediation Action** + +```powershell +Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15 +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.10](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.ps1 new file mode 100644 index 000000000000..c5b531c9c55b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_10.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestCIS_7_2_10 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.10) - Reauthentication with verification code SHALL be restricted + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_10' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Reauthentication with verification code is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $Required = $Cfg.EmailAttestationRequired + $Days = [int]$Cfg.EmailAttestationReAuthDays + + if ($Required -eq $true -and $Days -gt 0 -and $Days -le 15) { + $Status = 'Passed' + $Result = "Email attestation re-auth is enforced every $Days days." + } else { + $Status = 'Failed' + $Result = "Email attestation re-auth is not enforced (EmailAttestationRequired: $Required, EmailAttestationReAuthDays: $Days). CIS recommends 15 or less." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_10' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Reauthentication with verification code is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_10' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Reauthentication with verification code is restricted' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.md new file mode 100644 index 000000000000..a55b9ee409f7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.md @@ -0,0 +1,13 @@ +Defaulting to View prevents accidental Edit-grants when sharing files. Edit must be a deliberate user choice. + +**Remediation Action** + +```powershell +Set-SPOTenant -DefaultLinkPermission View +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.11](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 new file mode 100644 index 000000000000..93bd496c3038 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_11.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_7_2_11 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.11) - SharePoint default sharing link permission SHALL be set + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_11' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'The SharePoint default sharing link permission is set' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $SPO | Select-Object -First 1 + + if ($Cfg.DefaultLinkPermission -eq 'View') { + $Status = 'Passed' + $Result = 'DefaultLinkPermission is set to View.' + } else { + $Status = 'Failed' + $Result = "DefaultLinkPermission is set to $($Cfg.DefaultLinkPermission). CIS requires View." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_11' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'The SharePoint default sharing link permission is set' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_11' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'The SharePoint default sharing link permission is set' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.md new file mode 100644 index 000000000000..66e988aecced --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.md @@ -0,0 +1,13 @@ +Azure AD B2B integration ensures every external user accessing SharePoint / OneDrive is a managed guest, subject to Conditional Access and Access Reviews — not an ad-hoc external sharing identity. + +**Remediation Action** + +```powershell +Set-SPOTenant -EnableAzureADB2BIntegration $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.ps1 new file mode 100644 index 000000000000..79e1033d6b4b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_7_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.2) - SharePoint and OneDrive integration with Azure AD B2B SHALL be enabled + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'SharePoint and OneDrive integration with Azure AD B2B is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + + if ($Cfg.EnableAzureADB2BIntegration -eq $true) { + $Status = 'Passed' + $Result = 'SharePoint / OneDrive Entra B2B integration is enabled.' + } else { + $Status = 'Failed' + $Result = "SharePoint / OneDrive Entra B2B integration is disabled (EnableAzureADB2BIntegration: $($Cfg.EnableAzureADB2BIntegration))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'SharePoint and OneDrive integration with Azure AD B2B is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'SharePoint and OneDrive integration with Azure AD B2B is enabled' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.md new file mode 100644 index 000000000000..13b84eca3ac6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.md @@ -0,0 +1,13 @@ +SharePoint's `SharingCapability` controls how external users can be invited. `ExternalUserAndGuestSharing` allows unauthenticated "Anyone" links. CIS recommends restricting to existing or new authenticated guests only. + +**Remediation Action** + +```powershell +Set-SPOTenant -SharingCapability ExternalUserSharingOnly +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.ps1 new file mode 100644 index 000000000000..cb3523f4fbd5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_7_2_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.3) - External content sharing SHALL be restricted + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'External content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $Allowed = @('Disabled', 'ExistingExternalUserSharingOnly', 'ExternalUserSharingOnly') + + if ($Cfg.SharingCapability -in $Allowed) { + $Status = 'Passed' + $Result = "SharePoint SharingCapability is restricted ($($Cfg.SharingCapability))." + } else { + $Status = 'Failed' + $Result = "SharePoint SharingCapability is too permissive ($($Cfg.SharingCapability)). Set to ExternalUserSharingOnly or more restrictive." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'External content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'External content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.md new file mode 100644 index 000000000000..0d4e7fa4d7d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.md @@ -0,0 +1,13 @@ +OneDrive sharing should be at least as restrictive as SharePoint sharing — otherwise users can route around tenant-level sharing policy by using their personal OneDrive. + +**Remediation Action** + +```powershell +Set-SPOTenant -OneDriveSharingCapability ExternalUserSharingOnly +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.ps1 new file mode 100644 index 000000000000..d6a266f69684 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_4.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_7_2_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.4) - OneDrive content sharing SHALL be restricted + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'OneDrive content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $OneDrive = $Cfg.OneDriveSharingCapability + $SP = $Cfg.SharingCapability + $Allowed = @('Disabled', 'ExistingExternalUserSharingOnly', 'ExternalUserSharingOnly') + + # OneDrive must be at least as restrictive as SharePoint + if ($OneDrive -in $Allowed) { + $Status = 'Passed' + $Result = "OneDriveSharingCapability is restricted ($OneDrive). SharePoint SharingCapability: $SP." + } else { + $Status = 'Failed' + $Result = "OneDriveSharingCapability is too permissive ($OneDrive). Set to ExternalUserSharingOnly or more restrictive." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'OneDrive content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'OneDrive content sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.md new file mode 100644 index 000000000000..4e2d9e09b5b9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.md @@ -0,0 +1,13 @@ +External users should not be able to broaden the audience for content they were given access to. Disabling re-sharing keeps distribution under the control of the file owner. + +**Remediation Action** + +```powershell +Set-SPOTenant -PreventExternalUsersFromResharing $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.ps1 new file mode 100644 index 000000000000..fe949f0edb5c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_5.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_7_2_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.5) - SharePoint guest users SHALL NOT share items they don't own + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "SharePoint guest users cannot share items they don't own" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + + if ($Cfg.PreventExternalUsersFromResharing -eq $true) { + $Status = 'Passed' + $Result = 'External users cannot reshare (PreventExternalUsersFromResharing: true).' + } else { + $Status = 'Failed' + $Result = "External users can reshare (PreventExternalUsersFromResharing: $($Cfg.PreventExternalUsersFromResharing))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "SharePoint guest users cannot share items they don't own" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "SharePoint guest users cannot share items they don't own" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.md new file mode 100644 index 000000000000..b1c478f0f060 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.md @@ -0,0 +1,13 @@ +Restrict external sharing to a known list of partner domains so users can't share with unknown organisations. + +**Remediation Action** + +```powershell +Set-SPOTenant -SharingDomainRestrictionMode AllowList -SharingAllowedDomainList 'partner1.com partner2.com' +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.ps1 new file mode 100644 index 000000000000..99114158bf30 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_6.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_7_2_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.6) - SharePoint external sharing SHALL be restricted + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'SharePoint external sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $Mode = $Cfg.SharingDomainRestrictionMode + $AllowList = $Cfg.SharingAllowedDomainList + $BlockList = $Cfg.SharingBlockedDomainList + + $Pass = ($Mode -eq 'AllowList' -and -not [string]::IsNullOrWhiteSpace($AllowList)) -or + ($Mode -eq 'BlockList' -and -not [string]::IsNullOrWhiteSpace($BlockList)) + + if ($Pass) { + $Status = 'Passed' + $Result = "External sharing is restricted by domain list (mode: $Mode)." + } else { + $Status = 'Failed' + $Result = "External sharing is not restricted by domain list (SharingDomainRestrictionMode: $Mode)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'SharePoint external sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'SharePoint external sharing is restricted' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.md new file mode 100644 index 000000000000..1707d67e3a4a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.md @@ -0,0 +1,13 @@ +When the default link type is "Anyone with the link" users accidentally publish content to the internet. Default to Direct (specific people) so anonymous links are an explicit choice. + +**Remediation Action** + +```powershell +Set-SPOTenant -DefaultSharingLinkType Direct +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 new file mode 100644 index 000000000000..e328daf54e80 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_7.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_7_2_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.7) - Link sharing SHALL be restricted in SharePoint and OneDrive + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Link sharing is restricted in SharePoint and OneDrive' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $Acceptable = @('Direct', 'Internal') + + if ($Cfg.DefaultSharingLinkType -in $Acceptable) { + $Status = 'Passed' + $Result = "DefaultSharingLinkType is restricted ($($Cfg.DefaultSharingLinkType))." + } else { + $Status = 'Failed' + $Result = "DefaultSharingLinkType is too permissive ($($Cfg.DefaultSharingLinkType)). Set to Direct or Internal." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Link sharing is restricted in SharePoint and OneDrive' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Link sharing is restricted in SharePoint and OneDrive' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.md new file mode 100644 index 000000000000..4fd48541215c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.md @@ -0,0 +1,11 @@ +Limiting external sharing to a security group lets you control who in the organisation can share externally — useful for staged rollouts of guest collaboration. + +**Remediation Action** + +SharePoint admin centre > Policies > Sharing > Limit external sharing by security group. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.8](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.ps1 new file mode 100644 index 000000000000..98a929b52950 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_8.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestCIS_7_2_8 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.8) - External sharing SHALL be restricted by security group + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_8' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually.' -Risk 'Informational' -Name 'External sharing is restricted by security group' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.md new file mode 100644 index 000000000000..a28458deef86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.md @@ -0,0 +1,13 @@ +Set guest external access to expire after a fixed window so dormant guests automatically lose access without manual cleanup. + +**Remediation Action** + +```powershell +Set-SPOTenant -ExternalUserExpirationRequired $true -ExternalUserExpireInDays 30 +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.2.9](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.ps1 new file mode 100644 index 000000000000..80026e166328 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_2_9.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestCIS_7_2_9 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.2.9) - Guest access to a site or OneDrive SHALL expire automatically + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_9' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'Guest access to a site or OneDrive will expire automatically' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $SPO | Select-Object -First 1 + $Required = $Cfg.ExternalUserExpirationRequired + $Days = [int]$Cfg.ExternalUserExpireInDays + + if ($Required -eq $true -and $Days -gt 0 -and $Days -le 30) { + $Status = 'Passed' + $Result = "External user expiration is enforced after $Days days." + } else { + $Status = 'Failed' + $Result = "External user expiration is not enforced (ExternalUserExpirationRequired: $Required, ExternalUserExpireInDays: $Days). CIS recommends 30 or less." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_9' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Guest access to a site or OneDrive will expire automatically' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_2_9' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Guest access to a site or OneDrive will expire automatically' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.md new file mode 100644 index 000000000000..3fcb38f35791 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.md @@ -0,0 +1,13 @@ +Microsoft scans SharePoint and OneDrive content for malware. Without `DisallowInfectedFileDownload`, infected files can still be downloaded — Microsoft only flags them. + +**Remediation Action** + +```powershell +Set-SPOTenant -DisallowInfectedFileDownload $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.3.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.ps1 new file mode 100644 index 000000000000..ae5b57d105c6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_7_3_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.3.1) - Office 365 SharePoint infected files SHALL be disallowed for download + #> + param($Tenant) + + try { + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'SPOTenant cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'Office 365 SharePoint infected files are disallowed for download' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $SPO | Select-Object -First 1 + + if ($Cfg.DisallowInfectedFileDownload -eq $true) { + $Status = 'Passed' + $Result = 'Infected files cannot be downloaded (DisallowInfectedFileDownload: true).' + } else { + $Status = 'Failed' + $Result = "Infected files are still downloadable (DisallowInfectedFileDownload: $($Cfg.DisallowInfectedFileDownload))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Office 365 SharePoint infected files are disallowed for download' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Office 365 SharePoint infected files are disallowed for download' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.md new file mode 100644 index 000000000000..1537d891f930 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.md @@ -0,0 +1,16 @@ +Allowing OneDrive to sync to any device puts company data on personal / unmanaged endpoints. Restrict sync to AD-joined or compliant devices. + +**Remediation Action** + +```powershell +# Hybrid AD +Set-SPOTenantSyncClientRestriction -Enable -DomainGuids '' +# Entra-only +Set-SPOTenant -ConditionalAccessPolicy AllowLimitedAccess +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 7.3.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.ps1 new file mode 100644 index 000000000000..c239e32f08a3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_7_3_2.ps1 @@ -0,0 +1,36 @@ +function Invoke-CippTestCIS_7_3_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (7.3.2) - OneDrive sync SHALL be restricted for unmanaged devices + #> + param($Tenant) + + try { + $Sync = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenantSyncClientRestriction' + $SPO = Get-CIPPTestData -TenantFilter $Tenant -Type 'SPOTenant' + + if (-not $Sync -and -not $SPO) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (SPOTenantSyncClientRestriction or SPOTenant) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'OneDrive sync is restricted for unmanaged devices' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + return + } + + $S = $Sync | Select-Object -First 1 + $T = $SPO | Select-Object -First 1 + + $DomainRestricted = $S.TenantRestrictionEnabled -eq $true -and -not [string]::IsNullOrWhiteSpace($S.AllowedDomainList) + $CARestricted = $T.ConditionalAccessPolicy -in @('AllowLimitedAccess', 'BlockAccess') + + if ($DomainRestricted -or $CARestricted) { + $Status = 'Passed' + $Result = "OneDrive sync is restricted for unmanaged devices.`n`n- TenantRestrictionEnabled: $($S.TenantRestrictionEnabled)`n- ConditionalAccessPolicy: $($T.ConditionalAccessPolicy)" + } else { + $Status = 'Failed' + $Result = "OneDrive sync is not restricted for unmanaged devices.`n`n- TenantRestrictionEnabled: $($S.TenantRestrictionEnabled)`n- ConditionalAccessPolicy: $($T.ConditionalAccessPolicy)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'OneDrive sync is restricted for unmanaged devices' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_7_3_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'OneDrive sync is restricted for unmanaged devices' -UserImpact 'High' -ImplementationEffort 'Medium' -Category 'Device Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.md new file mode 100644 index 000000000000..94ee7ff3019f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.md @@ -0,0 +1,13 @@ +Teams can attach files from third-party storage providers. Disable any provider not in the approved list. + +**Remediation Action** + +```powershell +Set-CsTeamsClientConfiguration -Identity Global -AllowDropbox $false -AllowBox $false -AllowGoogleDrive $false -AllowShareFile $false -AllowEgnyte $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.1.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.ps1 new file mode 100644 index 000000000000..25c9ddca3b03 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_1.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_8_1_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.1.1) - External file sharing in Teams SHALL be enabled for only approved cloud storage services + #> + param($Tenant) + + try { + $Client = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsClientConfiguration' + + if (-not $Client) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsClientConfiguration cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'External file sharing in Teams is enabled for only approved cloud storage services' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $Client | Select-Object -First 1 + $Enabled = @() + if ($Cfg.AllowDropbox) { $Enabled += 'Dropbox' } + if ($Cfg.AllowBox) { $Enabled += 'Box' } + if ($Cfg.AllowGoogleDrive) { $Enabled += 'GoogleDrive' } + if ($Cfg.AllowShareFile) { $Enabled += 'ShareFile' } + if ($Cfg.AllowEgnyte) { $Enabled += 'Egnyte' } + + if ($Enabled.Count -eq 0) { + $Status = 'Passed' + $Result = 'No third-party cloud storage providers are enabled in Teams.' + } else { + $Status = 'Failed' + $Result = "Third-party cloud storage providers are enabled in Teams: $($Enabled -join ', ')." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'External file sharing in Teams is enabled for only approved cloud storage services' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'External file sharing in Teams is enabled for only approved cloud storage services' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.md new file mode 100644 index 000000000000..3dcb53b2058f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.md @@ -0,0 +1,13 @@ +Channel email addresses bypass Exchange transport rules and Defender protections, providing a path for malicious content to reach Teams channels directly. + +**Remediation Action** + +```powershell +Set-CsTeamsClientConfiguration -Identity Global -AllowEmailIntoChannel $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.1.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.ps1 new file mode 100644 index 000000000000..13fe3fd55e98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_1_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_1_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.1.2) - Users SHALL NOT be able to send emails to a channel email address + #> + param($Tenant) + + try { + $Client = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsClientConfiguration' + + if (-not $Client) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsClientConfiguration cache not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name "Users can't send emails to a channel email address" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + return + } + + $Cfg = $Client | Select-Object -First 1 + + if ($Cfg.AllowEmailIntoChannel -eq $false) { + $Status = 'Passed' + $Result = 'Email-into-channel is disabled (AllowEmailIntoChannel: false).' + } else { + $Status = 'Failed' + $Result = "Email-into-channel is enabled (AllowEmailIntoChannel: $($Cfg.AllowEmailIntoChannel))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Users can't send emails to a channel email address" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_1_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Users can't send emails to a channel email address" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.md new file mode 100644 index 000000000000..10dc6c02f674 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.md @@ -0,0 +1,13 @@ +Open federation lets any Teams tenant on the internet reach your users. Restrict to a known set of partner domains. + +**Remediation Action** + +```powershell +Set-CsTenantFederationConfiguration -AllowedDomains (New-CsEdgeAllowList -AllowedDomain 'partner1.com','partner2.com') +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.2.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 new file mode 100644 index 000000000000..4d5af185378f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_8_2_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.2.1) - External domains SHALL be restricted in the Teams admin center + #> + param($Tenant) + + try { + $External = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsExternalAccessPolicy' + $Federation = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTenantFederationConfiguration' + + if (-not $External -and -not $Federation) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (CsExternalAccessPolicy or CsTenantFederationConfiguration) not found. Please refresh the cache for this tenant.' -Risk 'Medium' -Name 'External domains are restricted in the Teams admin center' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + return + } + + $E = $External | Select-Object -First 1 + $F = $Federation | Select-Object -First 1 + + $PolicyDisabled = $E.EnableFederationAccess -eq $false + $TenantDisabled = $F.AllowFederatedUsers -eq $false + $TenantAllowList = $F.AllowedDomains -and ($F.AllowedDomains.AllowedDomain -or ($F.AllowedDomains -is [array] -and $F.AllowedDomains.Count -gt 0)) + + if ($PolicyDisabled -or $TenantDisabled -or $TenantAllowList) { + $Status = 'Passed' + $Result = "External domains are restricted.`n`n- EnableFederationAccess (policy): $($E.EnableFederationAccess)`n- AllowFederatedUsers (tenant): $($F.AllowFederatedUsers)" + } else { + $Status = 'Failed' + $Result = "External domains are not restricted.`n`n- EnableFederationAccess (policy): $($E.EnableFederationAccess)`n- AllowFederatedUsers (tenant): $($F.AllowFederatedUsers)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'External domains are restricted in the Teams admin center' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'External domains are restricted in the Teams admin center' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.md new file mode 100644 index 000000000000..47a21a17b669 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.md @@ -0,0 +1,13 @@ +Microsoft Teams (free) accounts can be created in seconds for phishing — Midnight Blizzard, DarkGate and others use them to deliver payloads. Disable communication with unmanaged Teams. + +**Remediation Action** + +```powershell +Set-CsExternalAccessPolicy -Identity Global -EnableTeamsConsumerAccess $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.2.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.ps1 new file mode 100644 index 000000000000..3c50d72d194d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_2.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestCIS_8_2_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.2.2) - Communication with unmanaged Teams users SHALL be disabled + #> + param($Tenant) + + try { + $External = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsExternalAccessPolicy' + $Federation = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTenantFederationConfiguration' + + if (-not $External -and -not $Federation) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (CsExternalAccessPolicy or CsTenantFederationConfiguration) not found.' -Risk 'High' -Name 'Communication with unmanaged Teams users is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $E = $External | Select-Object -First 1 + $F = $Federation | Select-Object -First 1 + + if ($E.EnableTeamsConsumerAccess -eq $false -or $F.AllowTeamsConsumer -eq $false) { + $Status = 'Passed' + $Result = "Communication with unmanaged Teams users is blocked.`n`n- EnableTeamsConsumerAccess (policy): $($E.EnableTeamsConsumerAccess)`n- AllowTeamsConsumer (tenant): $($F.AllowTeamsConsumer)" + } else { + $Status = 'Failed' + $Result = "Communication with unmanaged Teams users is allowed.`n`n- EnableTeamsConsumerAccess (policy): $($E.EnableTeamsConsumerAccess)`n- AllowTeamsConsumer (tenant): $($F.AllowTeamsConsumer)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Communication with unmanaged Teams users is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Communication with unmanaged Teams users is disabled' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.md new file mode 100644 index 000000000000..6eec9469075d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.md @@ -0,0 +1,13 @@ +If 8.2.2 must be relaxed for collaboration, this control mitigates by ensuring external users can't be the *initiator* of a chat — internal users must invite first. + +**Remediation Action** + +```powershell +Set-CsTeamsMessagingPolicy -Identity Global -UseB2BInvitesToAddExternalUsers $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.2.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.ps1 new file mode 100644 index 000000000000..e459d2daea9f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_3.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_2_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.2.3) - External Teams users SHALL NOT be able to initiate conversations + #> + param($Tenant) + + try { + $Messaging = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMessagingPolicy' + + if (-not $Messaging) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMessagingPolicy cache not found.' -Risk 'High' -Name 'External Teams users cannot initiate conversations' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $Messaging | Select-Object -First 1 + + if ($Cfg.UseB2BInvitesToAddExternalUsers -eq $false) { + $Status = 'Passed' + $Result = 'External users cannot initiate Teams chats via email (UseB2BInvitesToAddExternalUsers: false).' + } else { + $Status = 'Failed' + $Result = "External users can initiate Teams chats via email (UseB2BInvitesToAddExternalUsers: $($Cfg.UseB2BInvitesToAddExternalUsers))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'External Teams users cannot initiate conversations' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'External Teams users cannot initiate conversations' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.md new file mode 100644 index 000000000000..615cdbbb0c89 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.md @@ -0,0 +1,11 @@ +Trial Teams tenants are short-lived, low-friction accounts attackers spin up to bypass identity controls. Block federation with them. + +**Remediation Action** + +Teams admin centre > Users > External access — uncheck the trial tenants option. + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.2.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.ps1 new file mode 100644 index 000000000000..6db74cdf7292 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_4.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_2_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.2.4) - Organization SHALL NOT communicate with accounts in trial Teams tenants + #> + param($Tenant) + + try { + $Federation = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTenantFederationConfiguration' + + if (-not $Federation) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTenantFederationConfiguration cache not found.' -Risk 'High' -Name 'The organization cannot communicate with accounts in trial Teams tenants' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + return + } + + $Cfg = $Federation | Select-Object -First 1 + + if ($Cfg.AllowTeamsConsumer -eq $false) { + $Status = 'Passed' + $Result = 'Trial Teams tenant communication is blocked (AllowTeamsConsumer: false).' + } else { + $Status = 'Failed' + $Result = "Trial Teams tenant communication is allowed (AllowTeamsConsumer: $($Cfg.AllowTeamsConsumer))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'The organization cannot communicate with accounts in trial Teams tenants' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_2_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'The organization cannot communicate with accounts in trial Teams tenants' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'External Collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.md new file mode 100644 index 000000000000..c495e0ed233b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.md @@ -0,0 +1,11 @@ +Teams apps run with broad permissions. CIS recommends an explicit allow-list of third-party apps managed via permission policies. + +**Remediation Action** + +Teams admin centre > Teams apps > Permission policies > Global — set Third-party apps to Block all apps (or Allow specific apps and add the approved list). + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.4.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.ps1 new file mode 100644 index 000000000000..e8c8a1f7ba57 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_4_1.ps1 @@ -0,0 +1,35 @@ +function Invoke-CippTestCIS_8_4_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.4.1) - App permission policies SHALL be configured + #> + param($Tenant) + + try { + $AppPerms = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsAppPermissionPolicy' + + if (-not $AppPerms) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_4_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsAppPermissionPolicy cache not found.' -Risk 'Medium' -Name 'App permission policies are configured' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Application Management' + return + } + + $Global = $AppPerms | Where-Object { $_.Identity -eq 'Global' } | Select-Object -First 1 + if (-not $Global) { $Global = $AppPerms | Select-Object -First 1 } + + $ThirdParty = $Global.GlobalCatalogAppsType ?? $Global.DefaultCatalogAppsType + $Restricted = $ThirdParty -in @('BlockedAppList', 'AllowedAppList', 'BlockAllApps') + + if ($Restricted) { + $Status = 'Passed' + $Result = "Teams App Permission Policy restricts third-party apps (mode: $ThirdParty)." + } else { + $Status = 'Failed' + $Result = "Teams App Permission Policy allows all third-party apps (mode: $ThirdParty). Set to BlockedAppList, AllowedAppList, or BlockAllApps." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_4_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'App permission policies are configured' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Application Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_4_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'App permission policies are configured' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.md new file mode 100644 index 000000000000..e376a84995bf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.md @@ -0,0 +1,13 @@ +Anonymous meeting joiners are unauthenticated — there is no audit trail of *who* attended. Disable anonymous join unless you run external-facing webinars. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.ps1 new file mode 100644 index 000000000000..f675ac2fbd29 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_1.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.1) - Anonymous users SHALL NOT be able to join a meeting + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name "Anonymous users can't join a meeting" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowAnonymousUsersToJoinMeeting -eq $false) { + $Status = 'Passed' + $Result = 'Anonymous users cannot join meetings (AllowAnonymousUsersToJoinMeeting: false).' + } else { + $Status = 'Failed' + $Result = "Anonymous users can join meetings (AllowAnonymousUsersToJoinMeeting: $($Cfg.AllowAnonymousUsersToJoinMeeting))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Anonymous users can't join a meeting" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Anonymous users can't join a meeting" -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.md new file mode 100644 index 000000000000..c3b94088516a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.md @@ -0,0 +1,13 @@ +If anonymous users can start a meeting, they can also start an unattended meeting and host content sharing without any organisational presence. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.2](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.ps1 new file mode 100644 index 000000000000..d377ff58c07d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_2.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_2 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.2) - Anonymous users and dial-in callers SHALL NOT be able to start a meeting + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_2' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name "Anonymous users and dial-in callers can't start a meeting" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowAnonymousUsersToStartMeeting -eq $false) { + $Status = 'Passed' + $Result = 'Anonymous users cannot start meetings (AllowAnonymousUsersToStartMeeting: false).' + } else { + $Status = 'Failed' + $Result = "Anonymous users can start meetings (AllowAnonymousUsersToStartMeeting: $($Cfg.AllowAnonymousUsersToStartMeeting))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_2' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Anonymous users and dial-in callers can't start a meeting" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_2' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Anonymous users and dial-in callers can't start a meeting" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.md new file mode 100644 index 000000000000..2c1fb78e98f8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.md @@ -0,0 +1,13 @@ +The lobby acts as a gate for unknown participants. Letting only internal users bypass it forces guests to be admitted explicitly. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers EveryoneInCompanyExcludingGuests +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.3](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.ps1 new file mode 100644 index 000000000000..efd8438e341e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_3.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_8_5_3 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.3) - Only people in my org SHALL be able to bypass the lobby + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_3' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name 'Only people in my org can bypass the lobby' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + $Acceptable = @('OrganizerOnly', 'EveryoneInCompanyExcludingGuests', 'InvitedUsers') + + if ($Cfg.AutoAdmittedUsers -in $Acceptable) { + $Status = 'Passed' + $Result = "Lobby bypass restricted (AutoAdmittedUsers: $($Cfg.AutoAdmittedUsers))." + } else { + $Status = 'Failed' + $Result = "Lobby bypass too permissive (AutoAdmittedUsers: $($Cfg.AutoAdmittedUsers)). Set to EveryoneInCompanyExcludingGuests or stricter." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_3' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Only people in my org can bypass the lobby' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_3' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Only people in my org can bypass the lobby' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.md new file mode 100644 index 000000000000..42e6f720f3b8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.md @@ -0,0 +1,13 @@ +PSTN dial-in callers are anonymous from a Teams perspective — caller-ID alone is not authentication. Force them through the lobby. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowPSTNUsersToBypassLobby $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.4](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.ps1 new file mode 100644 index 000000000000..bae0c1dc803c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_4.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_4 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.4) - Users dialing in SHALL NOT bypass the lobby + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name "Users dialing in can't bypass the lobby" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowPSTNUsersToBypassLobby -eq $false) { + $Status = 'Passed' + $Result = 'Dial-in users cannot bypass the lobby (AllowPSTNUsersToBypassLobby: false).' + } else { + $Status = 'Failed' + $Result = "Dial-in users can bypass the lobby (AllowPSTNUsersToBypassLobby: $($Cfg.AllowPSTNUsersToBypassLobby))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_4' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "Users dialing in can't bypass the lobby" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_4' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "Users dialing in can't bypass the lobby" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.md new file mode 100644 index 000000000000..6bbf75ef50aa --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.md @@ -0,0 +1,13 @@ +Anonymous users in meeting chat can drop links and files visible to every attendee. Disabling chat for anonymous users removes that vector. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType EnabledExceptAnonymous +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.5](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.ps1 new file mode 100644 index 000000000000..7a8af5dc8a9d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_5.ps1 @@ -0,0 +1,32 @@ +function Invoke-CippTestCIS_8_5_5 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.5) - Meeting chat SHALL NOT allow anonymous users + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_5' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name 'Meeting chat does not allow anonymous users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + $Acceptable = @('EnabledExceptAnonymous', 'Disabled') + + if ($Cfg.MeetingChatEnabledType -in $Acceptable) { + $Status = 'Passed' + $Result = "Anonymous users cannot use meeting chat (MeetingChatEnabledType: $($Cfg.MeetingChatEnabledType))." + } else { + $Status = 'Failed' + $Result = "Anonymous users can use meeting chat (MeetingChatEnabledType: $($Cfg.MeetingChatEnabledType)). Set to EnabledExceptAnonymous." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_5' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Meeting chat does not allow anonymous users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_5' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Meeting chat does not allow anonymous users' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.md new file mode 100644 index 000000000000..536fff20ff31 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.md @@ -0,0 +1,13 @@ +Default presenter role of "Everyone" lets any participant share screen content — including hostile attendees. Restrict to organizer / co-organizer. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -DesignatedPresenterRoleMode OrganizerOnlyUserOverride +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.6](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.ps1 new file mode 100644 index 000000000000..14a7c77b88ec --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_6.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_6 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.6) - Only organizers and co-organizers SHALL be able to present + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_6' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name 'Only organizers and co-organizers can present' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.DesignatedPresenterRoleMode -eq 'OrganizerOnlyUserOverride') { + $Status = 'Passed' + $Result = 'Only organizers / co-organizers can present.' + } else { + $Status = 'Failed' + $Result = "Anyone can present by default (DesignatedPresenterRoleMode: $($Cfg.DesignatedPresenterRoleMode)). Set to OrganizerOnlyUserOverride." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_6' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Only organizers and co-organizers can present' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_6' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Only organizers and co-organizers can present' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.md new file mode 100644 index 000000000000..7dadaa413c2c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.md @@ -0,0 +1,13 @@ +"Give control" hands keyboard / mouse to another participant, who can then run commands on the presenter's machine. External participants must not have this ability. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalParticipantGiveRequestControl $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.7](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.ps1 new file mode 100644 index 000000000000..33f080c781c6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_7.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_7 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.7) - External participants SHALL NOT give or request control + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_7' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name "External participants can't give or request control" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowExternalParticipantGiveRequestControl -eq $false) { + $Status = 'Passed' + $Result = 'External participants cannot give or request control.' + } else { + $Status = 'Failed' + $Result = "External participants can give or request control (AllowExternalParticipantGiveRequestControl: $($Cfg.AllowExternalParticipantGiveRequestControl))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_7' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name "External participants can't give or request control" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_7' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name "External participants can't give or request control" -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.md new file mode 100644 index 000000000000..b159420d0db4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.md @@ -0,0 +1,13 @@ +External meeting chats persist across calls and create a long-lived chat thread with non-federated externals. Disable to limit chat to the meeting itself. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.8](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.ps1 new file mode 100644 index 000000000000..77498d547504 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_8.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_8 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.8) - External meeting chat SHALL be off + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_8' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name 'External meeting chat is off' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowExternalNonTrustedMeetingChat -eq $false) { + $Status = 'Passed' + $Result = 'External non-trusted meeting chat is disabled.' + } else { + $Status = 'Failed' + $Result = "External non-trusted meeting chat is enabled (AllowExternalNonTrustedMeetingChat: $($Cfg.AllowExternalNonTrustedMeetingChat))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_8' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'External meeting chat is off' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_8' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'External meeting chat is off' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.md new file mode 100644 index 000000000000..909a06c474fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.md @@ -0,0 +1,13 @@ +Default-on recording captures meetings that may include sensitive content (HR, M&A, customer data). Default off and grant recording rights only where there is a business need. + +**Remediation Action** + +```powershell +Set-CsTeamsMeetingPolicy -Identity Global -AllowCloudRecording $false +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.5.9](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.ps1 new file mode 100644 index 000000000000..950209604941 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_5_9.ps1 @@ -0,0 +1,31 @@ +function Invoke-CippTestCIS_8_5_9 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.5.9) - Meeting recording SHALL be off by default + #> + param($Tenant) + + try { + $MP = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMeetingPolicy' + + if (-not $MP) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_9' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'CsTeamsMeetingPolicy cache not found.' -Risk 'Medium' -Name 'Meeting recording is off by default' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + return + } + + $Cfg = $MP | Select-Object -First 1 + + if ($Cfg.AllowCloudRecording -eq $false) { + $Status = 'Passed' + $Result = 'Cloud recording is disabled in the global meeting policy.' + } else { + $Status = 'Failed' + $Result = "Cloud recording is enabled in the global meeting policy (AllowCloudRecording: $($Cfg.AllowCloudRecording))." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_9' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Meeting recording is off by default' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_5_9' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Meeting recording is off by default' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Meetings' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.md b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.md new file mode 100644 index 000000000000..7d4cba74f1be --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.md @@ -0,0 +1,14 @@ +User-reported messages are the most reliable signal of social engineering campaigns. Enable reporting in Teams and forward to the SOC mailbox. + +**Remediation Action** + +```powershell +Set-CsTeamsMessagingPolicy -Identity Global -AllowSecurityEndUserReporting $true +Set-ReportSubmissionPolicy -Identity DefaultReportSubmissionPolicy -ReportJunkToCustomizedAddress $true -ReportPhishToCustomizedAddress $true -ReportNotJunkToCustomizedAddress $true -ReportJunkAddresses 'soc@contoso.com' -ReportNotJunkAddresses 'soc@contoso.com' -ReportPhishAddresses 'soc@contoso.com' -ReportChatMessageEnabled $false -ReportChatMessageToCustomizedAddressEnabled $true +``` + +**Links** +- [CIS Microsoft 365 Foundations Benchmark v6.0.1 - 8.6.1](https://www.cisecurity.org/benchmark/microsoft_365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.ps1 new file mode 100644 index 000000000000..7a1beb834c08 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_6_1.ps1 @@ -0,0 +1,37 @@ +function Invoke-CippTestCIS_8_6_1 { + <# + .SYNOPSIS + Tests CIS M365 6.0.1 (8.6.1) - Users SHALL be able to report security concerns in Teams + #> + param($Tenant) + + try { + $Messaging = Get-CIPPTestData -TenantFilter $Tenant -Type 'CsTeamsMessagingPolicy' + $Submission = Get-CIPPTestData -TenantFilter $Tenant -Type 'ReportSubmissionPolicy' + + if (-not $Messaging -or -not $Submission) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_6_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (CsTeamsMessagingPolicy or ReportSubmissionPolicy) not found.' -Risk 'Medium' -Name 'Users can report security concerns in Teams' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Reporting' + return + } + + $M = $Messaging | Select-Object -First 1 + $S = $Submission | Select-Object -First 1 + + $TeamsReporting = $M.AllowSecurityEndUserReporting -eq $true + $DefenderReporting = ($S.ReportJunkToCustomizedAddress -eq $true -or $S.ReportPhishToCustomizedAddress -eq $true) -and + ($S.ReportChatMessageEnabled -eq $true -or $S.ReportChatMessageToCustomizedAddressEnabled -eq $true) + + if ($TeamsReporting -and $DefenderReporting) { + $Status = 'Passed' + $Result = "Teams security reporting is enabled and routed to a monitored mailbox.`n`n- AllowSecurityEndUserReporting: $($M.AllowSecurityEndUserReporting)`n- ReportChatMessageEnabled: $($S.ReportChatMessageEnabled)`n- ReportChatMessageToCustomizedAddressEnabled: $($S.ReportChatMessageToCustomizedAddressEnabled)" + } else { + $Status = 'Failed' + $Result = "Teams security reporting is not fully configured.`n`n- AllowSecurityEndUserReporting: $($M.AllowSecurityEndUserReporting)`n- ReportJunkToCustomizedAddress: $($S.ReportJunkToCustomizedAddress)`n- ReportPhishToCustomizedAddress: $($S.ReportPhishToCustomizedAddress)`n- ReportChatMessageEnabled: $($S.ReportChatMessageEnabled)`n- ReportChatMessageToCustomizedAddressEnabled: $($S.ReportChatMessageToCustomizedAddressEnabled)" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_6_1' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name 'Users can report security concerns in Teams' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Reporting' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_8_6_1' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Users can report security concerns in Teams' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Reporting' + } +} diff --git a/Modules/CIPPTests/Public/Tests/CIS/report.json b/Modules/CIPPTests/Public/Tests/CIS/report.json new file mode 100644 index 000000000000..25020866c7a6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/CIS/report.json @@ -0,0 +1,137 @@ +{ + "name": "CIS Microsoft 365 Foundations Benchmark v6.0.1", + "description": "Center for Internet Security (CIS) Microsoft 365 Foundations Benchmark v6.0.1 — prescriptive technical baseline for securely configuring a Microsoft 365 tenant across the M365 admin centre, Defender, Purview, Intune, Entra, Exchange Online, SharePoint, and Teams.", + "version": "6.0.1", + "source": "https://www.cisecurity.org/benchmark/microsoft_365", + "category": "CIS Security Baselines", + "IdentityTests": [ + "CIS_1_1_1", + "CIS_1_1_2", + "CIS_1_1_3", + "CIS_1_1_4", + "CIS_1_2_1", + "CIS_1_2_2", + "CIS_1_3_1", + "CIS_1_3_2", + "CIS_1_3_3", + "CIS_1_3_4", + "CIS_1_3_5", + "CIS_1_3_6", + "CIS_1_3_7", + "CIS_1_3_8", + "CIS_1_3_9", + "CIS_2_1_1", + "CIS_2_1_2", + "CIS_2_1_3", + "CIS_2_1_4", + "CIS_2_1_5", + "CIS_2_1_6", + "CIS_2_1_7", + "CIS_2_1_8", + "CIS_2_1_9", + "CIS_2_1_10", + "CIS_2_1_11", + "CIS_2_1_12", + "CIS_2_1_13", + "CIS_2_1_14", + "CIS_2_1_15", + "CIS_2_2_1", + "CIS_2_4_1", + "CIS_2_4_2", + "CIS_2_4_3", + "CIS_2_4_4", + "CIS_3_1_1", + "CIS_3_2_1", + "CIS_3_2_2", + "CIS_3_3_1", + "CIS_4_1", + "CIS_4_2", + "CIS_5_1_2_1", + "CIS_5_1_2_2", + "CIS_5_1_2_3", + "CIS_5_1_2_4", + "CIS_5_1_2_5", + "CIS_5_1_2_6", + "CIS_5_1_3_1", + "CIS_5_1_3_2", + "CIS_5_1_4_1", + "CIS_5_1_4_2", + "CIS_5_1_4_3", + "CIS_5_1_4_4", + "CIS_5_1_4_5", + "CIS_5_1_4_6", + "CIS_5_1_5_1", + "CIS_5_1_5_2", + "CIS_5_1_6_1", + "CIS_5_1_6_2", + "CIS_5_1_6_3", + "CIS_5_1_8_1", + "CIS_5_2_2_1", + "CIS_5_2_2_2", + "CIS_5_2_2_3", + "CIS_5_2_2_4", + "CIS_5_2_2_5", + "CIS_5_2_2_6", + "CIS_5_2_2_7", + "CIS_5_2_2_8", + "CIS_5_2_2_9", + "CIS_5_2_2_10", + "CIS_5_2_2_11", + "CIS_5_2_2_12", + "CIS_5_2_3_1", + "CIS_5_2_3_2", + "CIS_5_2_3_3", + "CIS_5_2_3_4", + "CIS_5_2_3_5", + "CIS_5_2_3_6", + "CIS_5_2_3_7", + "CIS_5_2_4_1", + "CIS_5_3_1", + "CIS_5_3_2", + "CIS_5_3_3", + "CIS_5_3_4", + "CIS_5_3_5", + "CIS_6_1_1", + "CIS_6_1_2", + "CIS_6_1_3", + "CIS_6_2_1", + "CIS_6_2_2", + "CIS_6_2_3", + "CIS_6_3_1", + "CIS_6_5_1", + "CIS_6_5_2", + "CIS_6_5_3", + "CIS_6_5_4", + "CIS_6_5_5", + "CIS_7_2_1", + "CIS_7_2_2", + "CIS_7_2_3", + "CIS_7_2_4", + "CIS_7_2_5", + "CIS_7_2_6", + "CIS_7_2_7", + "CIS_7_2_8", + "CIS_7_2_9", + "CIS_7_2_10", + "CIS_7_2_11", + "CIS_7_3_1", + "CIS_7_3_2", + "CIS_8_1_1", + "CIS_8_1_2", + "CIS_8_2_1", + "CIS_8_2_2", + "CIS_8_2_3", + "CIS_8_2_4", + "CIS_8_4_1", + "CIS_8_5_1", + "CIS_8_5_2", + "CIS_8_5_3", + "CIS_8_5_4", + "CIS_8_5_5", + "CIS_8_5_6", + "CIS_8_5_7", + "CIS_8_5_8", + "CIS_8_5_9", + "CIS_8_6_1" + ] +} From 16c03a17e437dadec75850dbad5f82b4b4922221 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 4 May 2026 21:07:45 +0200 Subject: [PATCH 10/68] add to scheduler --- .../Activity Triggers/Tests/Push-CIPPTestsList.ps1 | 2 +- Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index e38e357fcfca..e28d14425cfc 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -31,7 +31,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom') $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index 6a48ffd6496c..2e129932c92a 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -14,6 +14,7 @@ function Invoke-CIPPTestCollection { - ORCA → Invoke-CippTestORCA* - EIDSCA → Invoke-CippTestEIDSCA* - CISA → Invoke-CippTestCISA* + - CIS → Invoke-CippTestCIS_* - CopilotReadiness → Invoke-CippTestCopilotReady* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function @@ -31,7 +32,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -45,6 +46,7 @@ function Invoke-CIPPTestCollection { ORCA = 'Invoke-CippTestORCA*' EIDSCA = 'Invoke-CippTestEIDSCA*' CISA = 'Invoke-CippTestCISA*' + CIS = 'Invoke-CippTestCIS_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' } From cb51886cd28134fd986faf200ed2ec614e371507 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 4 May 2026 15:42:57 -0400 Subject: [PATCH 11/68] fix: MOERA standard reporting No way to remediate but we can use Read-DmarcRecord directly to query the current value and use that for reporting --- .../Invoke-CIPPStandardAddDMARCToMOERA.ps1 | 56 +++---------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 index 1dc590457dd1..add9e16c88f9 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 @@ -40,12 +40,7 @@ function Invoke-CIPPStandardAddDMARCToMOERA { param($Tenant, $Settings) #$Rerun -Type Standard -Tenant $Tenant -API 'AddDMARCToMOERA' -Settings $Settings - $RecordModel = [PSCustomObject]@{ - HostName = '_dmarc' - TtlValue = 3600 - Type = 'TXT' - Value = $Settings.RecordValue.Value ?? 'v=DMARC1; p=reject;' - } + $DesiredValue = $Settings.RecordValue.Value ?? 'v=DMARC1; p=reject;' # Get all fallback domains (onmicrosoft.com domains) and check if the DMARC record is set correctly try { @@ -56,54 +51,21 @@ function Invoke-CIPPStandardAddDMARCToMOERA { $CurrentInfo = foreach ($Domain in $Domains) { # Get current DNS records that matches _dmarc hostname and TXT type - $RecordsResponse = New-GraphGetRequest -TenantID $Tenant -Uri "https://graph.microsoft.com/beta/domains/$($Domain)/serviceConfigurationRecords" - $AllRecords = @($RecordsResponse) - $CurrentRecords = $AllRecords | Where-Object { - $_.recordType -ieq 'Txt' -and ($_.label -ieq '_dmarc' -or $_.label -ieq "_dmarc.$($Domain)") - } - Write-Information "Found $($CurrentRecords.count) DMARC records for domain $($Domain)" + $RecordsResponse = Read-DmarcPolicy -Domain $Domain + $CurrentRecord = $RecordsResponse.Record + Write-Information "Found DMARC record for domain $($Domain)" - if ($CurrentRecords.count -eq 0) { - #record not found, return a model with Match set to false + if (-not $CurrentRecord) { [PSCustomObject]@{ DomainName = $Domain Match = $false CurrentRecord = $null } } else { - foreach ($CurrentRecord in $CurrentRecords) { - # Create variable matching the RecordModel used for comparison - $CurrentRecordModel = [PSCustomObject]@{ - HostName = '_dmarc' - TtlValue = $CurrentRecord.ttl - Type = 'TXT' - Value = $CurrentRecord.text - } - - # Compare the current record with the expected record model - if (!(Compare-Object -ReferenceObject $RecordModel -DifferenceObject $CurrentRecordModel -Property HostName, TtlValue, Type, Value)) { - [PSCustomObject]@{ - DomainName = $Domain - Match = $true - CurrentRecord = [PSCustomObject]@{ - HostName = '_dmarc' - TtlValue = $CurrentRecord.ttl - Type = 'TXT' - Value = $CurrentRecord.text - } - } - } else { - [PSCustomObject]@{ - DomainName = $Domain - Match = $false - CurrentRecord = [PSCustomObject]@{ - HostName = '_dmarc' - TtlValue = $CurrentRecord.ttl - Type = 'TXT' - Value = $CurrentRecord.text - } - } - } + [PSCustomObject]@{ + DomainName = $Domain + Match = $CurrentRecord -eq $DesiredValue + CurrentRecord = $CurrentRecord } } } From f50d8a1194ccaf546383f3cc29389454fd31887a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 4 May 2026 17:22:03 -0400 Subject: [PATCH 12/68] feat: add Get-CIPPGroupsReport function and integrate with Invoke-ListGroups Co-authored-by: Copilot --- .../CIPPCore/Public/Get-CIPPGroupsReport.ps1 | 58 +++++++++++++++++++ .../Public/DBCache/Set-CIPPDBCacheGroups.ps1 | 38 +++++++++++- .../Groups/Invoke-ListGroups.ps1 | 13 +++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 new file mode 100644 index 000000000000..043fa36c1eb0 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPGroupsReport.ps1 @@ -0,0 +1,58 @@ +function Get-CIPPGroupsReport { + <# + .SYNOPSIS + Generates a groups report from the CIPP Reporting database + + .PARAMETER TenantFilter + The tenant to generate the report for, or 'AllTenants' for all tenants + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + if ($TenantFilter -eq 'AllTenants') { + $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Groups' + $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPGroupsReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'GroupsReport' -tenant $Tenant -message "Failed to get groups report: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + $Items = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' | Where-Object { $_.RowKey -notlike '*-Count' } + if (-not $Items) { + throw "No groups data found in reporting database for $TenantFilter. Sync the report data first." + } + + $CacheTimestamp = ($Items | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Items) { + try { + $Group = $Item.Data | ConvertFrom-Json -Depth 10 -ErrorAction Stop + if ($Group.members -and -not $Group.membersCsv) { + $Group | Add-Member -NotePropertyName 'membersCsv' -NotePropertyValue ($Group.members.userPrincipalName -join ',') -Force + } + $Group | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Results.Add($Group) + } catch { + Write-LogMessage -API 'GroupsReport' -tenant $TenantFilter -message "Failed to parse group item: $($_.Exception.Message)" -sev Warning + } + } + + return ($Results | Sort-Object displayName) +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 index 287c8882c2ce..b0857fd24d21 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGroups.ps1 @@ -19,7 +19,8 @@ function Set-CIPPDBCacheGroups { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching groups' -sev Debug - $Groups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName,groupTypes,mail,mailEnabled,securityEnabled,membershipRule,onPremisesSyncEnabled' -tenantid $TenantFilter + $GroupSelect = 'id,createdDateTime,displayName,description,mail,mailEnabled,mailNickname,resourceProvisioningOptions,securityEnabled,visibility,organizationId,onPremisesSamAccountName,membershipRule,groupTypes,onPremisesSyncEnabled,assignedLicenses,licenseProcessingState' + $Groups = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$top=999&`$select=$GroupSelect" -tenantid $TenantFilter # Build bulk request for group members $MemberRequests = $Groups | ForEach-Object { @@ -36,10 +37,25 @@ function Set-CIPPDBCacheGroups { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Fetching group members' -sev Debug $MemberResults = New-GraphBulkRequest -Requests @($MemberRequests) -tenantid $TenantFilter - # Add members to each group object + # Add members and computed properties to each group object $GroupsWithMembers = foreach ($Group in $Groups) { $Members = ($MemberResults | Where-Object { $_.id -eq $Group.id }).body.value + $groupType = if ($Group.groupTypes -contains 'Unified') { 'Microsoft 365' } + elseif ($Group.mailEnabled -and $Group.securityEnabled) { 'Mail-Enabled Security' } + elseif (-not $Group.mailEnabled -and $Group.securityEnabled) { 'Security' } + elseif ([string]::IsNullOrEmpty($Group.groupTypes) -and $Group.mailEnabled -and -not $Group.securityEnabled) { 'Distribution List' } + else { 'Unknown' } + $calculatedGroupType = if ($Group.groupTypes -contains 'Unified') { 'm365' } + elseif ($Group.mailEnabled -and $Group.securityEnabled) { 'security' } + elseif (-not $Group.mailEnabled -and $Group.securityEnabled) { 'generic' } + elseif ([string]::IsNullOrEmpty($Group.groupTypes) -and $Group.mailEnabled -and -not $Group.securityEnabled) { 'distributionList' } + else { 'unknown' } $Group | Add-Member -NotePropertyName 'members' -NotePropertyValue $Members -Force + $Group | Add-Member -NotePropertyName 'primDomain' -NotePropertyValue ($Group.mail -split '@' | Select-Object -Last 1) -Force + $Group | Add-Member -NotePropertyName 'teamsEnabled' -NotePropertyValue ($Group.resourceProvisioningOptions -contains 'Team') -Force + $Group | Add-Member -NotePropertyName 'dynamicGroupBool' -NotePropertyValue ($Group.groupTypes -contains 'DynamicMembership') -Force + $Group | Add-Member -NotePropertyName 'groupType' -NotePropertyValue $groupType -Force + $Group | Add-Member -NotePropertyName 'calculatedGroupType' -NotePropertyValue $calculatedGroupType -Force $Group } @@ -48,6 +64,24 @@ function Set-CIPPDBCacheGroups { $Groups = $null $GroupsWithMembers = $null } else { + $Groups = foreach ($Group in $Groups) { + $groupType = if ($Group.groupTypes -contains 'Unified') { 'Microsoft 365' } + elseif ($Group.mailEnabled -and $Group.securityEnabled) { 'Mail-Enabled Security' } + elseif (-not $Group.mailEnabled -and $Group.securityEnabled) { 'Security' } + elseif ([string]::IsNullOrEmpty($Group.groupTypes) -and $Group.mailEnabled -and -not $Group.securityEnabled) { 'Distribution List' } + else { 'Unknown' } + $calculatedGroupType = if ($Group.groupTypes -contains 'Unified') { 'm365' } + elseif ($Group.mailEnabled -and $Group.securityEnabled) { 'security' } + elseif (-not $Group.mailEnabled -and $Group.securityEnabled) { 'generic' } + elseif ([string]::IsNullOrEmpty($Group.groupTypes) -and $Group.mailEnabled -and -not $Group.securityEnabled) { 'distributionList' } + else { 'unknown' } + $Group | Add-Member -NotePropertyName 'primDomain' -NotePropertyValue ($Group.mail -split '@' | Select-Object -Last 1) -Force + $Group | Add-Member -NotePropertyName 'teamsEnabled' -NotePropertyValue ($Group.resourceProvisioningOptions -contains 'Team') -Force + $Group | Add-Member -NotePropertyName 'dynamicGroupBool' -NotePropertyValue ($Group.groupTypes -contains 'DynamicMembership') -Force + $Group | Add-Member -NotePropertyName 'groupType' -NotePropertyValue $groupType -Force + $Group | Add-Member -NotePropertyName 'calculatedGroupType' -NotePropertyValue $calculatedGroupType -Force + $Group + } Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' -Data $Groups Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' -Data $Groups -Count $Groups = $null diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 index 7a0dd4b715d3..fd3db975e50b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroups.ps1 @@ -14,6 +14,19 @@ function Invoke-ListGroups { $Owners = $Request.Query.owners $ExpandMembers = $Request.Query.expandMembers ?? $false + $UseReportDB = $Request.Query.UseReportDB + + # Cache path: list view only — skip when fetching a specific group's details + if ((-not $GroupID) -and (-not $Members) -and (-not $Owners) -and ($TenantFilter -eq 'AllTenants' -or $UseReportDB -eq 'true')) { + try { + $GraphRequest = Get-CIPPGroupsReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + return ([HttpResponseContext]@{ StatusCode = $StatusCode; Body = @($GraphRequest) }) + } $SelectString = 'id,createdDateTime,displayName,description,mail,mailEnabled,mailNickname,resourceProvisioningOptions,securityEnabled,visibility,organizationId,onPremisesSamAccountName,membershipRule,groupTypes,onPremisesSyncEnabled,resourceProvisioningOptions,assignedLicenses,userPrincipalName,licenseProcessingState' if ($ExpandMembers -ne $false) { From 3702c854804b2377015cffd9def102a8e9561283 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 4 May 2026 17:46:39 -0400 Subject: [PATCH 13/68] feat: PR check on fork --- .github/workflows/PR_Branch_Check.yml | 40 +++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PR_Branch_Check.yml b/.github/workflows/PR_Branch_Check.yml index 2fd5b8e65249..59b7b13ce46a 100644 --- a/.github/workflows/PR_Branch_Check.yml +++ b/.github/workflows/PR_Branch_Check.yml @@ -16,22 +16,46 @@ permissions: jobs: check-branch: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Check and Comment on PR # Only process fork PRs with specific branch conditions # Must be a fork AND (source is main/master OR target is main/master) if: | - github.event.pull_request.head.repo.fork == true && + github.event.pull_request.head.repo.fork == true && ((github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') || (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'master')) - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | let message = ''; - message += '🔄 If you are attempting to update your CIPP repo please follow the instructions at: https://docs.cipp.app/setup/self-hosting-guide/updating '; + // Check if the fork has open PRs (indicates pull bot or similar is active) + const forkOwner = context.payload.pull_request.head.repo.owner.login; + const forkRepo = context.payload.pull_request.head.repo.name; + const forkPullsUrl = context.payload.pull_request.head.repo.html_url + '/pulls'; + + let openPRs = []; + try { + const { data: prs } = await github.rest.pulls.list({ + owner: forkOwner, + repo: forkRepo, + state: 'open', + per_page: 5 + }); + openPRs = prs; + } catch (e) { + // Can't read fork PRs — skip + } + + message += '🔄 If you are attempting to update your CIPP-API repo please follow the instructions at: https://docs.cipp.app/setup/self-hosting-guide/updating. Are you a sponsor? Contact the helpdesk for direct assistance with updating to the latest version.'; + + if (openPRs.length > 0) { + message += ` It looks like you may already have a pending update PR on your fork — check your [open pull requests](${forkPullsUrl}) to accept it.`; + } else { + message += ` You can enable [Pull Bot](https://github.com/apps/pull) or [Repo Sync](https://github.com/apps/repo-sync) to automatically keep your fork up to date.`; + } message += '\n\n'; // Check if PR is targeting main/master @@ -40,20 +64,20 @@ jobs: } // Check if PR is from a fork's main/master branch - if (context.payload.pull_request.head.repo.fork && + if (context.payload.pull_request.head.repo.fork && (context.payload.pull_request.head.ref === 'main' || context.payload.pull_request.head.ref === 'master')) { message += '⚠️ This PR cannot be merged because it originates from your fork\'s main/master branch. If you are attempting to contribute code please PR from your dev branch or another non-main/master branch.\n\n'; } - message += '🔒 This PR will now be automatically closed due to the above violation(s).'; - + message += '🔒 This PR will now be automatically closed due to the above rules.'; + // Post the comment await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body: message }); - + // Close the PR await github.rest.pulls.update({ ...context.repo, From 8856340634e4668f121ee638960da544c2a08e7b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 5 May 2026 18:09:00 +0800 Subject: [PATCH 14/68] dead code --- .../Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 | 13 ------------- .../CIPP/Core/Invoke-GetCippAlerts.ps1 | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 deleted file mode 100644 index b8336c9975ff..000000000000 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -function Push-Z_CIPPQueueTrigger { - <# - .FUNCTIONALITY - Entrypoint - #> - Param($QueueItem, $TriggerMetadata) - $APIName = $QueueItem.FunctionName - - $FunctionName = 'Push-{0}' -f $APIName - if (Get-Command -Name $FunctionName -ErrorAction SilentlyContinue) { - & $FunctionName -QueueItem $QueueItem -TriggerMetadata $TriggerMetadata - } -} \ No newline at end of file diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 index be4d69e427e0..bdce27119e80 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 @@ -63,7 +63,7 @@ function Invoke-GetCippAlerts { }) Write-LogMessage -message ('CIPP API is running PowerShell {0}. PowerShell 7.4 or later is required.' -f $PSVersionTable.PSVersion) -API 'Updates' -tenant 'All Tenants' -sev Alert } - if (!(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { + if (${env:CIPP-NG} -ne 'true' -and !(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { $Alerts.Add( @{ title = 'Function App in Write Mode' From 8e12d1064b93760c63ce7bf834acd005ca327fcb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 5 May 2026 18:27:02 +0800 Subject: [PATCH 15/68] Correct CIPP SAM addition repeated alerts --- .../Push-UpdatePermissionsQueue.ps1 | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index e4df4f354f9c..d0318bbdde5e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -5,9 +5,11 @@ function Push-UpdatePermissionsQueue { #> param($Item) - try { - $DomainRefreshRequired = $false + $Status = 'Failed' + $FailureMessage = $null + $DomainRefreshRequired = $false + try { if (!$Item.defaultDomainName) { $DomainRefreshRequired = $true } @@ -46,33 +48,55 @@ function Push-UpdatePermissionsQueue { if ($Item.defaultDomainName -ne 'PartnerTenant') { Write-Information 'Pushing CIPP-SAM admin roles' - Set-CIPPSAMAdminRoles -TenantFilter $Item.customerId + try { + Set-CIPPSAMAdminRoles -TenantFilter $Item.customerId + } catch { + $SamRoleError = Get-CippException -Exception $_ + Write-Information "Failed to set CIPP-SAM admin roles for $($Item.displayName): $($_.Exception.Message)" + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Failed to set CIPP-SAM admin roles for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Warning' -API 'UpdatePermissionsQueue' -LogData $SamRoleError + if ($Status -eq 'Success') { + $Status = 'Failed' + $FailureMessage = "Set-CIPPSAMAdminRoles: $($_.Exception.Message)" + } + } } - - $Table = Get-CIPPTable -TableName cpvtenants - $unixtime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds - $GraphRequest = @{ - LastApply = "$unixtime" - LastStatus = "$Status" - applicationId = "$($env:ApplicationID)" - Tenant = "$($Item.customerId)" - PartitionKey = 'Tenant' - RowKey = "$($Item.customerId)" + } catch { + Write-Information "Error updating permissions for $($Item.displayName): $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' -LogData (Get-CippException -Exception $_) + $Status = 'Failed' + if (-not $FailureMessage) { + $FailureMessage = $_.Exception.Message } - if ($PermissionFailures) { - $GraphRequest.LastError = $FailureMessage + } finally { + try { + $CpvTable = Get-CIPPTable -TableName cpvtenants + $unixtime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + $GraphRequest = @{ + LastApply = "$unixtime" + LastStatus = "$Status" + applicationId = "$($env:ApplicationID)" + Tenant = "$($Item.customerId)" + PartitionKey = 'Tenant' + RowKey = "$($Item.customerId)" + } + if ($FailureMessage) { + $GraphRequest.LastError = "$FailureMessage" + } + Add-CIPPAzDataTableEntity @CpvTable -Entity $GraphRequest -Force + } catch { + Write-Information "Failed to persist cpvtenants row for $($Item.displayName): $($_.Exception.Message)" } - Add-CIPPAzDataTableEntity @Table -Entity $GraphRequest -Force if ($DomainRefreshRequired) { - $UpdatedTenant = Get-Tenants -TenantFilter $Item.customerId -TriggerRefresh - if ($UpdatedTenant.defaultDomainName) { - Write-Information "Updated tenant domains $($UpdatedTenant.defaultDomainName)" + try { + $UpdatedTenant = Get-Tenants -TenantFilter $Item.customerId -TriggerRefresh + if ($UpdatedTenant.defaultDomainName) { + Write-Information "Updated tenant domains $($UpdatedTenant.defaultDomainName)" + } + } catch { + Write-Information "Failed to refresh tenant domains for $($Item.displayName): $($_.Exception.Message)" } } - } catch { - Write-Information "Error updating permissions for $($Item.displayName): $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' -LogData (Get-CippException -Exception $_) } } From b230c7f612f6cf8c5c674aac0d8d47bf15b8504e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 5 May 2026 23:20:20 +0800 Subject: [PATCH 16/68] Fix drift tag resolution using stale rawData instead of live lookup Drift detection used rawData.templates from the frontend form snapshot to resolve tag-assigned Intune templates. This snapshot goes stale when templates are added to a tag after the drift standard is saved, causing policies to appear as deviations despite being compliant in standards. Replace rawData.templates with a live database lookup by package property, matching the approach already used by Get-CIPPTenantAlignment. Also consolidate template table queries into a single unfiltered load. --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 0c9f4fb305bd..b73a05e49bf9 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -33,9 +33,12 @@ function Get-CIPPDrift { $ConditionalAccessCapable = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') $IntuneTable = Get-CippTable -tablename 'templates' + # Load all templates for tag resolution (mirrors Get-CIPPTenantAlignment) + $AllTableTemplates = Get-CIPPAzDataTableEntity @IntuneTable + # Always load templates for display name resolution, even if tenant doesn't have licenses $IntuneFilter = "PartitionKey eq 'IntuneTemplate'" - $RawIntuneTemplates = (Get-CIPPAzDataTableEntity @IntuneTable -Filter $IntuneFilter) + $RawIntuneTemplates = $AllTableTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' } $AllIntuneTemplates = $RawIntuneTemplates | ForEach-Object { try { $JSONData = $_.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue @@ -51,8 +54,7 @@ function Get-CIPPDrift { } | Sort-Object -Property displayName # Load all CA templates - $CAFilter = "PartitionKey eq 'CATemplate'" - $RawCATemplates = (Get-CIPPAzDataTableEntity @IntuneTable -Filter $CAFilter) + $RawCATemplates = $AllTableTemplates | Where-Object { $_.PartitionKey -eq 'CATemplate' } $AllCATemplates = $RawCATemplates | ForEach-Object { try { $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue @@ -269,9 +271,15 @@ function Get-CIPPDrift { if ($Template.TemplateList.value) { $IntuneTemplateIds.Add($Template.TemplateList.value) } - if ($Template.'TemplateList-Tags'.rawData.templates) { - foreach ($TagTemplate in $Template.'TemplateList-Tags'.rawData.templates) { - $IntuneTemplateIds.Add($TagTemplate.GUID) + if ($Template.'TemplateList-Tags') { + foreach ($Tag in $Template.'TemplateList-Tags') { + $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } + $ResolvedTagTemplates = $AllTableTemplates | Where-Object -Property package -EQ $TagValue + foreach ($ResolvedTemplate in $ResolvedTagTemplates) { + if ($ResolvedTemplate.RowKey -and $ResolvedTemplate.RowKey -notin $IntuneTemplateIds) { + $IntuneTemplateIds.Add($ResolvedTemplate.RowKey) + } + } } } } From 7a7c70d9463107e7560fd40e47def99d1bb9ad91 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 5 May 2026 12:17:17 -0400 Subject: [PATCH 17/68] fix: remove +1hr buffer to end time --- Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index 9faf31d4a479..e135d29d6d74 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -123,7 +123,7 @@ function New-CippAuditLogSearch { $SearchParams = @{ displayName = $DisplayName filterStartDateTime = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') - filterEndDateTime = $EndTime.AddHours(1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + filterEndDateTime = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') } if ($OperationsFilters) { $SearchParams.operationFilters = @($OperationsFilters) From 61b891c9dfdad2853f36a8d381f8a833be055aa4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 May 2026 01:08:11 +0800 Subject: [PATCH 18/68] Fix image upload --- Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 | 2 +- Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 | 6 ++++-- Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 | 2 +- .../HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 | 2 +- .../Administration/Users/Invoke-ExecSetUserPhoto.ps1 | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f03e3187620e..3b2db7623b7e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -17,7 +17,7 @@ function Get-CIPPTextReplacement { [switch]$EscapeForJson ) if ($Text -isnot [string]) { - return $Text + return , $Text } # Without a tenant context, skip replacement lookups and return input as-is. diff --git a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 07e5301fdfac..88d7e2bc5ba5 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -90,10 +90,12 @@ function Invoke-CIPPRestMethod { # ------------------------------------------------------------------ # Escape hatch — env var kill switch, missing pooled client type, - # or per-call legacy switch + # per-call legacy switch, or binary body (byte[] cannot be + # serialised to string for the pooled C# client) # ------------------------------------------------------------------ $HasCippRestClient = $null -ne ('CIPP.CIPPRestClient' -as [type]) - if ($UseLegacyInvokeRestMethod -or $env:DisableCIPPRestMethod -eq 'true' -or -not $HasCippRestClient) { + $IsBinaryBody = $Body -is [byte[]] + if ($UseLegacyInvokeRestMethod -or $env:DisableCIPPRestMethod -eq 'true' -or -not $HasCippRestClient -or $IsBinaryBody) { $LegacyParams = @{ Uri = $Uri Method = $Method diff --git a/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 b/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 index 3069799de39f..5bf6225a54fa 100644 --- a/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 @@ -11,7 +11,7 @@ function Set-CIPPProfilePhoto { ) try { $PhotoBytes = [Convert]::FromBase64String($PhotoBase64) - New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$type/$id/photo/`$value" -tenantid $tenantfilter -type PUT -body $PhotoBytes -ContentType $ContentType + New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$type/$id/photo/`$value" -tenantid $TenantFilter -type PUT -body $PhotoBytes -ContentType $ContentType "Successfully set profile photo for $id" Write-LogMessage -headers $Headers -API 'Set-CIPPUserProfilePhoto' -message "Successfully set profile photo for $id" -Sev 'Info' -tenant $TenantFilter } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 index bdce27119e80..ad653e8acfbf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 @@ -63,7 +63,7 @@ function Invoke-GetCippAlerts { }) Write-LogMessage -message ('CIPP API is running PowerShell {0}. PowerShell 7.4 or later is required.' -f $PSVersionTable.PSVersion) -API 'Updates' -tenant 'All Tenants' -sev Alert } - if (${env:CIPP-NG} -ne 'true' -and !(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { + if (${env:CIPPNG} -ne 'true' -and !(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { $Alerts.Add( @{ title = 'Function App in Write Mode' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 index f4636684fb1c..261778ad8fc9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 @@ -64,7 +64,7 @@ function Invoke-ExecSetUserPhoto { } # Upload the photo using Graph API - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$userId/photo/`$value" -tenantid $tenantFilter -type PATCH -body $photoBytes -ContentType 'image/jpeg' -NoAuthCheck $true + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$userId/photo/`$value" -tenantid $tenantFilter -type PUT -body $photoBytes -ContentType 'image/jpeg' $Results.Add('Successfully set user profile picture.') Write-LogMessage -API $APIName -tenant $tenantFilter -headers $Headers -message "Set profile picture for user $userId" -Sev Info From 7787f187597ef2590a9ab3615631c16af1cbd884 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 May 2026 02:06:11 +0800 Subject: [PATCH 19/68] Improve drift alignment data --- .../Functions/Get-CIPPTenantAlignment.ps1 | 45 ++++++++++++++++--- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 36 ++++++++++++++- .../Standards/Invoke-ListTenantAlignment.ps1 | 3 +- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 11548bc85a5b..18dc0a1ee99c 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -70,6 +70,16 @@ function Get-CIPPTenantAlignment { $AllStandards | Where-Object { $_.PartitionKey -in $Tenants.defaultDomainName } } $TagTemplates = Get-CIPPAzDataTableEntity @TemplateTable + # Build a hashtable indexed by Package for O(1) tag lookup + $TemplatesByPackage = @{} + foreach ($t in $TagTemplates) { + if ($t.Package) { + if (-not $TemplatesByPackage.ContainsKey($t.Package)) { + $TemplatesByPackage[$t.Package] = [System.Collections.Generic.List[object]]::new() + } + $TemplatesByPackage[$t.Package].Add($t) + } + } # Build tenant standards data structure $tenantData = @{} foreach ($Standard in $Standards) { @@ -205,7 +215,7 @@ function Get-CIPPTenantAlignment { Write-Host "Processing Intune Tag: $($Tag.value)" $IntuneActions = if ($IntuneTemplate.action) { $IntuneTemplate.action } else { @() } $IntuneReportingEnabled = ($IntuneActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 - $TagTemplate = $TagTemplates | Where-Object -Property package -EQ $Tag.value + $TagTemplate = if ($TemplatesByPackage.ContainsKey($Tag.value)) { $TemplatesByPackage[$Tag.value] } else { @() } $TagTemplate | ForEach-Object { $TagStandardId = "standards.IntuneTemplate.$($_.GUID)" [PSCustomObject]@{ @@ -378,6 +388,14 @@ function Get-CIPPTenantAlignment { $LicenseMissingStandards = 0 $ReportingDisabledStandardsCount = 0 + # Initialize deviation counts before ComparisonResults loop so standards can be counted too + $PendingDeviationsCount = $null + $DeniedDeviationsCount = $null + if ($IsDriftTemplate) { + $PendingDeviationsCount = 0 + $DeniedDeviationsCount = 0 + } + foreach ($item in $ComparisonResults) { $IsAcceptedDeviation = $false $DeviationStatus = $null @@ -395,30 +413,44 @@ function Get-CIPPTenantAlignment { } if ($item.ComplianceStatus -in @('Compliant', 'Accepted Deviation', 'Customer Specific')) { $CompliantStandards++ } - elseif ($item.ComplianceStatus -eq 'Non-Compliant') { $NonCompliantStandards++ } + elseif ($item.ComplianceStatus -eq 'Non-Compliant') { + $NonCompliantStandards++ + # Count non-compliant standards as pending/denied based on drift status + if ($IsDriftTemplate) { + if (-not $DeviationStatus -or $DeviationStatus -eq 'New') { + $PendingDeviationsCount++ + } elseif ($DeviationStatus -in @('Denied', 'DeniedRemediate', 'DeniedDelete')) { + $DeniedDeviationsCount++ + } + } + } elseif ($item.ComplianceStatus -eq 'License Missing') { $LicenseMissingStandards++ } if ($item.ReportingDisabled) { $ReportingDisabledStandardsCount++ } } # For drift templates, include all policy deviation entries from tenantDrift table in alignment score # Accepted/CustomerSpecific count as compliant, all others (New, Denied, etc.) count as non-compliant - $CurrentDeviationsCount = $null if ($IsDriftTemplate) { $PolicyDeviationCompliant = 0 $PolicyDeviationNonCompliant = 0 foreach ($DriftKey in $TenantDriftStatuses.Keys) { if ($DriftKey -like 'IntuneTemplates.*' -or $DriftKey -like 'ConditionalAccessTemplates.*') { - if ($TenantDriftStatuses[$DriftKey] -in @('Accepted', 'CustomerSpecific')) { + $DriftStatus = $TenantDriftStatuses[$DriftKey] + if ($DriftStatus -in @('Accepted', 'CustomerSpecific')) { $PolicyDeviationCompliant++ } else { $PolicyDeviationNonCompliant++ + if ($DriftStatus -eq 'New') { + $PendingDeviationsCount++ + } else { + $DeniedDeviationsCount++ + } } } } $AllCount += $PolicyDeviationCompliant + $PolicyDeviationNonCompliant $CompliantStandards += $PolicyDeviationCompliant $NonCompliantStandards += $PolicyDeviationNonCompliant - $CurrentDeviationsCount = $PolicyDeviationNonCompliant } $AlignmentPercentage = if (($AllCount - $ReportingDisabledStandardsCount) -gt 0) { @@ -450,7 +482,8 @@ function Get-CIPPTenantAlignment { LicenseMissingStandards = $LicenseMissingStandards TotalStandards = $AllCount ReportingDisabledCount = $ReportingDisabledStandardsCount - CurrentDeviationsCount = $CurrentDeviationsCount + PendingDeviationsCount = $PendingDeviationsCount + DeniedDeviationsCount = $DeniedDeviationsCount LatestDataCollection = if ($LatestDataCollection) { $LatestDataCollection } else { $null } ComparisonDetails = $ComparisonResults } diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index b73a05e49bf9..b499b0f26e46 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -35,6 +35,16 @@ function Get-CIPPDrift { # Load all templates for tag resolution (mirrors Get-CIPPTenantAlignment) $AllTableTemplates = Get-CIPPAzDataTableEntity @IntuneTable + # Build a hashtable indexed by Package for O(1) tag lookup + $TemplatesByPackage = @{} + foreach ($t in $AllTableTemplates) { + if ($t.Package) { + if (-not $TemplatesByPackage.ContainsKey($t.Package)) { + $TemplatesByPackage[$t.Package] = [System.Collections.Generic.List[object]]::new() + } + $TemplatesByPackage[$t.Package].Add($t) + } + } # Always load templates for display name resolution, even if tenant doesn't have licenses $IntuneFilter = "PartitionKey eq 'IntuneTemplate'" @@ -274,7 +284,7 @@ function Get-CIPPDrift { if ($Template.'TemplateList-Tags') { foreach ($Tag in $Template.'TemplateList-Tags') { $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } - $ResolvedTagTemplates = $AllTableTemplates | Where-Object -Property package -EQ $TagValue + $ResolvedTagTemplates = if ($TemplatesByPackage.ContainsKey($TagValue)) { $TemplatesByPackage[$TagValue] } else { @() } foreach ($ResolvedTemplate in $ResolvedTagTemplates) { if ($ResolvedTemplate.RowKey -and $ResolvedTemplate.RowKey -notin $IntuneTemplateIds) { $IntuneTemplateIds.Add($ResolvedTemplate.RowKey) @@ -388,6 +398,28 @@ function Get-CIPPDrift { $AllDeviations.AddRange($StandardsDeviations) $AllDeviations.AddRange($PolicyDeviations) + # Persist newly detected deviations to the tenantDrift table so the summary page can count them + $NewDriftEntities = [System.Collections.Generic.List[object]]::new() + foreach ($Deviation in $AllDeviations) { + if (-not $ExistingDriftStates.ContainsKey($Deviation.standardName)) { + $RowKey = $Deviation.standardName -replace '\.', '_' + $NewDriftEntities.Add(@{ + PartitionKey = $TenantFilter + RowKey = $RowKey + StandardName = $Deviation.standardName + Status = 'New' + LastModified = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + }) + } + } + if ($NewDriftEntities.Count -gt 0) { + try { + Add-CIPPAzDataTableEntity @DriftTable -Entity $NewDriftEntities -Force + } catch { + Write-Warning "Failed to persist new drift deviations: $($_.Exception.Message)" + } + } + # Filter deviations by status for counting $NewDeviations = $AllDeviations | Where-Object { $_.Status -eq 'New' } $AcceptedDeviations = $AllDeviations | Where-Object { $_.Status -eq 'Accepted' } @@ -407,7 +439,7 @@ function Get-CIPPDrift { deniedDeviationsCount = $DeniedDeviations.Count customerSpecificDeviationsCount = $CustomerSpecificDeviations.Count newDeviationsCount = $NewDeviations.Count - alignedCount = $Alignment.CompliantStandards + alignedCount = $Alignment.CompliantStandards - $AcceptedDeviations.Count - $CustomerSpecificDeviations.Count currentDeviations = @($CurrentDeviations) acceptedDeviations = @($AcceptedDeviations) customerSpecificDeviations = @($CustomerSpecificDeviations) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index 8693cdc0c835..2b79546c3e81 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 @@ -92,7 +92,8 @@ function Invoke-ListTenantAlignment { alignmentScore = $_.AlignmentScore LicenseMissingPercentage = $_.LicenseMissingPercentage combinedAlignmentScore = $_.CombinedScore - currentDeviationsCount = $_.CurrentDeviationsCount + pendingDeviationsCount = $_.PendingDeviationsCount + deniedDeviationsCount = $_.DeniedDeviationsCount latestDataCollection = $_.LatestDataCollection } } From 08d2bcb8971ba4488ce4edd86de70a1297feccc8 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 5 May 2026 21:00:07 +0200 Subject: [PATCH 20/68] Add self-service email stuff --- ...CIPPStandardDisableSelfServiceLicenses.ps1 | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index 5a6ce39e07a6..f1bcecf87877 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -60,13 +60,25 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { }) } + try { + $AuthPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant + $AllowEmailSubscriptions = if ($AuthPolicy.allowedToSignUpEmailBasedSubscriptions) { 'Enabled' } else { 'Disabled' } + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = $AllowEmailSubscriptions + }) + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve authorization policy: $($_.Exception.Message)" -sev Error + } + if ($Settings.DisableTrials) { try { $AutoClaimPolicy = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = $AutoClaimPolicy.policyValue + policyValue = if ($null -eq $AutoClaimPolicy.policyValue) { 'Disabled' } else { $AutoClaimPolicy.policyValue } }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy: $($_.Exception.Message)" -sev Error @@ -99,6 +111,12 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { }) } + $ExpectedValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = 'Disabled' + }) + if ($settings.remediate) { $Compare = Compare-Object -ReferenceObject $ExpectedValues -DifferenceObject $CurrentValues -Property productName, productId, policyValue @@ -119,6 +137,9 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { if ($Item.productId -eq 'autoclaim') { New-GraphPostRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' -Body $body + } elseif ($Item.productId -eq 'allowedToSignUpEmailBasedSubscriptions') { + $authBody = @{ allowedToSignUpEmailBasedSubscriptions = $false } | ConvertTo-Json -Compress + New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant -body $authBody -type PATCH } else { New-GraphPOSTRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -uri "https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products/$($Item.productId)" -tenantid $Tenant -body $body -type PUT } @@ -139,13 +160,25 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $Item.policyValue }) } + try { + $AuthPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant + $AllowEmailSubscriptions = if ($AuthPolicy.allowedToSignUpEmailBasedSubscriptions) { 'Enabled' } else { 'Disabled' } + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = $AllowEmailSubscriptions + }) + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve authorization policy after remediation: $($_.Exception.Message)" -sev Error + } + if ($Settings.DisableTrials) { try { $AutoClaimPolicy = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = $AutoClaimPolicy.policyValue + policyValue = if ($null -eq $AutoClaimPolicy.policyValue) { 'Disabled' } else { $AutoClaimPolicy.policyValue } }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy after remediation: $($_.Exception.Message)" -sev Error From 733da2299ac5deff19797a0148eaa7eda2868f2a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 5 May 2026 21:02:25 +0200 Subject: [PATCH 21/68] updated --- .../Tests/Push-CIPPTestsList.ps1 | 2 +- .../Public/Invoke-CIPPTestCollection.ps1 | 4 +- .../Devices/Invoke-CippTestSMB1001_1_10.md | 18 ++++ .../Devices/Invoke-CippTestSMB1001_1_10.ps1 | 60 +++++++++++++ .../Devices/Invoke-CippTestSMB1001_1_12.md | 19 ++++ .../Devices/Invoke-CippTestSMB1001_1_12.ps1 | 42 +++++++++ .../Devices/Invoke-CippTestSMB1001_1_2.md | 16 ++++ .../Devices/Invoke-CippTestSMB1001_1_2.ps1 | 58 +++++++++++++ .../Devices/Invoke-CippTestSMB1001_1_3.md | 15 ++++ .../Devices/Invoke-CippTestSMB1001_1_3.ps1 | 58 +++++++++++++ .../Devices/Invoke-CippTestSMB1001_1_4.md | 14 +++ .../Devices/Invoke-CippTestSMB1001_1_4.ps1 | 56 ++++++++++++ .../Devices/Invoke-CippTestSMB1001_1_8.md | 15 ++++ .../Devices/Invoke-CippTestSMB1001_1_8.ps1 | 86 +++++++++++++++++++ .../Devices/Invoke-CippTestSMB1001_1_9.md | 15 ++++ .../Devices/Invoke-CippTestSMB1001_1_9.ps1 | 15 ++++ .../Devices/Invoke-CippTestSMB1001_2_2.md | 30 +++++++ .../Devices/Invoke-CippTestSMB1001_2_2.ps1 | 62 +++++++++++++ .../Devices/Invoke-CippTestSMB1001_4_7.md | 17 ++++ .../Devices/Invoke-CippTestSMB1001_4_7.ps1 | 15 ++++ .../Identity/Invoke-CippTestSMB1001_1_11.md | 15 ++++ .../Identity/Invoke-CippTestSMB1001_1_11.ps1 | 15 ++++ .../Identity/Invoke-CippTestSMB1001_2_1.md | 18 ++++ .../Identity/Invoke-CippTestSMB1001_2_1.ps1 | 49 +++++++++++ .../Identity/Invoke-CippTestSMB1001_2_12.md | 21 +++++ .../Identity/Invoke-CippTestSMB1001_2_12.ps1 | 78 +++++++++++++++++ .../Identity/Invoke-CippTestSMB1001_2_3.md | 18 ++++ .../Identity/Invoke-CippTestSMB1001_2_3.ps1 | 64 ++++++++++++++ .../Identity/Invoke-CippTestSMB1001_2_5.md | 16 ++++ .../Identity/Invoke-CippTestSMB1001_2_5.ps1 | 58 +++++++++++++ .../Identity/Invoke-CippTestSMB1001_2_5_L4.md | 19 ++++ .../Invoke-CippTestSMB1001_2_5_L4.ps1 | 48 +++++++++++ .../Identity/Invoke-CippTestSMB1001_2_6.md | 20 +++++ .../Identity/Invoke-CippTestSMB1001_2_6.ps1 | 59 +++++++++++++ .../Identity/Invoke-CippTestSMB1001_2_8.md | 25 ++++++ .../Identity/Invoke-CippTestSMB1001_2_8.ps1 | 53 ++++++++++++ .../Identity/Invoke-CippTestSMB1001_2_9.md | 18 ++++ .../Identity/Invoke-CippTestSMB1001_2_9.ps1 | 58 +++++++++++++ .../Identity/Invoke-CippTestSMB1001_3_1.md | 20 +++++ .../Identity/Invoke-CippTestSMB1001_3_1.ps1 | 45 ++++++++++ .../Public/Tests/SMB1001/report.json | 30 +++++++ 41 files changed, 1362 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/SMB1001/report.json diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index e28d14425cfc..916fc512fba9 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -31,7 +31,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom') $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index 2e129932c92a..48514b19b36e 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -15,6 +15,7 @@ function Invoke-CIPPTestCollection { - EIDSCA → Invoke-CippTestEIDSCA* - CISA → Invoke-CippTestCISA* - CIS → Invoke-CippTestCIS_* + - SMB1001 → Invoke-CippTestSMB1001_* - CopilotReadiness → Invoke-CippTestCopilotReady* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function @@ -32,7 +33,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -47,6 +48,7 @@ function Invoke-CIPPTestCollection { EIDSCA = 'Invoke-CippTestEIDSCA*' CISA = 'Invoke-CippTestCISA*' CIS = 'Invoke-CippTestCIS_*' + SMB1001 = 'Invoke-CippTestSMB1001_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' } diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md new file mode 100644 index 000000000000..5f767458faf0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md @@ -0,0 +1,18 @@ +SMB1001 (1.10) — Level 5 — disable untrusted Microsoft Office macros. The Intune-managed implementation is Defender Attack Surface Reduction (ASR) rules. The two key rules for SMB1001 1.10 are: + +- **Block Win32 API calls from Office macros** — prevents macros from calling Win32 APIs to download/execute payloads. +- **Block all Office applications from creating child processes** — prevents Office from spawning malicious processes. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Attack surface reduction > Create policy. +2. Choose Windows > Attack Surface Reduction Rules. +3. Set both Office-macro rules to **Block** (or Audit while validating). +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Attack Surface Reduction rules reference](https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 new file mode 100644 index 000000000000..0b8268c6c105 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 @@ -0,0 +1,60 @@ +function Invoke-CippTestSMB1001_1_10 { + <# + .SYNOPSIS + Tests SMB1001 (1.10) - Disable untrusted Microsoft Office macros + + .DESCRIPTION + Verifies an Attack Surface Reduction (ASR) policy is deployed via Intune that blocks + Win32 API calls from Office macros and child processes from Office apps. SMB1001 1.10 + (Level 5) requires untrusted Office macros to be disabled. + #> + param($Tenant) + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $ASRPolicies = $ConfigurationPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules' + } + + if (-not $ASRPolicies -or $ASRPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Attack Surface Reduction policies found. ASR rules block Office macro abuse, which SMB1001 1.10 requires.' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $MacroProtected = $ASRPolicies | Where-Object { + $children = $_.settings.settingInstance.groupSettingCollectionValue.children + $win32MacroSetting = $children | Where-Object { $_.settingDefinitionId -eq 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros' } + $officeChildSetting = $children | Where-Object { $_.settingDefinitionId -eq 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses' } + ($win32MacroSetting.choiceSettingValue.value -like '*_block' -or $win32MacroSetting.choiceSettingValue.value -like '*_warn') -or + ($officeChildSetting.choiceSettingValue.value -like '*_block' -or $officeChildSetting.choiceSettingValue.value -like '*_warn') + } + + if (-not $MacroProtected -or $MacroProtected.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'ASR policies exist but none enable the Office macro protection rules (Block Win32 API calls from Office macros / Block Office child processes).' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $Assigned = $MacroProtected | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned.Count -gt 0) { + $Status = 'Passed' + $Result = "$($Assigned.Count) ASR policy/policies are assigned with Office macro protection rules enabled." + } else { + $Status = 'Failed' + $Result = "ASR policies with Office macro protection exist but are not assigned. Found $($MacroProtected.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md new file mode 100644 index 000000000000..305e247c5d85 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md @@ -0,0 +1,19 @@ +SMB1001 (1.12) — Level 3 + Level 5 — implement Endpoint Detection and Response (EDR). At Level 5 the EDR must be paired with a Managed Detection and Response (MDR) service with a defined SLA. The Microsoft 365 implementation is Microsoft Defender for Endpoint, deployed via Intune onboarding plus an Endpoint security > EDR configuration policy. + +The MDR contractual relationship is verified separately to a Dynamic Standard Certifier (it is an operational control, not a tenant config). + +**Remediation Action** + +1. Microsoft 365 Defender > Settings > Endpoints > Onboarding — generate the onboarding package. +2. Intune admin centre > Endpoint security > Endpoint detection and response > Create policy. +3. Choose "Auto-configure from MDE connector" so devices use the connector's configuration. +4. Assign to All Devices. + +Use CIPP `standards.IntuneTemplate` with an EDR template to deploy across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Onboard devices to Defender for Endpoint with Intune](https://learn.microsoft.com/en-us/defender-endpoint/configure-endpoints-mdm) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 new file mode 100644 index 000000000000..398dd1fc0d22 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestSMB1001_1_12 { + <# + .SYNOPSIS + Tests SMB1001 (1.12) - Implement Endpoint Detection and Response (EDR) + + .DESCRIPTION + Verifies the Microsoft Defender for Endpoint - Intune connector is enabled. The connector + is the prerequisite for onboarding devices to MDE via Intune. SMB1001 1.12 Level 5 + additionally prescribes a Managed Detection and Response (MDR) service contract — that is + a contractual control evidenced separately. + #> + param($Tenant) + + $TestId = 'SMB1001_1_12' + $Name = 'Endpoint Detection and Response (EDR) is deployed' + + try { + $MDEOnboarding = Get-CIPPTestData -TenantFilter $Tenant -Type 'MDEOnboarding' + + if (-not $MDEOnboarding) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'MDEOnboarding cache not found. This may be due to missing Defender for Endpoint licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $Connector = $MDEOnboarding | Select-Object -First 1 + $State = $Connector.partnerState + + if ($State -eq 'enabled') { + $Status = 'Passed' + $Result = "The Microsoft Defender for Endpoint - Intune connector is enabled (partnerState: $State). Devices onboarded via Intune can report to MDE for EDR. If you are at L5, evidence the MDR service contract separately." + } else { + $Status = 'Failed' + $Result = "The Microsoft Defender for Endpoint - Intune connector is not enabled (partnerState: $($State ?? 'unavailable')). Onboard tenant in Microsoft 365 Defender > Settings > Endpoints > Advanced features and connect Intune." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md new file mode 100644 index 000000000000..7bc9377606fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md @@ -0,0 +1,16 @@ +SMB1001 (1.2) — Level 1+ — install and configure a firewall on every device that connects to the Internet. The Intune-managed implementation is the Microsoft Defender Firewall configuration policy under Endpoint security > Firewall. This test passes when at least one firewall policy is assigned to a group. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Firewall > Create policy. +2. Choose platform (Windows or macOS) and the Microsoft Defender Firewall profile. +3. Configure rules and assign to All Devices or a target group. + +Use CIPP `standards.IntuneTemplate` with a Defender Firewall template to deploy across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Configure Microsoft Defender Firewall with Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-firewall-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 new file mode 100644 index 000000000000..d5262a461140 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_1_2 { + <# + .SYNOPSIS + Tests SMB1001 (1.2) - Install and configure a firewall on all devices + + .DESCRIPTION + Verifies an Intune endpoint security firewall configuration policy exists and is assigned. + SMB1001 1.2 requires firewalls on every device that connects to the Internet, including + personal devices used for work. + #> + param($Tenant) + + $TestId = 'SMB1001_1_2' + $Name = 'Firewall is configured on all devices' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $FirewallPolicies = @($ConfigurationPolicies | Where-Object { + $_.templateReference -and $_.templateReference.templateFamily -eq 'endpointSecurityFirewall' + }) + + if ($FirewallPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No endpoint security firewall configuration policies found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AssignedPolicies = @($FirewallPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $FirewallPolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + "| $($P.name) | $A |" + } + $Result = (@( + "$($AssignedPolicies.Count) of $($FirewallPolicies.Count) firewall policy/policies are assigned." + '' + '| Policy Name | Assigned |' + '| :---------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Firewall policies exist but none are assigned. Found $($FirewallPolicies.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md new file mode 100644 index 000000000000..448d87b87354 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md @@ -0,0 +1,15 @@ +SMB1001 (1.3) — Level 1+ — install and enable antivirus on every workstation and laptop. Mobile devices are covered by ensuring built-in protections (Google Play Protect, App Store) are active. The Intune-managed implementation is a Microsoft Defender Antivirus configuration policy under Endpoint security > Antivirus. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Antivirus > Create policy. +2. Choose platform (Windows, macOS) and Microsoft Defender Antivirus profile. +3. Configure real-time protection, cloud-delivered protection, automatic sample submission. +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Antivirus policy for Endpoint security in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-antivirus-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 new file mode 100644 index 000000000000..39bdcf2e53fb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_1_3 { + <# + .SYNOPSIS + Tests SMB1001 (1.3) - Install antivirus software on all organization devices + + .DESCRIPTION + Verifies an Intune endpoint security antivirus configuration policy exists and is assigned. + SMB1001 1.3 requires actively-updated antivirus on workstations and laptops. + #> + param($Tenant) + + $TestId = 'SMB1001_1_3' + $Name = 'Antivirus is installed and configured on all devices' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AVPolicies = @($ConfigurationPolicies | Where-Object { + $_.templateReference -and $_.templateReference.templateFamily -eq 'endpointSecurityAntivirus' + }) + + if ($AVPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No endpoint security antivirus configuration policies found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AssignedPolicies = @($AVPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $AVPolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + $Plat = if ($P.platforms) { $P.platforms } else { 'unknown' } + "| $($P.name) | $Plat | $A |" + } + $Result = (@( + "$($AssignedPolicies.Count) of $($AVPolicies.Count) antivirus policy/policies are assigned." + '' + '| Policy Name | Platform | Assigned |' + '| :---------- | :------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Antivirus policies exist but none are assigned. Found $($AVPolicies.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md new file mode 100644 index 000000000000..407e93cc1d70 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md @@ -0,0 +1,14 @@ +SMB1001 (1.4) — Level 1+ — software updates and patches are installed automatically. If automatic updates cannot be configured, manual updates must be applied at least every three months. The Intune-managed implementation is Windows Update for Business — quality update profiles for monthly patches and feature update profiles for OS upgrades. + +**Remediation Action** + +1. Intune admin centre > Devices > Windows > Update rings (or Quality update profiles / Feature update profiles). +2. Configure deferral periods, deadlines, and automatic restarts. +3. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Windows Update for Business with Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/windows-update-for-business-configure) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 new file mode 100644 index 000000000000..d968d7637b08 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 @@ -0,0 +1,56 @@ +function Invoke-CippTestSMB1001_1_4 { + <# + .SYNOPSIS + Tests SMB1001 (1.4) - Automatically install tested software updates and patches + + .DESCRIPTION + Verifies that a Windows Update for Business configuration profile is deployed via Intune + and assigned. The Intune update profile is stored in IntuneDeviceConfigurations under the + '@odata.type' value '#microsoft.graph.windowsUpdateForBusinessConfiguration'. + #> + param($Tenant) + + $TestId = 'SMB1001_1_4' + $Name = 'Software updates are installed automatically' + + try { + $DeviceConfigs = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + + if (-not $DeviceConfigs) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'IntuneDeviceConfigurations cache not found. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $UpdatePolicies = @($DeviceConfigs | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' }) + + if ($UpdatePolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows Update for Business configuration profiles found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $Assigned = @($UpdatePolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($Assigned.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $UpdatePolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + "| $($P.displayName) | $A |" + } + $Result = (@( + "$($Assigned.Count) of $($UpdatePolicies.Count) Windows Update for Business profile(s) are assigned." + '' + '| Profile Name | Assigned |' + '| :----------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Windows Update for Business profiles exist but none are assigned. Found $($UpdatePolicies.Count) unassigned profile(s)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md new file mode 100644 index 000000000000..a8478b604077 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md @@ -0,0 +1,15 @@ +SMB1001 (1.8) — Level 5 — important digital data must be encrypted at rest. On Windows the Intune-managed implementation is BitLocker, deployed via Endpoint security > Disk encryption (or via a Settings Catalog policy enabling `device_vendor_msft_bitlocker_requiredeviceencryption`). + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Disk encryption > Create policy. +2. Choose Windows > BitLocker. +3. Configure recovery key escrow to Entra, encryption method, and startup authentication. +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Encrypt Windows devices with BitLocker in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/encrypt-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 new file mode 100644 index 000000000000..33ec235959a4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 @@ -0,0 +1,86 @@ +function Invoke-CippTestSMB1001_1_8 { + <# + .SYNOPSIS + Tests SMB1001 (1.8) - Ensure important digital data is encrypted at rest + + .DESCRIPTION + Verifies BitLocker encryption is enforced on Windows devices via an Intune configuration + policy. SMB1001 1.8 (Level 5) requires data at rest to be encrypted on devices that store + sensitive information. Detection follows the ZTNA24550 pattern — looks for the + 'device_vendor_msft_bitlocker_requiredeviceencryption_1' setting value on Windows + configuration policies. + #> + param($Tenant) + + $TestId = 'SMB1001_1_8' + $Name = 'Important digital data is encrypted at rest' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'IntuneConfigurationPolicies cache not found. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $WindowsPolicies = $ConfigurationPolicies | Where-Object { $_.platforms -match 'windows10' } + + $WindowsBitLockerPolicies = @( + foreach ($Policy in $WindowsPolicies) { + $ValidSettingValues = @('device_vendor_msft_bitlocker_requiredeviceencryption_1') + + if ($Policy.settings.settinginstance.choicesettingvalue.value) { + $PolicySettingValues = $Policy.settings.settinginstance.choicesettingvalue.value + if ($PolicySettingValues -isnot [array]) { + $PolicySettingValues = @($PolicySettingValues) + } + + foreach ($SettingValue in $PolicySettingValues) { + if ($ValidSettingValues -contains $SettingValue) { + $Policy + break + } + } + } + } + ) + + $AssignedPolicies = @($WindowsBitLockerPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($Policy in $WindowsBitLockerPolicies) { + $PolicyStatus = if ($Policy.assignments -and $Policy.assignments.Count -gt 0) { '✅ Assigned' } else { '❌ Not assigned' } + $AssignmentCount = if ($Policy.assignments) { $Policy.assignments.Count } else { 0 } + "| $($Policy.name) | $PolicyStatus | $AssignmentCount |" + } + $Result = (@( + 'At least one Windows BitLocker policy is configured and assigned.' + '' + '**Windows BitLocker Policies:**' + '' + '| Policy Name | Status | Assignment Count |' + '| :---------- | :----- | :--------------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + if ($WindowsBitLockerPolicies.Count -gt 0) { + $UnassignedRows = foreach ($Policy in $WindowsBitLockerPolicies) { "- $($Policy.name)" } + $Result = (@( + 'Windows BitLocker policies exist but none are assigned.' + '' + '**Unassigned BitLocker Policies:**' + '' + ) + $UnassignedRows) -join "`n" + } else { + $Result = 'No Windows BitLocker policy is configured.' + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md new file mode 100644 index 000000000000..83841aba7011 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md @@ -0,0 +1,15 @@ +SMB1001 (1.9) — Level 5 — implement application control (software allowlisting). Only approved software runs. The Intune-managed implementation is App Control for Business (formerly Windows Defender Application Control / WDAC) or AppLocker, deployed via Endpoint security > Application control or via the Settings Catalog. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Application control for Business > Create policy. +2. Choose Audit mode first, validate that legitimate apps are not blocked, then move to Enforce. +3. Define trusted publishers / managed installers. +4. Assign to a test ring then expand to All Devices. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [App Control for Business policies in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-app-control-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 new file mode 100644 index 000000000000..e4c35a4d0935 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_1_9 { + <# + .SYNOPSIS + Tests SMB1001 (1.9) - Implement application control + + .DESCRIPTION + SMB1001 1.9 (Level 5) requires software allowlisting on workstations via App Control for + Business / WDAC / AppLocker. CIPP does not yet have a proven cache-side detection pattern + for these policy families, so this control is informational and should be evidenced + separately from the Intune Endpoint Security > Application control blade. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_9' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (1.9) requires software allowlisting via App Control for Business, WDAC, or AppLocker. Verify in Microsoft Intune > Endpoint security > Application control for Business and evidence the assigned policy to your Dynamic Standard Certifier directly.' -Risk 'Informational' -Name 'Application control limits unauthorised software' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Device' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md new file mode 100644 index 000000000000..c56447e19199 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md @@ -0,0 +1,30 @@ +SMB1001 (2.2) — Level 2+ — employees who should not be permitted to install software on their workstations or laptops must not have local user accounts with administrative privileges. The Intune-managed implementation has two parts: + +1. The Microsoft Entra device registration policy must deny local admin rights to registering users (so a normal user joining a device does not become its local admin). +2. Windows LAPS must be deployed to manage the local administrator credential — without LAPS the local admin password is either shared, static, or unmanaged. + +**Remediation Action** + +```powershell +# 1. Device registration policy — deny local admin to registering users +$body = @{ + azureADJoin = @{ + localAdmins = @{ + registeringUsers = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } + enableGlobalAdmins = $false + } + } +} | ConvertTo-Json -Depth 10 +Invoke-MgGraphRequest -Method PUT -Uri 'https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy' -Body $body + +# 2. Deploy Windows LAPS via Intune (Endpoint security > Account protection > Local admin password solution) +``` + +Use CIPP `standards.intuneDeviceRegLocalAdmins` and `standards.laps`, and deploy a LAPS Intune template via `standards.IntuneTemplate`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Windows LAPS in Microsoft Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/windows-laps-overview) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 new file mode 100644 index 000000000000..5244f54f7d2f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 @@ -0,0 +1,62 @@ +function Invoke-CippTestSMB1001_2_2 { + <# + .SYNOPSIS + Tests SMB1001 (2.2) - Ensure employee accounts do not have administrative privileges + + .DESCRIPTION + Verifies the device registration policy disables registering users from being granted local + admin rights, and that an Intune Windows LAPS policy is deployed to manage the local + administrator credential. SMB1001 2.2 forbids users from having local admin rights to + install software on their workstations. + #> + param($Tenant) + + $TestId = 'SMB1001_2_2' + $Name = 'Employees do not have administrative privileges on their devices' + $Issues = [System.Collections.Generic.List[string]]::new() + + try { + $DeviceRegPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + # 1. Device registration policy: registering users should NOT auto become local admin + if ($DeviceRegPolicy) { + $Cfg = $DeviceRegPolicy | Select-Object -First 1 + $RegisteringType = $Cfg.azureADJoin.localAdmins.registeringUsers.'@odata.type' + if ($RegisteringType -ne '#microsoft.graph.noDeviceRegistrationMembership') { + $Issues.Add("Registering users are granted local administrator rights ($RegisteringType). Configure deviceRegistrationPolicy to deny.") + } + } else { + $Issues.Add('DeviceRegistrationPolicy cache not found — cannot verify whether registering users get local admin rights.') + } + + # 2. LAPS policy deployed and assigned + if ($ConfigPolicies) { + $LapsPolicies = @($ConfigPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.templateReference.templateFamily -eq 'endpointSecurityAccountProtection' -and + ($_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_laps_policies_backupdirectory') + }) + $AssignedLaps = @($LapsPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + if ($AssignedLaps.Count -eq 0) { + $Issues.Add('No assigned Windows LAPS policy found in Intune. Without LAPS, the local administrator credential is shared/static, contradicting SMB1001 2.2.') + } + } else { + $Issues.Add('IntuneConfigurationPolicies cache not found — cannot verify Windows LAPS deployment.') + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Registering users are not granted local administrator rights, and an assigned Windows LAPS policy manages the local admin credential.' + } else { + $Status = 'Failed' + $Result = "SMB1001 (2.2) requires employees to lack administrative privileges on their devices.`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md new file mode 100644 index 000000000000..3240bb4e93b9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md @@ -0,0 +1,17 @@ +SMB1001 (4.7) — Level 3+ — devices that store sensitive, private, or confidential information must be disposed of securely. The standard requires permanent destruction (shredder or external service) for end-of-life devices, or a non-recoverable format if the device is to be reused, sold, or given away. + +This is an operational/process control. The Intune lifecycle (Retire / Wipe / managed device cleanup rules) helps remove corporate data from devices that go missing or are decommissioned, but the physical-destruction or full storage-media format step happens outside Microsoft 365 and must be evidenced to your Dynamic Standard Certifier. + +**Remediation Action** + +1. Document a device-disposal procedure (who approves, how drives are formatted/destroyed, certificate of destruction). +2. Configure Intune managed device cleanup rules (`deviceInactivityBeforeRetirementInDays`) to auto-retire stale devices — see CIPP `standards.intuneDeviceRetirementDays`. +3. For sold/donated devices, run a cryptographic erase or a full disk wipe before handover. +4. For destroyed devices, retain the destruction certificate. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Retire or wipe devices using Intune](https://learn.microsoft.com/en-us/intune/intune-service/remote-actions/devices-wipe) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 new file mode 100644 index 000000000000..f2f96a933fa5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_4_7 { + <# + .SYNOPSIS + Tests SMB1001 (4.7) - Ensure all computer devices that store sensitive information are + disposed of securely + + .DESCRIPTION + SMB1001 4.7 requires permanent destruction or non-recoverable formatting of storage media + on decommissioned devices. The physical-disposal step happens outside Microsoft 365. + This test is informational so the disposal procedure is evidenced separately. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_4_7' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (4.7) requires devices that store sensitive, private, or confidential information to be disposed of securely — by physical destruction (shredder or external service) or non-recoverable formatting if the device is reused or sold. Evidence the disposal procedure (destruction certificates, asset disposal log) to your Dynamic Standard Certifier separately. Configuring an Intune managed-device cleanup rule helps remove corporate data from inactive devices but does not satisfy the physical-disposal requirement on its own.' -Risk 'Informational' -Name 'Devices that store sensitive information are disposed of securely' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md new file mode 100644 index 000000000000..bdde7820d423 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md @@ -0,0 +1,15 @@ +SMB1001 (1.11) — Level 5 — requires regular penetration testing, vulnerability scans, and social-engineering simulations. These are operational activities executed outside the Microsoft 365 tenant and cannot be verified automatically. This test is **informational**: evidence the testing programme to your Dynamic Standard Certifier directly. + +**Remediation Action** + +1. Engage a third-party vendor for annual external penetration testing. +2. Schedule quarterly vulnerability scans of public-facing services. +3. Run an ongoing social-engineering simulation programme (KnowBe4, Hoxhunt, Cofense). Configure a Phishing Simulation Override Policy in Defender (Microsoft 365 Defender > Policies > Advanced delivery) to whitelist the vendor's delivery domains and IPs. +4. Maintain a remediation register that links findings to fixes. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Configure third-party phishing simulations in Defender](https://learn.microsoft.com/en-us/defender-office-365/advanced-delivery-policy-configure) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 new file mode 100644 index 000000000000..1351200384e4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_1_11 { + <# + .SYNOPSIS + Tests SMB1001 (1.11) - Conduct penetration, vulnerability and social engineering testing + + .DESCRIPTION + Pen testing, vulnerability scanning, and social engineering simulations are operational + activities verified outside the M365 tenant. The closest M365 artefact is the + Phishing Simulation Override Policy (Get-PhishSimOverridePolicy), which is not cached. + This test is informational so that auditors evidence the testing programme separately. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_11' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (1.11) requires regular penetration tests, vulnerability scans, and social-engineering simulations. Evidence the testing programme (vendor reports, phishing simulation campaign results, remediation register) to your Dynamic Standard Certifier separately.' -Risk 'Informational' -Name 'Penetration, vulnerability and social engineering testing is conducted' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Security Testing' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md new file mode 100644 index 000000000000..c6e638f6f991 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md @@ -0,0 +1,18 @@ +SMB1001 (2.1) — Level 1+ — requires strong password hygiene including unique passphrases that have not appeared in data breaches. Entra ID Password Protection ships a global banned-password list maintained by Microsoft and lets you add an organisation-specific custom list (company name, product names, common local terms). The custom list requires Entra ID Premium P1 or P2. + +**Remediation Action** + +```powershell +# Configure custom banned passwords in Entra Portal +# https://entra.microsoft.com > Protection > Authentication methods > Password protection +# Enable "Enforce custom list" and add 4-16 character organisation-specific terms. +``` + +Or use the CIPP standard `standards.CustomBannedPasswordList` to deploy this across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Microsoft Entra Password Protection](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 new file mode 100644 index 000000000000..e88195f83112 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestSMB1001_2_1 { + <# + .SYNOPSIS + Tests SMB1001 (2.1) - Ensure strong password hygiene is maintained + + .DESCRIPTION + Verifies the tenant has Entra ID password protection enabled with a custom banned-password + list (the M365 mechanism that blocks weak / breached passwords required by SMB1001 2.1.vi). + #> + param($Tenant) + + $TestId = 'SMB1001_2_1' + $Name = 'Strong password hygiene is maintained' + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + + if (-not $Settings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Settings cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + return + } + + $PwdSetting = $Settings | Where-Object { + $_.templateId -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' -or $_.displayName -eq 'Password Rule Settings' + } | Select-Object -First 1 + + if (-not $PwdSetting) { + $Status = 'Failed' + $Result = 'Entra ID Password Rule Settings not found. Configure a custom banned-password list to satisfy SMB1001 (2.1.vi) — passwords must not appear in previous data breaches.' + } else { + $Enforce = ($PwdSetting.values | Where-Object { $_.name -eq 'EnableBannedPasswordCheck' }).value + $Custom = ($PwdSetting.values | Where-Object { $_.name -eq 'BannedPasswordList' }).value + + if ($Enforce -eq 'True' -and -not [string]::IsNullOrWhiteSpace($Custom)) { + $WordCount = ($Custom -split '\t').Count + $Status = 'Passed' + $Result = "Custom banned passwords are enforced ($WordCount banned term(s))." + } else { + $Status = 'Failed' + $Result = "Entra ID Password Protection is not fully configured.`n`n- EnableBannedPasswordCheck: $Enforce`n- BannedPasswordList length: $($Custom.Length)" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md new file mode 100644 index 000000000000..f91eb901403a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md @@ -0,0 +1,21 @@ +SMB1001 (2.12) — Level 2+ — configure SPF, DKIM, and DMARC on every domain used to send organisational email. Level 3 prescribes DMARC `p=reject` or `p=quarantine` with annual review. SPF prevents domain spoofing, DKIM cryptographically signs outgoing mail, and DMARC tells receivers what to do when SPF/DKIM fail. + +**Remediation Action** + +```powershell +# DKIM +New-DkimSigningConfig -DomainName contoso.com -KeySize 2048 -Enabled $true +# SPF (DNS TXT) +"v=spf1 include:spf.protection.outlook.com -all" +# DMARC (DNS TXT at _dmarc.contoso.com) +"v=DMARC1; p=reject; rua=mailto:dmarc@contoso.com" +``` + +Use the CIPP standards `standards.AddDKIM`, `standards.RotateDKIM`, and `standards.AddDMARCToMOERA` to automate. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Set up SPF, DKIM and DMARC for Microsoft 365](https://learn.microsoft.com/en-us/defender-office-365/email-authentication-about) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 new file mode 100644 index 000000000000..8b372ec564f9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 @@ -0,0 +1,78 @@ +function Invoke-CippTestSMB1001_2_12 { + <# + .SYNOPSIS + Tests SMB1001 (2.12) - Email Authentication and Anti-Spoofing (SPF, DKIM, DMARC) + + .DESCRIPTION + Verifies SPF, DKIM and DMARC are configured on every accepted sending domain. Combines + Domain Analyser results (SPF, DMARC) with Exchange DKIM signing config. Level 3 prescribes + DMARC p=reject or p=quarantine and 2048-bit DKIM keys. + #> + param($Tenant) + + $TestId = 'SMB1001_2_12' + $Name = 'SPF, DKIM, and DMARC are configured on all sending domains' + + try { + $Analyser = Get-CIPPDomainAnalyser -TenantFilter $Tenant + $Dkim = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoDkimSigningConfig' + $Accepted = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAcceptedDomains' + + if (-not $Analyser -or -not $Accepted) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required data (Domain Analyser or ExoAcceptedDomains) not found. Run the CIPP Domain Analyser and refresh caches.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + $Sending = @($Accepted | Where-Object { -not $_.SendingFromDomainDisabled -and $_.DomainName -notlike '*onmicrosoft.com' }) + + $Failures = @( + foreach ($D in $Sending) { + $A = $Analyser | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 + $K = $Dkim | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 + $Spf = $A.ActualSPFRecord -match 'v=spf1' + $Dmarc = $A.DMARCRecord -match 'v=DMARC1' + $DmarcStrong = $A.DMARCRecord -match 'p=(reject|quarantine)' + $DkimEnabled = ($K -and $K.Enabled -eq $true) + $DomainIssues = @( + if (-not $Spf) { 'no SPF' } + if (-not $DkimEnabled) { 'no DKIM' } + if (-not $Dmarc) { 'no DMARC' } + elseif (-not $DmarcStrong) { 'DMARC weak (not p=reject/quarantine)' } + ) + if ($DomainIssues.Count -gt 0) { + [PSCustomObject]@{ + Domain = $D.DomainName + SPF = if ($Spf) { '✅' } else { '❌' } + DKIM = if ($DkimEnabled) { '✅' } else { '❌' } + DMARC = if ($Dmarc) { if ($DmarcStrong) { '✅' } else { '⚠️' } } else { '❌' } + Issues = $DomainIssues -join ', ' + } + } + } + ) + + if ($Sending.Count -eq 0) { + $Status = 'Passed' + $Result = 'No custom sending domains configured.' + } elseif ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Sending.Count) sending domain(s) have SPF, DKIM, and DMARC (p=reject or p=quarantine) configured." + } else { + $Status = 'Failed' + $TableRows = foreach ($F in ($Failures | Select-Object -First 25)) { + "| $($F.Domain) | $($F.SPF) | $($F.DKIM) | $($F.DMARC) | $($F.Issues) |" + } + $Result = (@( + "$($Failures.Count) of $($Sending.Count) sending domain(s) are missing email authentication:" + '' + '| Domain | SPF | DKIM | DMARC | Issues |' + '| :----- | :-: | :--: | :---: | :----- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md new file mode 100644 index 000000000000..eb5ade015738 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md @@ -0,0 +1,18 @@ +SMB1001 (2.3) — Level 2+ — every employee must have their own username and password; shared logins are not permitted. In Microsoft 365 the most common shared-credential risk is a shared mailbox where the underlying Entra account remains enabled and could be signed into directly. Microsoft's recommendation is to disable sign-in on all shared, scheduling, room, and equipment mailboxes so employees access them only via delegated permissions. + +**Remediation Action** + +```powershell +# Disable sign-in for shared mailbox accounts +Get-Mailbox -RecipientTypeDetails SharedMailbox,SchedulingMailbox,RoomMailbox,EquipmentMailbox | + ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId -AccountEnabled:$false } +``` + +Or use the CIPP standards `standards.DisableSharedMailbox` and `standards.DisableResourceMailbox`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Block sign-in for shared mailbox accounts](https://learn.microsoft.com/en-us/microsoft-365/admin/email/about-shared-mailboxes#block-sign-in-for-the-shared-mailbox-account) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 new file mode 100644 index 000000000000..9b67adf36244 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 @@ -0,0 +1,64 @@ +function Invoke-CippTestSMB1001_2_3 { + <# + .SYNOPSIS + Tests SMB1001 (2.3) - Ensure employees have individual user accounts + + .DESCRIPTION + Verifies that shared/resource mailboxes do not have an enabled Entra account that could + be logged into directly with shared credentials. SMB1001 2.3.ii forbids shared usernames + and passwords across employees. + #> + param($Tenant) + + $TestId = 'SMB1001_2_3' + $Name = 'Employees have individual user accounts (no shared logins)' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Mailboxes -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Mailboxes or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + return + } + + $Shared = @($Mailboxes | Where-Object { $_.recipientTypeDetails -in @('SharedMailbox', 'SchedulingMailbox', 'EquipmentMailbox', 'RoomMailbox') }) + + $EnabledShared = @( + foreach ($Mbx in $Shared) { + $User = $Users | Where-Object { $_.id -eq $Mbx.ExternalDirectoryObjectId -or $_.userPrincipalName -eq $Mbx.UPN } | Select-Object -First 1 + if ($User -and $User.accountEnabled -eq $true -and $User.onPremisesSyncEnabled -ne $true) { + [PSCustomObject]@{ + UPN = $Mbx.UPN + DisplayName = $Mbx.displayName + RecipientTypeDetails = $Mbx.recipientTypeDetails + } + } + } + ) + + if ($Shared.Count -eq 0) { + $Status = 'Passed' + $Result = 'No shared, scheduling, room, or equipment mailboxes exist in the tenant.' + } elseif ($EnabledShared.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Shared.Count) shared/resource mailbox account(s) have sign-in disabled. Employees access them via delegation only." + } else { + $Status = 'Failed' + $TableRows = foreach ($M in ($EnabledShared | Select-Object -First 25)) { + "| $($M.UPN) | $($M.RecipientTypeDetails) |" + } + $Result = (@( + "$($EnabledShared.Count) of $($Shared.Count) shared/resource mailbox(es) still have an enabled Entra account that could be logged into with shared credentials:" + '' + '| Mailbox | Type |' + '| :------ | :--- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md new file mode 100644 index 000000000000..599cb270374c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md @@ -0,0 +1,16 @@ +SMB1001 (2.5) — Level 2+ — multi-factor authentication or two-step verification on all employee email accounts, including administrators. The test passes if MFA is enforced through any of: Security Defaults, an enforced Conditional Access policy targeting Office 365 / all apps, or per-user MFA on every active member account. + +**Remediation Action** + +Choose one path: + +- Enable **Security Defaults** in Microsoft Entra (Identity > Overview > Properties > Manage Security defaults). +- Deploy a **Conditional Access policy** that requires MFA for all users targeting all cloud apps (or Office 365). With CIPP: `standards.ConditionalAccessTemplate`. +- Enforce **per-user MFA** on every member account (legacy). With CIPP: `standards.PerUserMFA`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Common Conditional Access policy: Require MFA for all users](https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 new file mode 100644 index 000000000000..66c65c6d67bf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_2_5 { + <# + .SYNOPSIS + Tests SMB1001 (2.5) - Multi-factor authentication (MFA) on all employee email accounts + + .DESCRIPTION + Verifies MFA is enforced for every active member account. Uses the MFAState cache, which + aggregates Conditional Access coverage, Security Defaults state, and per-user MFA into a + single per-user record. SMB1001 2.5 requires MFA on email for all users including admins. + #> + param($Tenant) + + $TestId = 'SMB1001_2_5' + $Name = 'MFA is enforced on all employee email accounts' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -notlike 'Enforced*' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) are protected by MFA (Conditional Access, Security Defaults, or per-user MFA)." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) are not protected by any MFA enforcement mechanism:" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md new file mode 100644 index 000000000000..a164b6c7827f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md @@ -0,0 +1,19 @@ +SMB1001 Level 4 / 5 hardens controls 2.5 (MFA on email), 2.6 (MFA on business apps) and 2.9 (MFA where data is stored) with a factor-type prohibition: only Authenticator App, phone-based push, or U2F/FIDO2 may be used. SMS, Voice, Text and Email are explicitly forbidden as second factors and as backup/recovery methods. + +**Remediation Action** + +```powershell +# Disable weak MFA methods +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Sms' -BodyParameter @{state='disabled'} +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Voice' -BodyParameter @{state='disabled'} +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Email' -BodyParameter @{state='disabled'} +``` + +Or use the CIPP standards `standards.DisableSMS`, `standards.DisableVoice`, `standards.DisableEmail`. Pair with `standards.EnableFIDO2` for phishing-resistant factors. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Manage authentication methods](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 new file mode 100644 index 000000000000..c7436abfc061 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 @@ -0,0 +1,48 @@ +function Invoke-CippTestSMB1001_2_5_L4 { + <# + .SYNOPSIS + Tests SMB1001 (2.5/2.6/2.9 Level 4+) - Weak MFA factors disabled (SMS, Voice, Email) + + .DESCRIPTION + SMB1001 Level 4 hardens MFA controls 2.5, 2.6, 2.9 by prohibiting SMS, Voice, Text and + Email as second factors. Only Authenticator App, phone-based push, or U2F/FIDO2 may be + used. This test verifies the Authentication Methods Policy disables SMS, Voice, and Email. + #> + param($Tenant) + + $TestId = 'SMB1001_2_5_L4' + $Name = 'Phishing-resistant MFA factors are enforced (SMS, Voice, Email disabled)' + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $Sms = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Sms' } | Select-Object -First 1 + $Voice = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Voice' } | Select-Object -First 1 + $Email = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Email' } | Select-Object -First 1 + + $WeakStill = @( + if ($Sms -and $Sms.state -ne 'disabled') { "SMS ($($Sms.state))" } + if ($Voice -and $Voice.state -ne 'disabled') { "Voice ($($Voice.state))" } + if ($Email -and $Email.state -ne 'disabled') { "Email ($($Email.state))" } + ) + + if ($WeakStill.Count -eq 0) { + $Status = 'Passed' + $Result = 'SMS, Voice and Email authentication methods are all disabled. Phishing-resistant factors (Authenticator app, FIDO2, Hardware OATH) are the only paths.' + } else { + $Status = 'Failed' + $Result = "Level 4/5 of SMB1001 prohibits SMS/Voice/Email as MFA factors. The following weak methods remain enabled:`n`n- $($WeakStill -join "`n- ")`n`nDisable each via the Authentication Methods Policy." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md new file mode 100644 index 000000000000..98702a2f0bb3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md @@ -0,0 +1,20 @@ +SMB1001 (2.6) — Level 3+ — multi-factor authentication for all user and administrator accounts on all cloud-hosted business applications, including social media. The strongest Microsoft 365 implementation is a Conditional Access policy that targets All Cloud Apps with the grant control "Require multi-factor authentication" applied to All Users. + +**Remediation Action** + +Deploy a Conditional Access policy: + +- **Users**: All users +- **Cloud apps**: All cloud apps +- **Grant**: Require multi-factor authentication + +Use CIPP `standards.ConditionalAccessTemplate` with the "Require MFA for all users" template. + +For tenants without Entra ID Premium, fall back to Security Defaults or per-user MFA on every active member account. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Common Conditional Access policy: Require MFA for all users](https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 new file mode 100644 index 000000000000..1de907830b6e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 @@ -0,0 +1,59 @@ +function Invoke-CippTestSMB1001_2_6 { + <# + .SYNOPSIS + Tests SMB1001 (2.6) - MFA on all business applications and social media accounts + + .DESCRIPTION + Verifies MFA covers ALL cloud applications (not just specific ones). The MFAState cache + classifies each user's CA coverage as 'Enforced - All Apps', 'Enforced - Specific Apps', + or 'Not Enforced'. SMB1001 2.6 requires MFA across all business applications, so we + require All-Apps coverage, Security Defaults, or per-user MFA. + #> + param($Tenant) + + $TestId = 'SMB1001_2_6' + $Name = 'MFA is enforced on all business applications' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -ne 'Enforced - All Apps' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) have MFA enforced across all business applications." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) are not protected by an All-Apps MFA policy. Specific-Apps CA policies satisfy 2.5 (email) but not 2.6 (all business apps):" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md new file mode 100644 index 000000000000..1da9993cd87b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md @@ -0,0 +1,25 @@ +SMB1001 (2.8) — Level 4+ — manage remote access cloud credentials with least-privilege IAM. The Microsoft Entra default user role gives standard users the ability to register applications, create new M365 tenants, and create security groups — all administrative actions. SMB1001 2.8.i requires those privileges to be minimised for non-admin accounts. + +**Remediation Action** + +```powershell +# Disable user-level admin actions in the authorization policy +$body = @{ + defaultUserRolePermissions = @{ + allowedToCreateApps = $false + allowedToCreateTenants = $false + allowedToCreateSecurityGroups = $false + } + allowedToSignUpEmailBasedSubscriptions = $false +} | ConvertTo-Json +Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -Body $body +``` + +Or use the CIPP standards `standards.DisableAppCreation`, `standards.DisableTenantCreation`, `standards.DisableSecurityGroupUsers`, and `standards.DisableSelfServiceLicenses`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Restrict default user permissions in Microsoft Entra](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 new file mode 100644 index 000000000000..2ae05c2cc783 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 @@ -0,0 +1,53 @@ +function Invoke-CippTestSMB1001_2_8 { + <# + .SYNOPSIS + Tests SMB1001 (2.8) - Management of remote access cloud credentials + + .DESCRIPTION + Verifies the cloud IAM is configured with least privilege — regular users cannot create + tenants, applications, or security groups, all of which are administrative actions that + should be reserved for dedicated admin accounts. Implements the IAM scope of SMB1001 2.8. + #> + param($Tenant) + + $TestId = 'SMB1001_2_8' + $Name = 'Cloud IAM is configured with least privilege' + $Issues = [System.Collections.Generic.List[string]]::new() + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToCreateApps -ne $false) { + $Issues.Add("Users can create app registrations (allowedToCreateApps: $($Cfg.defaultUserRolePermissions.allowedToCreateApps))") + } + if ($Cfg.defaultUserRolePermissions.allowedToCreateTenants -ne $false) { + $Issues.Add("Users can create new M365 tenants (allowedToCreateTenants: $($Cfg.defaultUserRolePermissions.allowedToCreateTenants))") + } + if ($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups -ne $false) { + $Issues.Add("Users can create security groups (allowedToCreateSecurityGroups: $($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups))") + } + if ($Cfg.allowedToSignUpEmailBasedSubscriptions -ne $false) { + $Issues.Add("Users can sign up for self-service subscriptions (allowedToSignUpEmailBasedSubscriptions: $($Cfg.allowedToSignUpEmailBasedSubscriptions))") + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Cloud IAM is configured with least privilege — users cannot create app registrations, tenants, security groups, or self-service subscriptions.' + } else { + $Status = 'Failed' + $Result = "Cloud IAM grants users administrative-level capabilities that should be restricted to dedicated admin accounts:`n`n- $($Issues -join "`n- ")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md new file mode 100644 index 000000000000..b3c485974260 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md @@ -0,0 +1,18 @@ +SMB1001 (2.9) — Level 4+ — MFA on every account that can access important digital data. In Microsoft 365 the principal data stores are SharePoint Online, OneDrive for Business, and Exchange Online. The strongest implementation is a Conditional Access policy targeting all cloud apps (or specifically these three workloads) requiring MFA. + +**Remediation Action** + +Deploy a Conditional Access policy: + +- **Users**: All users (or those with access to data) +- **Cloud apps**: Office 365 (covers SPO/ODB/EXO) — or All cloud apps +- **Grant**: Require multi-factor authentication + +Use CIPP `standards.ConditionalAccessTemplate` with the Microsoft "Require MFA for all users" baseline template. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Conditional Access app: Office 365](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#office-365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 new file mode 100644 index 000000000000..251bfcd05a46 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_2_9 { + <# + .SYNOPSIS + Tests SMB1001 (2.9) - MFA where important digital data is stored + + .DESCRIPTION + Verifies MFA is enforced for every active member account that can access important + digital data. Uses the MFAState cache, which evaluates Conditional Access coverage, + Security Defaults state, and per-user MFA per user. + #> + param($Tenant) + + $TestId = 'SMB1001_2_9' + $Name = 'MFA is enforced where important digital data is stored' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -notlike 'Enforced*' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) accessing data-storing workloads are protected by MFA." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) can access data-storing workloads (SharePoint, OneDrive, Exchange) without MFA:" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md new file mode 100644 index 000000000000..1326ff0b8bda --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md @@ -0,0 +1,20 @@ +SMB1001 (3.1) — Level 1+ — implement a backup and recovery strategy for important digital data, with at least one offline copy isolated from the business network and a six-month minimum recovery history. Microsoft 365 native data-preservation features (Litigation Hold, retention policies, archive mailboxes) cover part of the recovery surface but do not satisfy the offline-isolated backup requirement on their own — that needs a third-party M365 backup product (Veeam, Datto, Spanning, AvePoint, or Microsoft 365 Backup). + +This test verifies the M365-native preservation half. Evidence the offline-backup half to your Dynamic Standard Certifier separately. + +**Remediation Action** + +```powershell +# Enable Litigation Hold on all user mailboxes +Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited | + Set-Mailbox -LitigationHoldEnabled $true +``` + +Or use CIPP `standards.EnableLitigationHold`. Pair with a third-party M365 backup product for offline copies. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Litigation Hold in Exchange Online](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 new file mode 100644 index 000000000000..eec43b854577 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestSMB1001_3_1 { + <# + .SYNOPSIS + Tests SMB1001 (3.1) - Implement a backup and recovery strategy for important digital assets + + .DESCRIPTION + Verifies the M365 data preservation feature most relevant to recovery — Litigation Hold + on user mailboxes — is enabled where licensed. SMB1001 3.1 also requires offline isolated + backups; that requirement is met by a third-party M365 backup product and must be + evidenced separately. + #> + param($Tenant) + + $TestId = 'SMB1001_3_1' + $Name = 'Backup and recovery strategy preserves important digital data' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Mailboxes cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + return + } + + $UserMailboxes = @($Mailboxes | Where-Object { $_.recipientTypeDetails -eq 'UserMailbox' -and $_.LicensedForLitigationHold -eq $true }) + $WithoutHold = @($UserMailboxes | Where-Object { $_.LitigationHoldEnabled -ne $true }) + + if ($UserMailboxes.Count -eq 0) { + $Status = 'Informational' + $Result = 'No user mailboxes with a licence that supports Litigation Hold were found. SMB1001 (3.1) still requires an offline-isolated backup strategy — evidence the third-party backup product separately.' + } elseif ($WithoutHold.Count -eq 0) { + $Status = 'Passed' + $Result = "Litigation Hold is enabled on all $($UserMailboxes.Count) eligible user mailbox(es). Evidence the offline-isolated backup half of SMB1001 (3.1) separately (e.g., third-party M365 backup vendor)." + } else { + $Status = 'Failed' + $TableRows = foreach ($M in ($WithoutHold | Select-Object -First 25)) { "- $($M.UPN)" } + $Result = "$($WithoutHold.Count) of $($UserMailboxes.Count) eligible user mailbox(es) do not have Litigation Hold enabled. Without preservation, deleted email cannot be recovered after the retention window:`n`n$(($TableRows) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/report.json b/Modules/CIPPTests/Public/Tests/SMB1001/report.json new file mode 100644 index 000000000000..76b619b9826c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/report.json @@ -0,0 +1,30 @@ +{ + "name": "SMB1001:2026 Cybersecurity Standard", + "description": "Dynamic Standards International (DSI) SMB1001:2026 — multi-tiered cybersecurity certification for small and medium-sized businesses. The standard prescribes a five-level pathway across Technology Management, Access Management, Backup and Recovery, Policies/Processes/Plans, and Education and Training. CIPP tests cover the technical controls implementable against a Microsoft 365 tenant (Identity) and via Intune-managed workstations (Devices).", + "version": "2026 v1.0", + "source": "https://dsi.org", + "category": "SMB Security Baselines", + "IdentityTests": [ + "SMB1001_1_11", + "SMB1001_2_1", + "SMB1001_2_3", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_8", + "SMB1001_2_9", + "SMB1001_2_12", + "SMB1001_3_1" + ], + "DevicesTests": [ + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_2_2", + "SMB1001_4_7" + ] +} From e2382b4a2975aba260b15a5038d4dc3d151b96e4 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 5 May 2026 16:27:39 -0400 Subject: [PATCH 22/68] fix: exclude expired user consent requests --- .../CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 index d6899a8af1f4..ac6aa1bef28e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 @@ -14,7 +14,7 @@ function Get-CIPPAlertNewAppApproval { ) try { - $Approvals = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/identityGovernance/appConsent/appConsentRequests?`$top=100&`$filter=userConsentRequests/any (u:u/status eq 'InProgress')" -tenantid $TenantFilter + $Approvals = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/identityGovernance/appConsent/appConsentRequests?`$top=100&`$filter=userConsentRequests/any(u:u/status eq 'InProgress')" -tenantid $TenantFilter if ($Approvals.count -gt 0) { $TenantGUID = (Get-Tenants -TenantFilter $TenantFilter -SkipDomains).customerId @@ -24,6 +24,9 @@ function Get-CIPPAlertNewAppApproval { $userConsentRequests = New-GraphGetRequest -Uri "https://graph.microsoft.com/v1.0/identityGovernance/appConsent/appConsentRequests/$($App.id)/userConsentRequests" -tenantid $TenantFilter $userConsentRequests | ForEach-Object { + if ($_.status -eq 'Expired') { + return + } $consentUrl = if ($App.consentType -eq 'Static') { # if something is going wrong here you've probably stumbled on a fourth variation - rvdwegen "https://login.microsoftonline.com/$($TenantFilter)/adminConsent?client_id=$($App.appId)&bf_id=$($App.id)&redirect_uri=https://entra.microsoft.com/TokenAuthorize" From 45c8f59872453db2fa35ade73ca4df788c64f947 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Sun, 3 May 2026 12:24:55 -0500 Subject: [PATCH 23/68] Fix TeamsMeetingRecordingExpiration drift report showing Current=true The report block overwrote the actual current expiration day count with the boolean literal $true whenever StateIsCorrect was true, then wrote that boolean into the compare field as the "Current" value. This caused the drift page to render Expected=120 / Current= true and flag a deviation even when the policy was correctly set -- while the remediate block (which used the unmangled value) logged "already set to 120 days". Removing the conditional reassignment makes the report always emit the real numeric value, so when state matches expected the drift page goes green instead of showing a phantom deviation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 index e84885986aee..e093179116f2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 @@ -91,7 +91,6 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { if ($Settings.report -eq $true) { Add-CIPPBPAField -FieldName 'TeamsMeetingRecordingExpiration' -FieldValue $CurrentExpirationDays -StoreAs string -Tenant $Tenant - $CurrentExpirationDays = if ($StateIsCorrect) { $true } else { $CurrentExpirationDays } $CurrentValue = @{ MeetingRecordingExpirationDays = $CurrentExpirationDays } From 512d87c2aa15d4d9ae25225def7e6e08dd714f27 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Sun, 3 May 2026 12:30:09 -0500 Subject: [PATCH 24/68] Fix SPFileRequests drift report showing pre-remediation values The standard fetches $CurrentState from SPO once at the top, then the remediate block calls Set-CIPPSPOTenant to mutate the live state but never refreshes $CurrentState. When both remediate and report run in the same pass, the report block builds CurrentValue from the stale pre-remediation $CurrentState and writes it into the drift compare field -- producing Expected=true / Current=false on the manage drift page even though the live SPO state is now correct (and the logbook says "Successfully set File Requests..."). After a successful Set-CIPPSPOTenant, mirror the just-applied properties into $CurrentState so the report block writes the post-remediation values. The catch path is untouched, so failed remediation correctly leaves the original state in the report. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Standards/Invoke-CIPPStandardSPFileRequests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 099d196ac80e..5c3169485453 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -97,6 +97,15 @@ function Invoke-CIPPStandardSPFileRequests { $CurrentState | Set-CIPPSPOTenant -Properties $Properties + # Reflect the just-applied state in-memory so the report block does not write + # the pre-remediation values into the drift compare field. + $CurrentState.CoreRequestFilesLinkEnabled = $WantedState + $CurrentState.OneDriveRequestFilesLinkEnabled = $WantedState + if ($null -ne $ExpirationDays -and $WantedState -eq $true) { + $CurrentState.CoreRequestFilesLinkExpirationInDays = $ExpirationDays + $CurrentState.OneDriveRequestFilesLinkExpirationInDays = $ExpirationDays + } + $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set File Requests to $HumanReadableState$ExpirationMessage" -sev Info } catch { From 2be775b64dcd88fa112a236d9b8b61a4ea3d2e94 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Sun, 3 May 2026 12:59:59 -0500 Subject: [PATCH 25/68] Fix DisableSelfServiceLicenses autoclaim reading wrong property The autoclaim GET endpoint at admin.microsoft.com returns the policy state under tenantPolicyValue, not policyValue: { "policyId": "Autoclaim", "tenantPolicyValue": "Disabled", "tenantId": "..." } CIPP was reading $AutoClaimPolicy.policyValue (which does not exist on the response object) so it always evaluated to $null, causing: - the initial Compare to find a diff vs the Expected 'Disabled', - remediation to fire every run, - the post-remediation re-fetch to also read null, - the drift compare-field to be written with Value=null, - the manage drift page to show a phantom deviation despite the log saying "Changed Self Service status for product 'Trial Autoclaim - autoclaim' from '' to 'Disabled'". The POST body still uses {"policyValue": "Disabled"} -- Microsoft's read/write schemas are asymmetric on this endpoint, so only the read paths needed the property name corrected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index f1bcecf87877..43c37afa8fa4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -78,7 +78,7 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = if ($null -eq $AutoClaimPolicy.policyValue) { 'Disabled' } else { $AutoClaimPolicy.policyValue } + policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy: $($_.Exception.Message)" -sev Error @@ -178,7 +178,7 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = if ($null -eq $AutoClaimPolicy.policyValue) { 'Disabled' } else { $AutoClaimPolicy.policyValue } + policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy after remediation: $($_.Exception.Message)" -sev Error From 7aeeffcb48b080685c180e8431e3128d3433a082 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Sun, 3 May 2026 13:04:58 -0500 Subject: [PATCH 26/68] Fix TeamsFederationConfiguration drift report ordering mismatch The state check uses Compare-Object (order-insensitive) so $StateIsCorrect correctly reported "Federation Configuration already set." But the report block wrote the raw arrays to the compare field, where the current side was alphabetically sorted upstream and the expected side was in user-input order from $Settings.DomainList. The drift page diffs the JSON arrays literally, so identical sets in different orders rendered as a phantom deviation. Sort the parsed expected arrays (AllowedDomainsAsAList for AllowSpecificExternal, BlockedDomains for BlockSpecificExternal) at the source so they match the already-sorted current values. Set-CsTenantFederationConfiguration is set-semantic on the Microsoft side, so order does not affect remediation behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Invoke-CIPPStandardTeamsFederationConfiguration.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index ab245b7fd939..425aa552a2b6 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -75,7 +75,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $AllowedDomains = $null $BlockedDomains = @() if ($null -ne $Settings.DomainList) { - $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() + $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() | Sort-Object } else { $AllowedDomainsAsAList = @() } @@ -85,7 +85,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $AllowedDomains = $AllowAllKnownDomains $AllowedDomainsAsAList = @() if ($null -ne $Settings.DomainList) { - $BlockedDomains = @($Settings.DomainList).Split(',').Trim() + $BlockedDomains = @($Settings.DomainList).Split(',').Trim() | Sort-Object } else { $BlockedDomains = @() } From 27c08abc9a2d81b0ae133f8f66ba2602c10353d4 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Sun, 3 May 2026 13:09:55 -0500 Subject: [PATCH 27/68] Fix SafeLinksPolicy and MalwareFilterPolicy drift report ordering Same shape as the TeamsFederationConfiguration fix: the state check uses Compare-Object (order-insensitive) so $StateIsCorrect correctly reports "already correctly configured", but the report block writes the raw arrays to the compare field where Current and Expected come from different sources and may be ordered differently. The drift page diffs the JSON arrays literally, producing phantom deviations even though the policies are correctly applied. SafeLinksPolicy.DoNotRewriteUrls: Current was $CurrentState.DoNotRewriteUrls (raw API), Expected was $Settings.DoNotRewriteUrls.value (raw user input). Sort both in the report block so they match regardless of input or API order. MalwareFilterPolicy.FileTypes: Current was $CurrentState.FileTypes (raw API), Expected was $ExpectedFileTypes ($DefaultFileTypes + user-supplied OptionalFileTypes split). Sort both in the report block. Set-SafeLinksPolicy and Set-MalwareFilterPolicy treat these lists as sets on the Microsoft side, so order does not affect remediation behavior -- only the drift display. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 | 4 ++-- .../Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 index 978103c47d88..661ee92e0760 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 @@ -220,7 +220,7 @@ function Invoke-CIPPStandardMalwareFilterPolicy { Name = $CurrentState.Name EnableFileFilter = $CurrentState.EnableFileFilter FileTypeAction = $CurrentState.FileTypeAction - FileTypes = $CurrentState.FileTypes + FileTypes = @($CurrentState.FileTypes | Sort-Object) ZapEnabled = $CurrentState.ZapEnabled QuarantineTag = $CurrentState.QuarantineTag EnableInternalSenderAdminNotifications = $CurrentState.EnableInternalSenderAdminNotifications @@ -238,7 +238,7 @@ function Invoke-CIPPStandardMalwareFilterPolicy { Name = $PolicyName EnableFileFilter = $true FileTypeAction = $FileTypeAction - FileTypes = $ExpectedFileTypes + FileTypes = @($ExpectedFileTypes | Sort-Object) ZapEnabled = $true QuarantineTag = $Settings.QuarantineTag EnableInternalSenderAdminNotifications = $Settings.EnableInternalSenderAdminNotifications diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 index 04527ce524d6..132389c0f15f 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 @@ -231,7 +231,7 @@ function Invoke-CIPPStandardSafeLinksPolicy { DeliverMessageAfterScan = $CurrentState.DeliverMessageAfterScan DisableUrlRewrite = $CurrentState.DisableUrlRewrite EnableOrganizationBranding = $CurrentState.EnableOrganizationBranding - DoNotRewriteUrls = $CurrentState.DoNotRewriteUrls + DoNotRewriteUrls = @($CurrentState.DoNotRewriteUrls | Sort-Object) } $ExpectedValue = @{ Name = $PolicyName @@ -245,7 +245,7 @@ function Invoke-CIPPStandardSafeLinksPolicy { DeliverMessageAfterScan = $true DisableUrlRewrite = $Settings.DisableUrlRewrite EnableOrganizationBranding = $Settings.EnableOrganizationBranding - DoNotRewriteUrls = $Settings.DoNotRewriteUrls.value ?? @() + DoNotRewriteUrls = @(($Settings.DoNotRewriteUrls.value ?? @()) | Sort-Object) } Set-CIPPStandardsCompareField -FieldName 'standards.SafeLinksPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From 212c6f1323d7a92b43ce98fd0b90b190e17b0892 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 May 2026 08:32:09 +0800 Subject: [PATCH 28/68] improve drift page loading speed --- .../Functions/Get-CIPPTenantAlignment.ps1 | 3 +- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 86 ++++++++----------- 2 files changed, 35 insertions(+), 54 deletions(-) diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 18dc0a1ee99c..b5c1368413a1 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -69,7 +69,7 @@ function Get-CIPPTenantAlignment { $Tenants = Get-Tenants -IncludeErrors $AllStandards | Where-Object { $_.PartitionKey -in $Tenants.defaultDomainName } } - $TagTemplates = Get-CIPPAzDataTableEntity @TemplateTable + $TagTemplates = Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'IntuneTemplate'" # Build a hashtable indexed by Package for O(1) tag lookup $TemplatesByPackage = @{} foreach ($t in $TagTemplates) { @@ -212,7 +212,6 @@ function Get-CIPPTenantAlignment { if ($IntuneTemplate.'TemplateList-Tags') { foreach ($Tag in $IntuneTemplate.'TemplateList-Tags') { - Write-Host "Processing Intune Tag: $($Tag.value)" $IntuneActions = if ($IntuneTemplate.action) { $IntuneTemplate.action } else { @() } $IntuneReportingEnabled = ($IntuneActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 $TagTemplate = if ($TemplatesByPackage.ContainsKey($Tag.value)) { $TemplatesByPackage[$Tag.value] } else { @() } diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index b499b0f26e46..7700cfa119da 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -33,11 +33,11 @@ function Get-CIPPDrift { $ConditionalAccessCapable = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') $IntuneTable = Get-CippTable -tablename 'templates' - # Load all templates for tag resolution (mirrors Get-CIPPTenantAlignment) - $AllTableTemplates = Get-CIPPAzDataTableEntity @IntuneTable + # Load only IntuneTemplate partition for tag resolution and display name lookup + $RawIntuneTemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'IntuneTemplate'" # Build a hashtable indexed by Package for O(1) tag lookup $TemplatesByPackage = @{} - foreach ($t in $AllTableTemplates) { + foreach ($t in $RawIntuneTemplates) { if ($t.Package) { if (-not $TemplatesByPackage.ContainsKey($t.Package)) { $TemplatesByPackage[$t.Package] = [System.Collections.Generic.List[object]]::new() @@ -45,35 +45,36 @@ function Get-CIPPDrift { $TemplatesByPackage[$t.Package].Add($t) } } - - # Always load templates for display name resolution, even if tenant doesn't have licenses - $IntuneFilter = "PartitionKey eq 'IntuneTemplate'" - $RawIntuneTemplates = $AllTableTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' } - $AllIntuneTemplates = $RawIntuneTemplates | ForEach-Object { + # Build GUID-indexed hashtables for O(1) display name lookups in deviation loop + $IntuneTemplatesByGuid = @{} + $AllIntuneTemplates = foreach ($RawTemplate in $RawIntuneTemplates) { try { - $JSONData = $_.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue + $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force + $IntuneTemplatesByGuid[$RawTemplate.RowKey] = $data $data } catch { # Skip invalid templates } - } | Sort-Object -Property displayName + } - # Load all CA templates - $RawCATemplates = $AllTableTemplates | Where-Object { $_.PartitionKey -eq 'CATemplate' } - $AllCATemplates = $RawCATemplates | ForEach-Object { + # Load CA templates with GUID hashtable + $RawCATemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'CATemplate'" + $CATemplatesByGuid = @{} + $AllCATemplates = foreach ($RawTemplate in $RawCATemplates) { try { - $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force + $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force + $CATemplatesByGuid[$RawTemplate.RowKey] = $data $data } catch { # Skip invalid templates } - } | Sort-Object -Property displayName + } try { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter -TemplateId $TemplateId | Where-Object -Property standardType -EQ 'drift' @@ -116,44 +117,22 @@ function Get-CIPPDrift { $standardDescription = $null #if the $ComparisonItem.StandardName contains *IntuneTemplate*, then it's an Intune policy deviation, and we need to grab the correct displayname from the template table if ($ComparisonItem.StandardName -like '*IntuneTemplate*') { - # Extract GUID from format like: standards.IntuneTemplate.{GUID}.IntuneTemplate.json - # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) $Parts = $ComparisonItem.StandardName.Split('.') - $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 - - if ($CompareGuid) { - Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllIntuneTemplates | Where-Object { $_.GUID -match "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found Intune template: $displayName" - } else { - Write-Warning "Intune template not found for GUID: $CompareGuid" - } - } else { - Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" + $CompareGuid = foreach ($p in $Parts) { if ($p -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { $p; break } } + if ($CompareGuid -and $IntuneTemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $IntuneTemplatesByGuid[$CompareGuid] + $displayName = $Template.displayName + $standardDescription = $Template.description } } # Handle Conditional Access templates if ($ComparisonItem.StandardName -like '*ConditionalAccessTemplate*') { - # Extract GUID from format like: standards.ConditionalAccessTemplate.{GUID}.CATemplate.json - # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) $Parts = $ComparisonItem.StandardName.Split('.') - $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 - - if ($CompareGuid) { - Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllCATemplates | Where-Object { $_.GUID -match "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found CA template: $displayName" - } else { - Write-Warning "CA template not found for GUID: $CompareGuid" - } - } else { - Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" + $CompareGuid = foreach ($p in $Parts) { if ($p -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { $p; break } } + if ($CompareGuid -and $CATemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $CATemplatesByGuid[$CompareGuid] + $displayName = $Template.displayName + $standardDescription = $Template.description } } # Handle QuarantineTemplate — suffix is hex-encoded display name, decode it @@ -302,7 +281,9 @@ function Get-CIPPDrift { # Get actual CA templates from templates table if ($CATemplateIds.Count -gt 0) { try { - $TemplateCATemplates = $AllCATemplates | Where-Object { $_.GUID -in $CATemplateIds } + $TemplateCATemplates = foreach ($id in $CATemplateIds) { + if ($CATemplatesByGuid.ContainsKey($id)) { $CATemplatesByGuid[$id] } + } } catch { Write-Warning "Failed to get CA templates: $($_.Exception.Message)" } @@ -311,8 +292,9 @@ function Get-CIPPDrift { # Get actual Intune templates from templates table if ($IntuneTemplateIds.Count -gt 0) { try { - - $TemplateIntuneTemplates = $AllIntuneTemplates | Where-Object { $_.GUID -in $IntuneTemplateIds } + $TemplateIntuneTemplates = foreach ($id in $IntuneTemplateIds) { + if ($IntuneTemplatesByGuid.ContainsKey($id)) { $IntuneTemplatesByGuid[$id] } + } } catch { Write-Warning "Failed to get Intune templates: $($_.Exception.Message)" } From 0e537b1dd94b00cea9873903bbc526b17828aa30 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 5 May 2026 22:07:06 -0400 Subject: [PATCH 29/68] fix: Guard against missing GeoIP.Data --- Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 index 20f0e94172ee..754454529e3d 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 @@ -8,7 +8,7 @@ function Get-CIPPGeoIPLocation { $30DaysAgo = (Get-Date).AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') $Filter = "PartitionKey eq 'IP' and RowKey eq '$IP' and Timestamp ge datetime'$30DaysAgo'" $GeoIP = Get-CippAzDataTableEntity @CacheGeoIPTable -Filter $Filter - if ($GeoIP) { + if ($GeoIP -and $GeoIP.Data) { return ($GeoIP.Data | ConvertFrom-Json) } $location = Invoke-CIPPRestMethod -Uri "https://geoipdb.azurewebsites.net/api/GetIPInfo?IP=$IP" From 21528eafdcc6a28a36cf82cea77ef77af735bb7f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 May 2026 13:44:08 +0800 Subject: [PATCH 30/68] parse timezone value for nice response message --- .../CIPPCore/Public/Set-CIPPOutOfoffice.ps1 | 23 +++++++++++++++---- .../Administration/Invoke-ExecSetOoO.ps1 | 4 ++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 index ab71ea5ca769..a445e296a0d7 100644 --- a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 @@ -18,7 +18,8 @@ function Set-CIPPOutOfOffice { [bool]$AutoDeclineFutureRequestsWhenOOF, [bool]$DeclineEventsForScheduledOOF, [bool]$DeclineAllEventsForScheduledOOF, - [string]$DeclineMeetingMessage + [string]$DeclineMeetingMessage, + [string]$Timezone ) try { @@ -68,9 +69,23 @@ function Set-CIPPOutOfOffice { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxAutoReplyConfiguration' -cmdParams $CmdParams -Anchor $UserID - $Results = $State -eq 'Scheduled' ? - "Scheduled Out-of-office for $($UserID) between $($StartTime.toString()) and $($EndTime.toString())" : - "Set Out-of-office for $($UserID) to $State." + if ($State -eq 'Scheduled') { + # Convert display times to the user's local timezone if provided + $DisplayStart = $StartTime + $DisplayEnd = $EndTime + if ($Timezone) { + try { + $UserTz = [System.TimeZoneInfo]::FindSystemTimeZoneById($Timezone) + $DisplayStart = [System.TimeZoneInfo]::ConvertTimeFromUtc($StartTime, $UserTz) + $DisplayEnd = [System.TimeZoneInfo]::ConvertTimeFromUtc($EndTime, $UserTz) + } catch { + # Fall back to UTC times if timezone conversion fails + } + } + $Results = "Scheduled Out-of-office for $($UserID) between $($DisplayStart.ToString()) and $($DisplayEnd.ToString())" + } else { + $Results = "Set Out-of-office for $($UserID) to $State." + } Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Info' -tenant $TenantFilter return $Results diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 index a9df5705c33f..fbb3b64d7e66 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 @@ -71,6 +71,10 @@ function Invoke-ExecSetOoO { } } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.timezone)) { + $SplatParams.Timezone = $Request.Body.timezone + } + Write-Information "Setting Out of Office with the following parameters: $($SplatParams | ConvertTo-Json -Depth 10)" $Results = Set-CIPPOutOfOffice @SplatParams $StatusCode = [HttpStatusCode]::OK From 23505c303e863da9d1fb274f2f67fff7daa44cd6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 May 2026 16:42:15 +0800 Subject: [PATCH 31/68] Correct backup object retuned --- .../HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 index a9b0190a718d..4ddaf9c44c52 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 @@ -23,7 +23,7 @@ function Invoke-ExecListBackup { if ($NameOnly) { try { $Processed = foreach ($item in $Result) { - $properties = $item.PSObject.Properties | Where-Object { $_.Name -notin @('TenantFilter', 'ETag', 'PartitionKey', 'RowKey', 'Timestamp', 'OriginalEntityId', 'SplitOverProps', 'PartIndex') -and $_.Value } + $properties = $item.PSObject.Properties | Where-Object { $_.Name -notin @('TenantFilter', 'ETag', 'PartitionKey', 'RowKey', 'Timestamp', 'OriginalEntityId', 'SplitOverProps', 'PartIndex', 'Backup', 'BackupIsBlob') -and $_.Value } if ($Type -eq 'Scheduled') { [PSCustomObject]@{ @@ -51,7 +51,7 @@ function Invoke-ExecListBackup { } } } - $Result = $Processed | Sort-Object Timestamp -Descending + $Result = if ($Processed) { @($Processed | Sort-Object Timestamp -Descending) } else { @() } } catch { Write-Warning "Error processing backup entries: $_" Write-Information $_.InvocationInfo.PositionMessage From cc07ca51d567a2eaf00caae357c8f87d8c25146d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 6 May 2026 12:43:59 +0200 Subject: [PATCH 32/68] public group standard --- ...nvoke-CIPPStandardEnforcePrivateGroups.ps1 | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 new file mode 100644 index 000000000000..6318cb73f1c1 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 @@ -0,0 +1,126 @@ +function Invoke-CIPPStandardEnforcePrivateGroups { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) EnforcePrivateGroups + .SYNOPSIS + (Label) Enforce Private M365 Groups + .DESCRIPTION + (Helptext) Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword. + (DocsDescription) Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 6.0.1 benchmark control 1.2.1. + .NOTES + CAT + Entra (AAD) Standards + TAG + "CIS M365 6.0.1 (1.2.1)" + EXECUTIVETEXT + Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces. + ADDEDCOMPONENT + {"type":"autoComplete","name":"standards.EnforcePrivateGroups.ExcludedGroupNames","label":"Exclude groups by display name keyword","multiple":true,"creatable":true,"required":false} + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Update-MgGroup -GroupId -Visibility Private + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "SHAREPOINTWAC" + "SHAREPOINTSTANDARD" + "SHAREPOINTENTERPRISE" + "SHAREPOINTENTERPRISE_EDU" + "SHAREPOINTENTERPRISE_GOV" + "ONEDRIVE_BASIC" + "ONEDRIVE_ENTERPRISE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'EnforcePrivateGroups' -TenantFilter $Tenant ` + -RequiredCapabilities @('SHAREPOINTWAC', 'SHAREPOINTSTANDARD', 'SHAREPOINTENTERPRISE', 'SHAREPOINTENTERPRISE_EDU', 'SHAREPOINTENTERPRISE_GOV', 'ONEDRIVE_BASIC', 'ONEDRIVE_ENTERPRISE') + if ($TestResult -eq $false) { return $true } + + # Parse exclusion keywords from settings + $ExcludeKeywords = @( + @($Settings.ExcludedGroupNames) | ForEach-Object { + if ($_ -is [string]) { $_ } else { [string]($_.value ?? $_.label) } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + + # Get all M365 (Unified) groups + try { + $AllGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=groupTypes/any(c:c eq 'Unified')&`$select=id,displayName,visibility&`$top=999" -tenantid $Tenant) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -message "EnforcePrivateGroups: Could not retrieve groups. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + # Identify public groups, excluding any that match exclusion keywords + $PublicGroups = foreach ($Group in $AllGroups) { + if ($Group.visibility -ne 'Public') { continue } + $IsExcluded = $false + foreach ($Keyword in $ExcludeKeywords) { + if ($Group.displayName -match [regex]::Escape($Keyword)) { + $IsExcluded = $true + break + } + } + if (-not $IsExcluded) { $Group } + } + + $StateIsCorrect = ($PublicGroups.Count -eq 0) + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All M365 groups are already private (or excluded).' -sev Info + } else { + $SuccessCount = 0 + $FailCount = 0 + foreach ($Group in $PublicGroups) { + try { + $Body = @{ visibility = 'Private' } | ConvertTo-Json -Compress -Depth 10 + New-GraphPostRequest -tenantid $Tenant -Uri "https://graph.microsoft.com/beta/groups/$($Group.id)" ` + -Type PATCH -Body $Body -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Set group '$($Group.displayName)' to Private." -sev Info + $SuccessCount++ + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set group '$($Group.displayName)' to Private: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $FailCount++ + } + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "EnforcePrivateGroups: Remediated $SuccessCount group(s), $FailCount failure(s)." -sev Info + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All M365 groups are private (or excluded).' -sev Info + } else { + $GroupNames = ($PublicGroups | Select-Object -ExpandProperty displayName) -join ', ' + Write-StandardsAlert -message "The following M365 groups are public and not excluded: $GroupNames" ` + -object ($PublicGroups | Select-Object id, displayName, visibility) ` + -tenant $Tenant -standardName 'EnforcePrivateGroups' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + PublicGroupCount = @($PublicGroups).Count + PublicGroups = ($PublicGroups | Select-Object -ExpandProperty displayName) -join ', ' + } + $ExpectedValue = [PSCustomObject]@{ + PublicGroupCount = 0 + PublicGroups = '' + } + Set-CIPPStandardsCompareField -FieldName 'standards.EnforcePrivateGroups' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'EnforcePrivateGroups' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} From 9a844383ff2383a99e23c2be29f89674b1cdb1c1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 6 May 2026 13:12:59 +0200 Subject: [PATCH 33/68] Empty AllowList Standard for CIS --- ...oke-CIPPStandardEmptyFilterIPAllowList.ps1 | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 new file mode 100644 index 000000000000..834a84e735ec --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 @@ -0,0 +1,93 @@ +function Invoke-CIPPStandardEmptyFilterIPAllowList { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) EmptyFilterIPAllowList + .SYNOPSIS + (Label) Ensure connection filter IP allow list is empty + .DESCRIPTION + (Helptext) Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks. + (DocsDescription) IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it. + .NOTES + CAT + Defender Standards + TAG + "CIS M365 6.0.1 (2.1.12)" + EXECUTIVETEXT + Ensures the Exchange Online connection filter IP allow list is empty, preventing any IP addresses from bypassing spam filtering, spoofing checks, and sender authentication. Keeping this list empty ensures all inbound email undergoes full security scanning, reducing the risk of phishing and malware delivery through trusted-but-compromised sources. + ADDEDCOMPONENT + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @() + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "EXCHANGE_S_STANDARD" + "EXCHANGE_S_ENTERPRISE" + "EXCHANGE_S_STANDARD_GOV" + "EXCHANGE_S_ENTERPRISE_GOV" + "EXCHANGE_LITE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'EmptyFilterIPAllowList' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { return $true } + + try { + $CurrentState = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-HostedConnectionFilterPolicy' -cmdParams @{ Identity = 'Default' }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "EmptyFilterIPAllowList: Failed to get connection filter policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $IPAllowList = @($CurrentState.IPAllowList) + $StateIsCorrect = ($IPAllowList.Count -eq 0) + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Connection filter IP allow list is already empty.' -sev Info + } else { + try { + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-HostedConnectionFilterPolicy' -cmdParams @{ + Identity = 'Default' + IPAllowList = @() + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cleared connection filter IP allow list. Removed: $($IPAllowList -join ', ')" -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to clear connection filter IP allow list. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Connection filter IP allow list is empty.' -sev Info + } else { + Write-StandardsAlert -message "Connection filter IP allow list is not empty. Current entries: $($IPAllowList -join ', ')" -object @{ IPAllowList = $IPAllowList } -tenant $Tenant -standardName 'EmptyFilterIPAllowList' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + IPAllowListEmpty = $StateIsCorrect + IPAllowList = ($IPAllowList -join ', ') + } + $ExpectedValue = [PSCustomObject]@{ + IPAllowListEmpty = $true + IPAllowList = '' + } + Set-CIPPStandardsCompareField -FieldName 'standards.EmptyFilterIPAllowList' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'EmptyFilterIPAllowList' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} From 7962aab67227cb24cbbd6f9236bdd6056cf60469 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 6 May 2026 13:23:18 +0200 Subject: [PATCH 34/68] add teasm ZAP standard --- .../Standards/Invoke-CIPPStandardTeamsZAP.ps1 | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 new file mode 100644 index 000000000000..3b8b0ea9b22d --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 @@ -0,0 +1,90 @@ +function Invoke-CIPPStandardTeamsZAP { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) TeamsZAP + .SYNOPSIS + (Label) Ensure Zero-hour auto purge for Microsoft Teams is on + .DESCRIPTION + (Helptext) Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery. + (DocsDescription) Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 6.0.1 benchmark control 2.4.4. + .NOTES + CAT + Defender Standards + TAG + "CIS M365 6.0.1 (2.4.4)" + EXECUTIVETEXT + Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users. + ADDEDCOMPONENT + IMPACT + Low Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "EXCHANGE_S_STANDARD" + "EXCHANGE_S_ENTERPRISE" + "EXCHANGE_S_STANDARD_GOV" + "EXCHANGE_S_ENTERPRISE_GOV" + "EXCHANGE_LITE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'TeamsZAP' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { return $true } + + try { + $CurrentState = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TeamsProtectionPolicy' -cmdParams @{ Identity = 'Teams Protection Policy' }).ZapEnabled + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "TeamsZAP: Failed to get Teams Protection Policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $StateIsCorrect = $CurrentState -eq $true + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Teams ZAP is already enabled.' -sev Info + } else { + try { + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-TeamsProtectionPolicy' -cmdParams @{ + Identity = 'Teams Protection Policy' + ZapEnabled = $true + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Successfully enabled Teams ZAP.' -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable Teams ZAP. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Teams ZAP is enabled.' -sev Info + } else { + Write-StandardsAlert -message 'Teams Zero-hour auto purge (ZAP) is not enabled.' -object @{ ZapEnabled = $CurrentState } -tenant $Tenant -standardName 'TeamsZAP' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + ZapEnabled = $CurrentState + } + $ExpectedValue = [PSCustomObject]@{ + ZapEnabled = $true + } + Set-CIPPStandardsCompareField -FieldName 'standards.TeamsZAP' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'TeamsZAP' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} From 94a291c99939353e9134e7d78d739d0fe41ac94a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 6 May 2026 14:40:12 +0200 Subject: [PATCH 35/68] Ensure that collaboration invitations are sent to allowed domains only --- ...StandardCollaborationDomainRestriction.ps1 | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 new file mode 100644 index 000000000000..742c67aa89df --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 @@ -0,0 +1,118 @@ +function Invoke-CIPPStandardCollaborationDomainRestriction { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) CollaborationDomainRestriction + .SYNOPSIS + (Label) Restrict collaboration invitations to allowed domains only + .DESCRIPTION + (Helptext) Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured. + (DocsDescription) By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list. + .NOTES + CAT + Entra (AAD) Standards + TAG + "CIS M365 6.0.1 (5.1.6.1)" + EXECUTIVETEXT + Restricts external collaboration invitations to approved domains only, preventing users from sharing data with unapproved external organizations. This reduces the risk of data exfiltration and ensures that collaboration occurs only with trusted business partners. + ADDEDCOMPONENT + {"type":"textField","name":"standards.CollaborationDomainRestriction.allowedDomains","label":"Allowed domains (comma separated)","required":false,"placeholder":"contoso.com, fabrikam.com"} + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $Uri = 'https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default' + + try { + $CurrentState = New-GraphGetRequest -Uri $Uri -tenantid $Tenant + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Could not get B2B management policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $CurrentDomains = $CurrentState.invitationsAllowedAndBlockedDomainsPolicy + $HasRestrictions = $CurrentDomains -and ( + ($CurrentDomains.allowedDomains -and $CurrentDomains.allowedDomains.Count -gt 0) -or + ($CurrentDomains.blockedDomains -and $CurrentDomains.blockedDomains.Count -gt 0) + ) + + $DesiredDomains = @() + if ($Settings.allowedDomains) { + $DesiredDomains = @($Settings.allowedDomains -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + } + + if ($DesiredDomains.Count -gt 0) { + $CurrentAllowed = @($CurrentDomains.allowedDomains | Sort-Object) + $DesiredSorted = @($DesiredDomains | Sort-Object) + $StateIsCorrect = ($null -ne $CurrentDomains) -and ($CurrentAllowed -join ',') -eq ($DesiredSorted -join ',') + } else { + $StateIsCorrect = $HasRestrictions + } + + if ($Settings.remediate -eq $true) { + if ($DesiredDomains.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No allowed domains specified for CollaborationDomainRestriction. Skipping remediation.' -sev Info + } elseif ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'B2B collaboration domain restrictions are already configured correctly.' -sev Info + } else { + try { + $Body = @{ + invitationsAllowedAndBlockedDomainsPolicy = @{ + allowedDomains = $DesiredDomains + } + } | ConvertTo-Json -Depth 10 -Compress + + $null = New-GraphPostRequest -Uri $Uri -Body $Body -TenantID $Tenant -Type PATCH -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully set B2B collaboration allowed domains to: $($DesiredDomains -join ', ')" -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set B2B collaboration domain restrictions. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'B2B collaboration domain restrictions are configured.' -sev Info + } else { + $AlertMsg = if ($DesiredDomains.Count -gt 0) { + "B2B collaboration allowed domains do not match expected list: $($DesiredDomains -join ', ')" + } else { + 'B2B collaboration invitations are not restricted by domain allow/block list' + } + Write-StandardsAlert -message $AlertMsg -object $CurrentDomains -tenant $Tenant -standardName 'CollaborationDomainRestriction' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = @{ + hasRestrictions = $HasRestrictions + allowedDomains = $CurrentDomains.allowedDomains -join ', ' + blockedDomains = $CurrentDomains.blockedDomains -join ', ' + } + $ExpectedValue = @{ + hasRestrictions = $true + } + if ($DesiredDomains.Count -gt 0) { + $ExpectedValue.allowedDomains = $DesiredDomains -join ', ' + } + + Set-CIPPStandardsCompareField -FieldName 'standards.CollaborationDomainRestriction' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'CollaborationDomainRestriction' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} From 498d03f3258b1a1704ae748faecaf12dc53b311e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 6 May 2026 15:01:14 -0400 Subject: [PATCH 36/68] fix: duplicate group ID retrieval in Invoke-ExecAddGDAPRole function limit to one per role --- .../HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 index dcf555008f41..f02dff9d052b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 @@ -101,10 +101,10 @@ function Invoke-ExecAddGDAPRole { if ($GroupName -in $ExistingGroups.displayName) { @{ PartitionKey = 'Roles' - RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName).id + RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id RoleName = $RoleName GroupName = $GroupName - GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName).id + GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id roleDefinitionId = $Value } $Results.Add("$GroupName already exists") From 9ae75623734128d3f60654e9aeaf6e73b06b134e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 7 May 2026 11:37:01 +0800 Subject: [PATCH 37/68] Update Standard AutoAddProxy User reporting DB, add rerun protection and add null guarding --- .../Invoke-CIPPStandardAutoAddProxy.ps1 | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 index 5549e79b9bc1..688250583458 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 @@ -36,22 +36,43 @@ function Invoke-CIPPStandardAutoAddProxy { $QueueItem ) - try { - $Domains = New-ExoRequest -TenantId $Tenant -Cmdlet 'Get-AcceptedDomain' | Select-Object -ExpandProperty DomainName - $AllMailboxes = New-ExoRequest -TenantId $Tenant -Cmdlet 'Get-Mailbox' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the AutoAddProxy state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $TestResult = Test-CIPPStandardLicense -StandardName 'AutoArchive' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { + return $true + } + + # Re-run protection — skip if already executed within the last 24 hours + $Rerun = Test-CIPPRerun -Tenant $Tenant -API 'AutoAddProxy' -Interval 86400 + if ($Rerun) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'AutoAddProxy recently executed. Skipping to prevent duplicate execution.' -sev Debug + return $true + } + + # Use the reporting DB cache for both accepted domains and mailboxes + $Domains = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'ExoAcceptedDomains' | Select-Object -ExpandProperty DomainName) + if ($Domains.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No cached accepted domains found. Ensure the ExoAcceptedDomains cache has been populated.' -sev Error + return + } + + $AllMailboxes = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'Mailboxes') + if ($AllMailboxes.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No cached mailboxes found. Ensure the mailbox cache has been populated.' -sev Error return } + # Build a list of all email addresses per mailbox from the cache fields + # Cache stores: primarySmtpAddress, AdditionalEmailAddresses (comma-separated lowercase smtp aliases) $MissingProxies = 0 foreach ($Domain in $Domains) { - $ProcessMailboxes = $AllMailboxes | Where-Object { - $addresses = @($_.EmailAddresses) -replace '^[^:]+:' # remove SPO:, SMTP:, etc. - $hasDomain = $addresses | Where-Object { $_ -like "*@$Domain" } - if ($hasDomain) { return $false } else { return $true } - } + $ProcessMailboxes = @($AllMailboxes | Where-Object { + $AllAddresses = @($_.primarySmtpAddress) + if (-not [string]::IsNullOrWhiteSpace($_.AdditionalEmailAddresses)) { + $AllAddresses += @($_.AdditionalEmailAddresses -split ',\s*') + } + $HasDomain = $AllAddresses | Where-Object { $_ -like "*@$Domain" } + -not $HasDomain + }) $MissingProxies += $ProcessMailboxes.Count } @@ -63,7 +84,6 @@ function Invoke-CIPPStandardAutoAddProxy { MissingProxies = $MissingProxies } - if ($Settings.report -eq $true) { Set-CIPPStandardsCompareField -FieldName 'standards.AutoAddProxy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'AutoAddProxy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant @@ -83,19 +103,25 @@ function Invoke-CIPPStandardAutoAddProxy { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All mailboxes already have proxy addresses for all domains' -sev Info } else { foreach ($Domain in $Domains) { - $ProcessMailboxes = $AllMailboxes | Where-Object { - $addresses = @($_.EmailAddresses) -replace '^[^:]+:' # remove SPO:, SMTP:, etc. - $hasDomain = $addresses | Where-Object { $_ -like "*@$Domain" } - if ($hasDomain) { return $false } else { return $true } - } + $ProcessMailboxes = @($AllMailboxes | Where-Object { + $AllAddresses = @($_.primarySmtpAddress) + if (-not [string]::IsNullOrWhiteSpace($_.AdditionalEmailAddresses)) { + $AllAddresses += @($_.AdditionalEmailAddresses -split ',\s*') + } + $HasDomain = $AllAddresses | Where-Object { $_ -like "*@$Domain" } + -not $HasDomain + }) $bulkRequest = foreach ($Mailbox in $ProcessMailboxes) { - $LocalPart = $Mailbox.UserPrincipalName -split '@' | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($Mailbox.UPN)) { continue } + $LocalPart = $Mailbox.UPN -split '@' | Select-Object -First 1 $NewAlias = "$LocalPart@$Domain" @{ CmdletInput = @{ CmdletName = 'Set-Mailbox' - Parameters = @{Identity = $Mailbox.Identity ; EmailAddresses = @{ + Parameters = @{ + Identity = $Mailbox.UPN + EmailAddresses = @{ '@odata.type' = '#Exchange.GenericHashTable' Add = "smtp:$NewAlias" } @@ -103,11 +129,13 @@ function Invoke-CIPPStandardAutoAddProxy { } } } - $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($bulkRequest) - foreach ($Result in $BatchResults) { - if ($Result.error) { - $ErrorMessage = Get-CippException -Exception $Result.error - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to apply proxy address to $($Result.error.target) Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if ($bulkRequest) { + $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($bulkRequest) + foreach ($Result in $BatchResults) { + if ($Result.error) { + $ErrorMessage = Get-CippException -Exception $Result.error + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to apply proxy address to $($Result.error.target) Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } } } } From adbbb537ca629d05d4d3a2ccfa04d081e0471e5f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 7 May 2026 12:52:44 +0800 Subject: [PATCH 38/68] Update CIPPTimers.json --- Config/CIPPTimers.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index c64985793b36..b9899dec6d4b 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -78,6 +78,7 @@ "Cron": "0 0 */12 * * *", "Priority": 4, "RunOnProcessor": true, + "TZOffset": true, "PreferredProcessor": "standards" }, { @@ -87,6 +88,7 @@ "Cron": "0 15 */12 * * *", "Priority": 5, "RunOnProcessor": true, + "TZOffset": true, "PreferredProcessor": "standards" }, { @@ -120,6 +122,7 @@ "Cron": "0 0 0 * * 0", "Priority": 7, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -137,6 +140,7 @@ "Description": "Orchestrator to process domains", "Cron": "0 30 5 * * *", "Priority": 22, + "TZOffset": true, "RunOnProcessor": true }, { @@ -149,6 +153,7 @@ "Cron": "0 0 23 * * *", "Priority": 10, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -158,6 +163,7 @@ "Cron": "0 0 0 * * *", "Priority": 10, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -166,6 +172,7 @@ "Description": "Timer to process billing", "Cron": "0 0 0 * * *", "Priority": 12, + "TZOffset": true, "RunOnProcessor": true }, { @@ -174,6 +181,7 @@ "Description": "Orchestrator to process BPA reports", "Cron": "0 0 3 * * *", "Priority": 10, + "TZOffset": true, "RunOnProcessor": true }, { @@ -191,6 +199,7 @@ "Cron": "0 0 0 * * *", "Priority": 15, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -200,6 +209,7 @@ "Cron": "0 0 23 * * *", "Priority": 20, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -212,6 +222,7 @@ "Cron": "0 0 0 * * *", "Priority": 20, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -221,6 +232,7 @@ "Cron": "0 0 2 * * *", "Priority": 21, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -230,6 +242,7 @@ "Cron": "0 30 2 * * *", "Priority": 22, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -239,6 +252,7 @@ "Cron": "0 0 3 * * *", "Priority": 23, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -248,6 +262,7 @@ "Cron": "0 0 4 * * *", "Priority": 24, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true } ] From 3beb6226f86e5b05661160a8fcace5d7931e161c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 7 May 2026 19:10:37 +0800 Subject: [PATCH 39/68] Better queue tracking --- .../Graph Requests/Push-ListGraphRequestQueue.ps1 | 9 +++++++++ Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 | 1 + .../Orchestrator Functions/Start-CIPPOrchestrator.ps1 | 8 +++----- .../Public/GraphRequests/Get-GraphRequestList.ps1 | 5 +++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 index 9c838711e42c..fb6c7fe15bcc 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 @@ -63,6 +63,15 @@ function Push-ListGraphRequestQueue { Data = [string]$Json } Add-CIPPAzDataTableEntity @Table -Entity $GraphResults -Force | Out-Null + + if ($env:CIPPNG -eq 'true') { + try { + [Craft.Services.CacheBridge]::InvalidateByScope('AllTenants') + } catch { + Write-Information "CacheBridge invalidation skipped: $($_.Exception.Message)" + } + } + return $true } catch { Write-Warning "Queue Error: $($_.Exception.Message)" diff --git a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 index 6302f05994e8..ae77578f4ae3 100644 --- a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 @@ -21,6 +21,7 @@ function New-CippQueueEntry { } if ($env:CIPPNG -eq 'true') { + [Craft.Services.QueueStatusBridge]::RegisterQueueMetadata($QueueEntry.RowKey, $Name, $Link, $Reference) return $QueueEntry } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 31a13b68a50b..b5509bb4efc4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -42,10 +42,7 @@ function Start-CIPPOrchestrator { if (-not $InputObject.Batch -and $InputObject.QueueFunction) { $QueueFuncName = "Push-$($InputObject.QueueFunction.FunctionName)" Write-Information "Craft: Calling QueueFunction '$QueueFuncName' to build batch for '$OrchestratorName'" - $QueueItem = [PSCustomObject]@{} - if ($InputObject.QueueFunction.Parameters) { - $QueueItem = [PSCustomObject]$InputObject.QueueFunction.Parameters - } + $QueueItem = [PSCustomObject]$InputObject.QueueFunction $BatchResult = & $QueueFuncName -Item $QueueItem $QueueBatch = @($BatchResult | Where-Object { $null -ne $_ }) if ($QueueBatch.Count -eq 0) { @@ -78,7 +75,8 @@ function Start-CIPPOrchestrator { $BatchJson, 4, $PostExecFunctionName, - $PostExecParametersJson + $PostExecParametersJson, + $InputObject.Reference ) return "Craft-$OrchestratorName" } diff --git a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 index ad1db76305bb..134f8aeeeb79 100644 --- a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 +++ b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 @@ -199,7 +199,7 @@ function Get-GraphRequestList { $Type = 'Queue' Write-Information "Cached: $(($Rows | Measure-Object).Count) rows (Type: $($Type))" $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey - $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -ne 'Completed' -and $_.Status -ne 'Failed' } + $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -ne 'Completed' -and $_.Status -ne 'Failed' -and $_.Reference -eq $QueueReference } } elseif (!$SkipCache.IsPresent -and !$ClearCache.IsPresent -and !$CountOnly.IsPresent) { if ($TenantFilter -eq 'AllTenants' -or $Count -gt $SingleTenantThreshold) { $Table = Get-CIPPTable -TableName $TableName @@ -214,7 +214,7 @@ function Get-GraphRequestList { $Type = 'Cache' Write-Information "Table: $TableName | PK: $PartitionKey | Cached: $(($Rows | Measure-Object).Count) rows (Type: $($Type))" $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey - $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } + $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' -and $_.Reference -eq $QueueReference } } } } catch { @@ -294,6 +294,7 @@ function Get-GraphRequestList { $InputObject = @{ OrchestratorName = 'GraphRequestOrchestrator' Batch = @($Batch) + Reference = $QueueReference } #Write-Information ($InputObject | ConvertTo-Json -Depth 5) $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject From b9e193e62a108b2cb56fc72289206f12a89c391a Mon Sep 17 00:00:00 2001 From: Joachim Date: Thu, 7 May 2026 13:27:59 +0200 Subject: [PATCH 40/68] Add usageLocation support to JIT Admin creation and templates (#5910) --- Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 | 5 ++++- .../Administration/Users/Invoke-AddJITAdminTemplate.ps1 | 3 +++ .../Administration/Users/Invoke-EditJITAdminTemplate.ps1 | 3 +++ .../Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 b/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 index 4adcd4831f7e..9dd7a7254dd0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 @@ -10,7 +10,7 @@ function Set-CIPPUserJITAdmin { Tenant to manage for JIT admin .PARAMETER User - User object to manage JIT admin roles, required property UserPrincipalName - If user is being created we also require FirstName and LastName + User object to manage JIT admin roles, required property UserPrincipalName - If user is being created we also require FirstName and LastName. Optional UsageLocation (ISO 3166-1 alpha-2 country code) can be provided for user creation. .PARAMETER Roles List of Role GUIDs to add or remove @@ -82,6 +82,9 @@ function Set-CIPPUserJITAdmin { jitAdminCreatedBy = if ($Headers) { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } else { 'Unknown' } } } + if (-not [string]::IsNullOrWhiteSpace($User.UsageLocation)) { + $Body.usageLocation = $User.UsageLocation + } $Json = ConvertTo-Json -Depth 5 -InputObject $Body try { $NewUser = New-GraphPOSTRequest -type POST -Uri 'https://graph.microsoft.com/beta/users' -Body $Json -tenantid $TenantFilter diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 index cdabf6ab6273..141a2754cf0b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 @@ -112,6 +112,9 @@ function Invoke-AddJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } + if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUsageLocation)) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation + } # defaultDomain is only saved for specific tenant templates (not AllTenants) if ($TenantFilter -ne 'AllTenants' -and $Request.Body.defaultDomain) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 index 2207932c0581..b3282e64647b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 @@ -130,6 +130,9 @@ function Invoke-EditJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } + if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUsageLocation)) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation + } # defaultDomain is only saved for specific tenant templates (not AllTenants) if ($TenantFilter -ne 'AllTenants' -and $Request.Body.defaultDomain) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 index c8a44a162f8b..2f522cc0a66d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 @@ -63,6 +63,7 @@ function Invoke-ExecJITAdmin { 'FirstName' = $Request.Body.FirstName 'LastName' = $Request.Body.LastName 'UserPrincipalName' = $Username + 'UsageLocation' = $Request.Body.usageLocation } Expiration = $Expiration StartDate = $Start From 8e7392da6e5a9bf03c4c5b9ea1c2f0d1a9bc1fa7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 7 May 2026 14:51:47 -0400 Subject: [PATCH 41/68] fix: scripted alert optimization --- .../Get-CIPPAlertIntunePolicyConflicts.ps1 | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index d9b2239b1bcb..071f65cff4e7 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -48,9 +48,10 @@ function Get-CIPPAlertIntunePolicyConflicts { return } - $AlertableStatuses = @() - if ($Config.AlertErrors) { $AlertableStatuses += 'error', 'failed' } - if ($Config.AlertConflicts) { $AlertableStatuses += 'conflict' } + $AlertableStatuses = @( + if ($Config.AlertErrors) { 'error'; 'failed' } + if ($Config.AlertConflicts) { 'conflict' } + ) if (-not $AlertableStatuses) { return @@ -68,7 +69,7 @@ function Get-CIPPAlertIntunePolicyConflicts { return } - $Issues = @() + $Issues = [System.Collections.Generic.List[object]]::new() if ($Config.IncludePolicies) { try { @@ -77,16 +78,16 @@ function Get-CIPPAlertIntunePolicyConflicts { foreach ($Device in $ManagedDevices) { $PolicyStates = $Device.deviceConfigurationStates | Where-Object { $_.state -and ($AlertableStatuses -contains $_.state) } foreach ($State in $PolicyStates) { - $Issues += [PSCustomObject]@{ - Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Policy' - PolicyName = $State.displayName - IssueStatus = $State.state - DeviceName = $Device.deviceName - UserPrincipalName = $Device.userPrincipalName - DeviceId = $Device.id - } + $Issues.Add([PSCustomObject]@{ + Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Policy' + PolicyName = $State.displayName + IssueStatus = $State.state + DeviceName = $Device.deviceName + UserPrincipalName = $Device.userPrincipalName + DeviceId = $Device.id + }) } } } catch { @@ -105,16 +106,16 @@ function Get-CIPPAlertIntunePolicyConflicts { } foreach ($Status in $BadStatuses) { - $Issues += [PSCustomObject]@{ - Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Application' - AppName = $App.displayName - IssueStatus = $Status.installState - DeviceName = $Status.deviceName - UserPrincipalName = $Status.userPrincipalName - DeviceId = $Status.deviceId - } + $Issues.Add([PSCustomObject]@{ + Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Application' + AppName = $App.displayName + IssueStatus = $Status.installState + DeviceName = $Status.deviceName + UserPrincipalName = $Status.userPrincipalName + DeviceId = $Status.deviceId + }) } } } catch { @@ -132,11 +133,11 @@ function Get-CIPPAlertIntunePolicyConflicts { $AppCount = ($Issues | Where-Object { $_.Type -eq 'Application' }).Count $AlertData = @([PSCustomObject]@{ - Message = "Found $PolicyCount policy issues and $AppCount application issues in Intune." - Tenant = $TenantFilter - PolicyIssues = $PolicyCount - AppIssues = $AppCount - Issues = $Issues + Message = "Found $PolicyCount policy issues and $AppCount application issues in Intune." + Tenant = $TenantFilter + PolicyIssues = $PolicyCount + AppIssues = $AppCount + Issues = $Issues }) } else { $AlertData = $Issues From 731a41edf5e1c80bb5d1b1bfacd095022fd746bf Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 7 May 2026 15:33:22 -0400 Subject: [PATCH 42/68] fix: ensure unique and non-null email addresses in report generation --- .../Public/Standards/Invoke-CIPPStandardMailContacts.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 index a8036b8ea184..0b27c0a250b3 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 @@ -106,13 +106,13 @@ function Invoke-CIPPStandardMailContacts { } if ($Settings.report -eq $true) { $CurrentValue = @{ - marketingNotificationEmails = @($CurrentInfo.marketingNotificationEmails | Sort-Object) - technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Sort-Object) + marketingNotificationEmails = @($CurrentInfo.marketingNotificationEmails | Sort-Object -Unique) + technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $CurrentInfo.privacyProfile.contactEmail } $ExpectedValue = @{ - marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object) - technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { $_ -ne $null } | Select-Object -Unique | Sort-Object) + marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object -Unique) + technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $Contacts.GeneralContact } Set-CIPPStandardsCompareField -FieldName 'standards.MailContacts' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant From bc4e643dbd7a65030681b89e758733fb1b89dad6 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 00:19:26 +0200 Subject: [PATCH 43/68] add purview section --- .../Invoke-AddDlpCompliancePolicy.ps1 | 48 ++++++++++++++++++ .../Invoke-AddDlpCompliancePolicyTemplate.ps1 | 49 +++++++++++++++++++ .../Invoke-EditDlpCompliancePolicy.ps1 | 48 ++++++++++++++++++ .../Invoke-ListDlpCompliancePolicy.ps1 | 30 ++++++++++++ ...nvoke-ListDlpCompliancePolicyTemplates.ps1 | 26 ++++++++++ .../Invoke-RemoveDlpCompliancePolicy.ps1 | 36 ++++++++++++++ ...voke-RemoveDlpCompliancePolicyTemplate.ps1 | 36 ++++++++++++++ .../Invoke-AddSensitiveInfoType.ps1 | 47 ++++++++++++++++++ .../Invoke-AddSensitiveInfoTypeTemplate.ps1 | 48 ++++++++++++++++++ .../Invoke-EditSensitiveInfoType.ps1 | 41 ++++++++++++++++ .../Invoke-ListSensitiveInfoType.ps1 | 33 +++++++++++++ .../Invoke-ListSensitiveInfoTypeTemplates.ps1 | 26 ++++++++++ .../Invoke-RemoveSensitiveInfoType.ps1 | 36 ++++++++++++++ ...Invoke-RemoveSensitiveInfoTypeTemplate.ps1 | 36 ++++++++++++++ .../Invoke-AddSensitivityLabel.ps1 | 49 +++++++++++++++++++ .../Invoke-AddSensitivityLabelTemplate.ps1 | 48 ++++++++++++++++++ .../Invoke-EditSensitivityLabel.ps1 | 41 ++++++++++++++++ .../Invoke-ListSensitivityLabel.ps1 | 35 +++++++++++++ .../Invoke-ListSensitivityLabelTemplates.ps1 | 26 ++++++++++ .../Invoke-RemoveSensitivityLabel.ps1 | 36 ++++++++++++++ .../Invoke-RemoveSensitivityLabelTemplate.ps1 | 36 ++++++++++++++ 21 files changed, 811 insertions(+) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..c251373790d7 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, RuleParams + $RuleParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).RuleParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $PolicyParams = $RequestParams | Select-Object -Property * -ExcludeProperty RuleParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpCompliancePolicy' -cmdParams $PolicyParams -Compliance -useSystemMailbox $true + + if ($RuleParams) { + # Ensure rule references the new policy + $RuleHash = @{} + $RuleParams.PSObject.Properties | ForEach-Object { $RuleHash[$_.Name] = $_.Value } + $RuleHash['Policy'] = $RequestParams.Name + if (-not $RuleHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($RuleHash['Name'])) { + $RuleHash['Name'] = "$($RequestParams.Name) Rule" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpComplianceRule' -cmdParams $RuleHash -Compliance -useSystemMailbox $true + } + + "Successfully created DLP compliance policy $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created DLP compliance policy $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create DLP compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create DLP compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..911c7817935f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 @@ -0,0 +1,49 @@ +Function Invoke-AddDlpCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Comment, Mode, Workload, Enabled, ExchangeLocation, ExchangeSenderMemberOf, ExchangeSenderMemberOfException, SharePointLocation, SharePointLocationException, OneDriveLocation, OneDriveLocationException, TeamsLocation, TeamsLocationException, EndpointDlpLocation, EndpointDlpLocationException, OnPremisesScannerDlpLocation, OnPremisesScannerDlpLocationException, ThirdPartyAppDlpLocation, ThirdPartyAppDlpLocationException, PowerBIDlpLocation, PowerBIDlpLocationException, RuleParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + # Allow Name to be sourced from displayName/name fields and ensure templated comments preserved + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'DlpCompliancePolicyTemplate' + } + $Result = "Successfully created DLP Compliance Policy Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create DLP Compliance Policy Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..c0e8daee0627 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 @@ -0,0 +1,48 @@ +Function Invoke-EditDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $State = $Request.Query.State ?? $Request.Body.State + + try { + $Params = @{ + Identity = $Identity + } + + # If a state was passed, toggle Enabled on the policy + if ($State) { + $Params['Enabled'] = ($State -eq 'enable' -or $State -eq $true -or $State -eq 'true') + } + + # Allow passing arbitrary additional params via Body.parameters + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpCompliancePolicy' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated DLP compliance policy $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating DLP compliance policy $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..26fcd8eafb9e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 @@ -0,0 +1,30 @@ +Function Invoke-ListDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $Rules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $GraphRequest = $Policies | Select-Object *, + @{l = 'AssociatedRules'; e = { $name = $_.Name; @($Rules | Where-Object { $_.ParentPolicyName -eq $name }) } }, + @{l = 'RuleCount'; e = { $name = $_.Name; (@($Rules | Where-Object { $_.ParentPolicyName -eq $name })).Count } } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 new file mode 100644 index 000000000000..a958e9acd2ee --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListDlpCompliancePolicyTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.DlpCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'DlpCompliancePolicyTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..4af00a520304 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpCompliancePolicy' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted DLP compliance policy $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete DLP compliance policy $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..9504141526e5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveDlpCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'DlpCompliancePolicyTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed DLP Compliance Policy template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove DLP Compliance Policy template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 new file mode 100644 index 000000000000..b8d530757c05 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 @@ -0,0 +1,47 @@ +Function Invoke-AddSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + # New-DlpSensitiveInformationType expects FileData (byte array of XML rule pack) or specific simple parameters. + # We pass through whatever the user provided as JSON parameters. + $Params = @{} + $RequestParams.PSObject.Properties | ForEach-Object { + $Params[$_.Name] = $_.Value + } + + # If the template provided XML rule pack content as base64, decode it for FileData + if ($Params.ContainsKey('FileDataBase64') -and $Params['FileDataBase64']) { + $Params['FileData'] = [System.Convert]::FromBase64String($Params['FileDataBase64']) + $Params.Remove('FileDataBase64') + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + "Successfully created Sensitive Information Type $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Sensitive Information Type $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create Sensitive Information Type for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create Sensitive Information Type for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 new file mode 100644 index 000000000000..d4f18b501fb0 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddSensitiveInfoTypeTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Description, Pattern, FileDataBase64, Locale, Publisher, Confidence, Recommended, RulePackVersion, Comment) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'SensitiveInfoTypeTemplate' + } + $Result = "Successfully created Sensitive Information Type Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Sensitive Information Type Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 new file mode 100644 index 000000000000..213ac25966fa --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 @@ -0,0 +1,41 @@ +Function Invoke-EditSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated Sensitive Information Type $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating Sensitive Information Type $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 new file mode 100644 index 000000000000..5ae4a340fe49 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 @@ -0,0 +1,33 @@ +Function Invoke-ListSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + $IncludeBuiltIn = ($Request.Query.IncludeBuiltIn -eq 'true' -or $Request.Query.IncludeBuiltIn -eq $true) + + try { + $SITs = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + + if (-not $IncludeBuiltIn) { + $SITs = $SITs | Where-Object { $_.Publisher -ne 'Microsoft Corporation' -and $_.Publisher -notlike 'Microsoft*' } + } + + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $SITs + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 new file mode 100644 index 000000000000..fa21108ce653 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListSensitiveInfoTypeTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.SensitiveInfoType.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'SensitiveInfoTypeTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 new file mode 100644 index 000000000000..3fa5502ee294 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted Sensitive Information Type $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete Sensitive Information Type $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 new file mode 100644 index 000000000000..23c88a9572a6 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitiveInfoTypeTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'SensitiveInfoTypeTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Sensitive Information Type template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Sensitive Information Type template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 new file mode 100644 index 000000000000..9f47b293933f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 @@ -0,0 +1,49 @@ +Function Invoke-AddSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, PolicyParams + $PolicyParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).PolicyParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $LabelParams = $RequestParams | Select-Object -Property * -ExcludeProperty PolicyParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-Label' -cmdParams $LabelParams -Compliance -useSystemMailbox $true + + if ($PolicyParams) { + $PolicyHash = @{} + $PolicyParams.PSObject.Properties | ForEach-Object { $PolicyHash[$_.Name] = $_.Value } + if (-not $PolicyHash.ContainsKey('Labels') -or -not $PolicyHash['Labels']) { + $PolicyHash['Labels'] = @($RequestParams.Name) + } + if (-not $PolicyHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($PolicyHash['Name'])) { + $PolicyHash['Name'] = "$($RequestParams.Name) Policy" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-LabelPolicy' -cmdParams $PolicyHash -Compliance -useSystemMailbox $true + } + + "Successfully created sensitivity label $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created sensitivity label $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create sensitivity label for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create sensitivity label for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 new file mode 100644 index 000000000000..045a31fa3d80 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddSensitivityLabelTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, DisplayName, Comment, Tooltip, ParentId, ContentType, EncryptionEnabled, EncryptionProtectionType, EncryptionRightsDefinitions, EncryptionContentExpiredOnDateInDaysOrNever, EncryptionDoNotForward, EncryptionEncryptOnly, EncryptionOfflineAccessDays, EncryptionPromptUser, EncryptionAESKeySize, ContentMarkingHeaderEnabled, ContentMarkingHeaderText, ContentMarkingHeaderFontSize, ContentMarkingHeaderFontColor, ContentMarkingHeaderAlignment, ContentMarkingFooterEnabled, ContentMarkingFooterText, ContentMarkingFooterFontSize, ContentMarkingFooterFontColor, ContentMarkingFooterAlignment, ContentMarkingWatermarkEnabled, ContentMarkingWatermarkText, ContentMarkingWatermarkFontSize, ContentMarkingWatermarkFontColor, ContentMarkingWatermarkLayout, ApplyContentMarkingHeaderEnabled, ApplyContentMarkingFooterEnabled, ApplyWaterMarkingEnabled, SiteAndGroupProtectionEnabled, SiteAndGroupProtectionPrivacy, SiteAndGroupProtectionAllowAccessToGuestUsers, SiteAndGroupProtectionAllowEmailFromGuestUsers, SiteAndGroupProtectionAllowFullAccess, SiteAndGroupProtectionAllowLimitedAccess, SiteAndGroupProtectionBlockAccess, Conditions, AdvancedSettings, PolicyParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'SensitivityLabelTemplate' + } + $Result = "Successfully created Sensitivity Label Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Sensitivity Label Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 new file mode 100644 index 000000000000..acad97a18693 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 @@ -0,0 +1,41 @@ +Function Invoke-EditSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated sensitivity label $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating sensitivity label $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 new file mode 100644 index 000000000000..4c6947962223 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 @@ -0,0 +1,35 @@ +Function Invoke-ListSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Labels = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Label' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-LabelPolicy' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + + $GraphRequest = $Labels | Select-Object *, + @{l = 'PublishedInPolicies'; e = { + $labelGuid = $_.Guid + @($Policies | Where-Object { $_.Labels -contains $labelGuid -or $_.Labels -contains $_.ImmutableId }) | Select-Object -ExpandProperty Name + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 new file mode 100644 index 000000000000..ab066a4eb6d5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListSensitivityLabelTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.SensitivityLabel.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'SensitivityLabelTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 new file mode 100644 index 000000000000..225351276dc5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-Label' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted sensitivity label $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete sensitivity label $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 new file mode 100644 index 000000000000..c8084265db52 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitivityLabelTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'SensitivityLabelTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Sensitivity Label template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Sensitivity Label template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} From c0098fdea73e4ff9a6d5e6be89df0c7a8a18185e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 00:46:46 +0200 Subject: [PATCH 44/68] remove old file --- .../Public/Tests/CISA-Missing-Caches.md | 195 ------------------ 1 file changed, 195 deletions(-) delete mode 100644 Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md diff --git a/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md b/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md deleted file mode 100644 index 5b7bd652195a..000000000000 --- a/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md +++ /dev/null @@ -1,195 +0,0 @@ -# Missing CIPP Caches for CISA Tests - -This document lists the caches that need to be created to support the remaining CISA tests that cannot currently be implemented. - -## ✅ Implemented Cache Functions - -### 1. ✅ CASMailbox Cache -**Required For:** -- ✅ MS.EXO.5.1 - SMTP Authentication - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheCASMailbox.ps1 - ---- - -### 2. ✅ ExoSharingPolicy Cache -**Required For:** -- ✅ MS.EXO.6.1 - Contact Sharing -- ✅ MS.EXO.6.2 - Calendar Sharing - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoSharingPolicy.ps1 - ---- - -### 3. ✅ ExoAdminAuditLogConfig Cache -**Required For:** -- ✅ MS.EXO.17.1 - Audit Log -- ✅ MS.EXO.17.3 - Audit Log Retention - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 - ---- - -### 4. ✅ ExoPresetSecurityPolicy Cache -**Required For:** -- ✅ MS.EXO.11.1 - Impersonation -- ✅ MS.EXO.11.2 - Impersonation Tips -- ✅ MS.EXO.11.3 - Mailbox Intelligence - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 - ---- - -### 5. ✅ ExoTenantAllowBlockList Cache -**Required For:** -- ✅ MS.EXO.12.1 - Anti-Spam Allow List - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoTenantAllowBlockList.ps1 - ---- - -## Required New Cache Functions -**Required For:** -- MS.EXO.8.1 - DLP Solution -- MS.EXO.8.2 - DLP PII -- MS.EXO.8.4 - DLP Baseline Rules - -**SecurityCompliance Command:** -```powershell -Get-DlpCompliancePolicy | Select-Object Name, Enabled, Mode -Get-DlpComplianceRule | Select-Object Name, ParentPolicyName, ContentContainsSensitiveInformation, Disabled -``` -1 -**Cache Function Names:** -- `Set-CIPPDBCacheSccDlpPolicy` -- `Set-CIPPDBCacheSccDlpRule` - -**Properties Needed:** -- Policy: Name, Enabled, Mode -- Rule: Name, ParentPolicyName, ContentContainsSensitiveInformation, Disabled - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -### 2. SecurityCompliance ProtectionAlert Cache -**Required For:** -- MS.EXO.16.1 - Alerts - -**SecurityCompliance Command:** -```powershell -Get-ProtectionAlert | Select-Object Name, Disabled -``` - -**Cache Function Name:** `Set-CIPPDBCacheSccProtectionAlert` - -**Properties Needed:** -- Name -- Disabled - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -### 3. SecurityCompliance ActivityAlert Cache -**Required For:** -- MS.EXO.16.2 - Alert SIEM - -**SecurityCompliance Command:** -```powershell -Get-ActivityAlert | Select-Object Name, Disabled, NotificationEnabled, Type -``` - -**Cache Function Name:** `Set-CIPPDBCacheSccActivityAlert` - -**Properties Needed:** -- Name -- Disabled -- NotificationEnabled -- Type - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -## DNS-Based Tests (Cannot Be Cached) - -These tests require external DNS lookups and cannot be implemented with cached Exchange data: - -### MS.EXO.2.1 - SPF Restriction -**Requires:** DNS TXT record lookup for SPF -**Query:** `nslookup -type=txt ` - -### MS.EXO.2.2 - SPF Directive -**Requires:** DNS TXT record parsing for SPF policy -**Query:** Parse SPF record for `~all` or `-all` - -### MS.EXO.4.1 - DMARC Record Exists -**Requires:** DNS TXT record lookup for DMARC -**Query:** `nslookup -type=txt _dmarc.` - -### MS.EXO.4.2 - DMARC Reject Policy -**Requires:** DNS TXT record parsing for DMARC policy -**Query:** Parse DMARC record for `p=reject` or `p=quarantine` - -### MS.EXO.4.3 - DMARC Aggregate Reports -**Requires:** DNS TXT record parsing for DMARC rua tags -**Query:** Parse DMARC record for `rua=` email addresses - -### MS.EXO.4.4 - DMARC Reports -**Requires:** DNS TXT record parsing for DMARC report configuration -**Query:** Parse DMARC record for report targets - -### MS.EXO.7.1 - External Sender Warning -**Requires:** ExoOrganizationConfig.ExternalInOutlook property -**Note:** May already be in ExoOrganizationConfig cache - needs verification - -### MS.EXO.13.1 - Mailbox Auditing -**Requires:** ExoOrganizationConfig.AuditDisabled property -**Note:** May already be in ExoOrganizationConfig cache - needs verification - -## Manual Assessment Tests (Cannot Be Automated) - -### MS.EXO.8.3 - DLP Alternate Solution -**Reason:** Requires manual assessment of 3rd party DLP solutions - -### MS.EXO.9.4 - Email Filter Alternative -**Reason:** Requires manual assessment of 3rd party email filtering solutions - -### MS.EXO.14.4 - Spam Alternative Solution -**Reason:** Requires manual assessment of 3rd party anti-spam solutions - -### MS.EXO.17.2 - Audit Log Premium -**Reason:** Requires license validation and advanced audit policy checks beyond cached data - ---- - -## Implementation Priority - -### High Priority (Core Security Controls): -1. CASMailbox - SMTP Auth control -2. ExoAdminAuditLogConfig - Audit logging -3. ExoTenantAllowBlockList - Allow list bypass prevention - -### Medium Priority (DLP and Alerts): -4. SecurityCompliance DLP caches - Data loss prevention -5. SecurityCompliance Alert caches - Security monitoring - -### Low Priority (Advanced Features): -6. ExoSharingPolicy - External sharing controls -7. ExoPresetSecurityPolicy - Preset security policies - ---- - -## Notes on Implementation - -1. **Graph API Alternative**: Some Exchange Online cmdlets may have equivalent Graph API endpoints that could be used instead. - -## Summary - -- **New Caches Required**: 8 cache functions -- **DNS Tests**: 6 tests (architectural limitation) -- **Manual Tests**: 4 tests (cannot be automated) -- **Implementable After New Caches**: 15 additional tests -- **Current Implementation**: 13 tests -- **Total Possible with New Caches**: 28 tests (68% coverage) From 26cb9aee55dcb43b4f5ca97e3f4806f6c7b7662f Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 8 May 2026 00:50:48 +0200 Subject: [PATCH 45/68] feat: Add AutoDiscover check to domain analysis - Implemented AutoDiscover record validation in Push-DomainAnalyserDomain function. - Enhanced Invoke-ListDomainHealth to support AutoDiscover record retrieval. --- .../Push-DomainAnalyserDomain.ps1 | 21 +++++++++++++++++++ .../Standards/Invoke-ListDomainHealth.ps1 | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 index 2a4357ec7d98..508da7a7e02e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 @@ -62,6 +62,7 @@ function Push-DomainAnalyserDomain { MSCNAMEDKIMSelectors = '' EnterpriseEnrollment = '' EnterpriseRegistration = '' + AutoDiscover = '' Score = '' MaximumScore = 160 ScorePercentage = '' @@ -293,6 +294,26 @@ function Push-DomainAnalyserDomain { } #EndRegion Intune Enrollment CNAME Check + #Region AutoDiscover Check + try { + $AutoDiscoverRecord = Read-AutoDiscoverRecord -Domain $Domain + $AutoDiscoverFailCount = $AutoDiscoverRecord.ValidationFails | Measure-Object | Select-Object -ExpandProperty Count + $AutoDiscoverWarnCount = $AutoDiscoverRecord.ValidationWarns | Measure-Object | Select-Object -ExpandProperty Count + if ($AutoDiscoverFailCount -eq 0 -and $AutoDiscoverWarnCount -eq 0) { + $Result.AutoDiscover = 'Correct' + } elseif ($AutoDiscoverFailCount -eq 0) { + $Result.AutoDiscover = "$($AutoDiscoverRecord.RecordType): $($AutoDiscoverRecord.Record)" + $ScoreExplanation.Add("AutoDiscover $($AutoDiscoverRecord.RecordType) record points to unexpected target") | Out-Null + } else { + $Result.AutoDiscover = 'No Record' + $ScoreExplanation.Add('No AutoDiscover DNS record found') | Out-Null + } + } catch { + $Result.AutoDiscover = 'Error' + Write-LogMessage -API 'DomainAnalyser' -tenant $DomainObject.TenantId -message "AutoDiscover check error for $Domain" -LogData (Get-CippException -Exception $_) -sev Error + } + #EndRegion AutoDiscover Check + #Region MSCNAME DKIM Records # Get Microsoft DKIM CNAME selector Records # Ugly, but i needed to create a scope/loop i could break out of without breaking the rest of the function diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 index 8a87edb67943..2ae766dda392 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 @@ -133,6 +133,15 @@ function Invoke-ListDomainHealth { } $Body = Test-MtaSts @HttpsQuery } + 'ReadAutoDiscover' { + $AutoDiscoverQuery = @{ + Domain = $Request.Query.Domain + } + if ($Request.Query.ExpectedTarget) { + $AutoDiscoverQuery.ExpectedTarget = $Request.Query.ExpectedTarget + } + $Body = Read-AutoDiscoverRecord @AutoDiscoverQuery + } } } else { $body = [pscustomobject]@{'Results' = "Domain: $($Request.Query.Domain) is invalid" } From 94158f4fb0fa4a03a936d2d3726dd27b2e67c06e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 8 May 2026 09:12:28 +0800 Subject: [PATCH 46/68] Add Investigate status to custom tests --- .../Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 | 1 + .../Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 index 981a6b02a64e..4618714eed85 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 @@ -212,6 +212,7 @@ function Invoke-ListTests { Custom = @{ Passed = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Passed' }).Count Failed = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Failed' }).Count + NeedsAttention = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Investigate' }).Count Skipped = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Skipped' }).Count Informational = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Informational' }).Count Total = $CustomTotal diff --git a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 index 75e65e8dc70c..edd5477a2d1e 100644 --- a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 +++ b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 @@ -68,7 +68,7 @@ function Invoke-CippTestCustomScripts { # Auto-detected status from output, then apply explicit override if present $AutoStatus = if ($FailedRows.Count -gt 0) { 'Failed' } else { 'Passed' } - $ValidExplicitStatuses = @('Passed', 'Failed', 'Info') + $ValidExplicitStatuses = @('Passed', 'Failed', 'Info', 'Investigate') if ($ExplicitStatus -and $ExplicitStatus -in $ValidExplicitStatuses) { $AutoStatus = $ExplicitStatus } @@ -76,6 +76,7 @@ function Invoke-CippTestCustomScripts { $FinalStatus = switch ($ResultMode) { 'AlwaysPass' { 'Passed' } 'AlwaysInfo' { 'Info' } + 'AlwaysInvestigate' { 'Investigate' } default { $AutoStatus } } @@ -90,6 +91,7 @@ function Invoke-CippTestCustomScripts { $FinalStatus = switch ($ResultMode) { 'AlwaysPass' { 'Passed' } 'AlwaysInfo' { 'Info' } + 'AlwaysInvestigate' { 'Investigate' } default { 'Failed' } } Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultMarkdown "Custom script execution failed: $($ErrorMessage.NormalizedError)" -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' From f20c60a66bda10222492ab65cef58a258c4c7a2e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 8 May 2026 10:44:19 +0800 Subject: [PATCH 47/68] Revert escaping --- Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index 221346f1bd04..deb66894da52 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -280,17 +280,9 @@ function New-CIPPAlertTemplate { } if ($Format -eq 'html') { - # Escape curly braces in content variables so the -f format operator - # does not interpret data values (e.g. JSON in drift/standards) as placeholders - $FmtTitle = [string]$Title -replace '\{', '{{' -replace '\}', '}}' - $FmtIntroText = [string]$IntroText -replace '\{', '{{' -replace '\}', '}}' - $FmtButtonUrl = [string]$ButtonUrl -replace '\{', '{{' -replace '\}', '}}' - $FmtButtonText = [string]$ButtonText -replace '\{', '{{' -replace '\}', '}}' - $FmtAfterButtonText = [string]$AfterButtonText -replace '\{', '{{' -replace '\}', '}}' - $FmtAuditLogLink = [string]$AuditLogLink -replace '\{', '{{' -replace '\}', '}}' - return [pscustomobject]@{ + return [pscustomobject]@{ title = $Title - htmlcontent = $HTMLTemplate -f $FmtTitle, $FmtIntroText, $FmtButtonUrl, $FmtButtonText, $FmtAfterButtonText, $FmtAuditLogLink + htmlcontent = $HTMLTemplate -f $Title, $IntroText, $ButtonUrl, $ButtonText, $AfterButtonText, $AuditLogLink } } elseif ($Format -eq 'json') { if ($InputObject -eq 'auditlog') { From 318d82612204d4ecac1a328b073ebdda24b55c83 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 7 May 2026 23:15:02 -0400 Subject: [PATCH 48/68] fix: correct assignment syntax for FieldValue in Add-CIPPBPAField function --- Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 b/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 index bed52e8cc786..74c1110550a6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 @@ -34,7 +34,7 @@ function Add-CIPPBPAField { $Result[$fieldName] = [string]$JsonString } 'string' { - $Result[$fieldName], [string]$FieldValue + $Result[$fieldName] = [string]$FieldValue } } Add-CIPPAzDataTableEntity @Table -Entity $Result -Force From 51b22818735ef58a5f958be1e3a9e91715be93e0 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 00:08:13 -0400 Subject: [PATCH 49/68] fix: add SharingCapability to current state retrieval in Invoke-CIPPStandardSPFileRequests function --- .../Invoke-CIPPStandardSPFileRequests.ps1 | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 5c3169485453..11cba32e784f 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -48,8 +48,15 @@ function Invoke-CIPPStandardSPFileRequests { return $true } + $SharingCapabilityEnum = @{ + 0L = 'Disabled' + 1L = 'External Users Only' + 2L = 'External Users and Guests (Anyone)' + 3L = 'Existing External Users Only' + } + try { - $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object _ObjectIdentity_, TenantFilter, CoreRequestFilesLinkEnabled, OneDriveRequestFilesLinkEnabled, CoreRequestFilesLinkExpirationInDays, OneDriveRequestFilesLinkExpirationInDays + $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object _ObjectIdentity_, TenantFilter, CoreRequestFilesLinkEnabled, OneDriveRequestFilesLinkEnabled, CoreRequestFilesLinkExpirationInDays, OneDriveRequestFilesLinkExpirationInDays, SharingCapability } catch { Write-LogMessage -API 'Standards' -tenant $tenant -message 'Failed to get current state of SPO tenant details' -sev Error return @@ -61,8 +68,8 @@ function Invoke-CIPPStandardSPFileRequests { return } - $WantedState = $Settings.state - $ExpirationDays = $Settings.expirationDays + $WantedState = [bool]$Settings.state + $ExpirationDays = if ($null -ne $Settings.expirationDays) { [int]$Settings.expirationDays } else { $null } $HumanReadableState = if ($WantedState -eq $true) { 'enabled' } else { 'disabled' } # Check if current state matches desired state @@ -83,34 +90,39 @@ function Invoke-CIPPStandardSPFileRequests { if ($Settings.remediate -eq $true) { if ($AllSettingsCorrect -eq $false) { - try { - $Properties = @{ - CoreRequestFilesLinkEnabled = $WantedState - OneDriveRequestFilesLinkEnabled = $WantedState - } - - # Add expiration settings if specified and feature is being enabled - if ($null -ne $ExpirationDays -and $WantedState -eq $true) { - $Properties['CoreRequestFilesLinkExpirationInDays'] = $ExpirationDays - $Properties['OneDriveRequestFilesLinkExpirationInDays'] = $ExpirationDays + if ($CurrentState.SharingCapability -ne 2) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot set File Requests to $HumanReadableState because the Tenant sharing level is set to $($SharingCapabilityEnum[$CurrentState.SharingCapability]). The sharing level must be set to 'External Users and Guests (Anyone)' to remediate this standard. There may be a conflicting standard preventing this." -sev Error + } else { + try { + $Properties = @{ + CoreRequestFilesLinkEnabled = $WantedState + OneDriveRequestFilesLinkEnabled = $WantedState + } + + # Add expiration settings if specified and feature is being enabled + if ($null -ne $ExpirationDays -and $WantedState -eq $true) { + $Properties['CoreRequestFilesLinkExpirationInDays'] = $ExpirationDays + $Properties['OneDriveRequestFilesLinkExpirationInDays'] = $ExpirationDays + } + + $CurrentState | Set-CIPPSPOTenant -Properties $Properties + + # Reflect the just-applied state in-memory so the report block does not write + # the pre-remediation values into the drift compare field. + $CurrentState.CoreRequestFilesLinkEnabled = $WantedState + $CurrentState.OneDriveRequestFilesLinkEnabled = $WantedState + if ($null -ne $ExpirationDays -and $WantedState -eq $true) { + $CurrentState.CoreRequestFilesLinkExpirationInDays = $ExpirationDays + $CurrentState.OneDriveRequestFilesLinkExpirationInDays = $ExpirationDays + } + + $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } + Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set File Requests to $HumanReadableState$ExpirationMessage" -sev Info + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set File Requests to $HumanReadableState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } - - $CurrentState | Set-CIPPSPOTenant -Properties $Properties - - # Reflect the just-applied state in-memory so the report block does not write - # the pre-remediation values into the drift compare field. - $CurrentState.CoreRequestFilesLinkEnabled = $WantedState - $CurrentState.OneDriveRequestFilesLinkEnabled = $WantedState - if ($null -ne $ExpirationDays -and $WantedState -eq $true) { - $CurrentState.CoreRequestFilesLinkExpirationInDays = $ExpirationDays - $CurrentState.OneDriveRequestFilesLinkExpirationInDays = $ExpirationDays - } - - $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } - Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set File Requests to $HumanReadableState$ExpirationMessage" -sev Info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set File Requests to $HumanReadableState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } else { $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } @@ -147,12 +159,14 @@ function Invoke-CIPPStandardSPFileRequests { OneDriveRequestFilesLinkEnabled = $CurrentState.OneDriveRequestFilesLinkEnabled CoreRequestFilesLinkExpirationInDays = $CurrentState.CoreRequestFilesLinkExpirationInDays OneDriveRequestFilesLinkExpirationInDays = $CurrentState.OneDriveRequestFilesLinkExpirationInDays + SharingCapability = $SharingCapabilityEnum[$CurrentState.SharingCapability] } $ExpectedValue = @{ CoreRequestFilesLinkEnabled = $WantedState OneDriveRequestFilesLinkEnabled = $WantedState CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } + SharingCapability = 'External Users and Guests (Anyone)' } Set-CIPPStandardsCompareField -FieldName 'standards.SPFileRequests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From 7804aaf8d9bb24ceec54af7affdabf5702a3c660 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 8 May 2026 14:02:27 +0800 Subject: [PATCH 50/68] Try infer template type from content if missing, else fail early --- .../Public/Tools/Import-CommunityTemplate.ps1 | 19 ++++++++++++++-- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 603df66a4c96..56d3c5843e44 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -68,7 +68,7 @@ function Import-CommunityTemplate { $Template | Add-Member -MemberType NoteProperty -Name SHA -Value $SHA -Force $Template | Add-Member -MemberType NoteProperty -Name Source -Value $Source -Force Add-CIPPAzDataTableEntity @Table -Entity $Template -Force - + if ($Existing -and $Existing.SHA -ne $SHA) { $StatusMessage = "Updated template '$($Template.RowKey)' from source '$Source' (SHA changed)." } elseif ($Existing) { @@ -217,6 +217,21 @@ function Import-CommunityTemplate { '*managedAppPolicies*' { 'AppProtection' } '*deviceAppManagement*' { 'AppProtection' } } + + # Fallback: infer type from template content when @odata.id is missing or unrecognized + if (-not $URLName) { + $odataType = $Template.'@odata.type' + $URLName = if ($null -ne $Template.settings -and $null -ne $Template.technologies) { 'Catalog' } + elseif ($null -ne $Template.scheduledActionsForRule -or $odataType -match 'CompliancePolicy') { 'DeviceCompliancePolicies' } + elseif ($odataType -match 'windowsDriverUpdateProfile') { 'windowsDriverUpdateProfiles' } + elseif ($odataType -match 'ManagedApp|managedAppProtection') { 'AppProtection' } + elseif ($odataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { 'Device' } + else { $null } + if ($URLName) { + Write-Information "Inferred Intune template type '$URLName' from content structure for '$($Template.displayName ?? $Template.Name)'" + } + } + $RawJson = $Template | Select-Object * -ExcludeProperty id, lastModifiedDateTime, 'assignments', '#microsoft*', '*@odata.navigationLink', '*@odata.associationLink', '*@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime', '@odata.id', '@odata.editLink', 'lastModifiedDateTime@odata.type', 'roleScopeTagIds@odata.type', createdDateTime, 'createdDateTime@odata.type' Remove-ODataProperties -Object $RawJson $RawJson = $RawJson | ConvertTo-Json -Depth 100 -Compress @@ -288,6 +303,6 @@ function Import-CommunityTemplate { Write-Warning $StatusMessage Write-Information $_.InvocationInfo.PositionMessage } - + return $StatusMessage } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index bb1cf5dd4ca6..7af9715f79db 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -75,6 +75,28 @@ function Invoke-CIPPStandardIntuneTemplate { $RawJSON = $rawJsonFromTemplate $TemplateType = $Template.Type + # Fallback: infer type from RAWJson content when stored template has no Type + if (-not $TemplateType) { + try { + $parsedRaw = $rawJsonFromTemplate | ConvertFrom-Json -ErrorAction SilentlyContinue + $odataType = $parsedRaw.'@odata.type' + $TemplateType = if ($null -ne $parsedRaw.settings -and $null -ne $parsedRaw.technologies) { 'Catalog' } + elseif ($null -ne $parsedRaw.scheduledActionsForRule -or $odataType -match 'CompliancePolicy') { 'deviceCompliancePolicies' } + elseif ($odataType -match 'windowsDriverUpdateProfile') { 'windowsDriverUpdateProfiles' } + elseif ($odataType -match 'ManagedApp|managedAppProtection') { 'AppProtection' } + elseif ($odataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { 'Device' } + else { $null } + } catch { + $TemplateType = $null + } + if ($TemplateType) { + Write-Information "[IntuneTemplate][$Tenant] Inferred template type '$TemplateType' from content for '$displayname'" + } else { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Intune Template '$displayname' has no Type and type could not be inferred. Re-import the template to fix." -sev 'Error' + return $true + } + } + $AssignmentsMatch = $null try { $ExistingPolicy = Get-CIPPIntunePolicy -tenantFilter $Tenant -DisplayName $displayname -TemplateType $TemplateType From 8d454b9566497fb2577c76d50c8ee4a75562e5e1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 12:01:35 +0200 Subject: [PATCH 51/68] fixed unmapped issue sherweb --- .../HTTP Functions/Invoke-ListCSPsku.ps1 | 13 +++++++++---- .../Sherweb/Get-SherwebCurrentSubscription.ps1 | 4 ++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 index 094f651314e2..b317057f1870 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 @@ -19,11 +19,16 @@ function Invoke-ListCSPsku { } $StatusCode = [HttpStatusCode]::OK } catch { - $GraphRequest = [PSCustomObject]@{ - name = @(@{value = 'Error getting catalog' }) - sku = $_.Exception.Message + if ($_.Exception.Message -eq 'No Sherweb mapping found') { + $GraphRequest = @() + $StatusCode = [HttpStatusCode]::OK + } else { + $GraphRequest = [PSCustomObject]@{ + name = @(@{value = 'Error getting catalog' }) + sku = $_.Exception.Message + } + $StatusCode = [HttpStatusCode]::InternalServerError } - $StatusCode = [HttpStatusCode]::InternalServerError } return [HttpResponseContext]@{ diff --git a/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 index f0cf0308dc7c..bfdbf623ca80 100644 --- a/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 @@ -11,6 +11,10 @@ function Get-SherwebCurrentSubscription { $CustomerId = Get-ExtensionMapping -Extension 'Sherweb' | Where-Object { $_.RowKey -eq $TenantFilter } | Select-Object -ExpandProperty IntegrationId } + if ([string]::IsNullOrEmpty($CustomerId)) { + throw 'No Sherweb mapping found' + } + Write-Information "Getting current subscription for $CustomerId" $AuthHeader = Get-SherwebAuthentication $Uri = "https://api.sherweb.com/service-provider/v1/billing/subscriptions/details?customerId=$CustomerId" From 85d9a6c228dee577ba5cded5bab28255ce069d9a Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Fri, 8 May 2026 12:07:51 +0200 Subject: [PATCH 52/68] concept --- .../Invoke-ListObjectHistory.ps1 | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 new file mode 100644 index 000000000000..68ed78012e63 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 @@ -0,0 +1,284 @@ +function Invoke-ListObjectHistory { + <# + .SYNOPSIS + In progress concept - Rvd + Returns a transformed timeline of audit events for any tenant object over a configurable period. + + .DESCRIPTION + Aggregates change history from Graph directoryAudits and (for Exchange objects) the Unified Audit Log. + Returns a normalised timeline array with parsed property changes, actor details, and source metadata. + Supports users, groups, applications, service principals, devices, administrative units, + conditional access policies, shared mailboxes, distribution lists, and mail contacts. + + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.AuditLog.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $ObjectId = $Request.Query.objectId ?? $Request.Query.id ?? $Request.Body.objectId ?? $Request.Body.id + $ObjectType = $Request.Query.objectType ?? $Request.Body.objectType + $Days = try { [int]($Request.Query.days ?? $Request.Body.days ?? 30) } catch { 30 } + + #region Validation + if (-not $ObjectId) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: objectId is required' } + } + } + + try { + $ObjectId = ConvertTo-CIPPODataFilterValue -Value $ObjectId -Type Guid + } catch { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: objectId must be a valid GUID' } + } + } + + if (-not $TenantFilter) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: tenantFilter is required' } + } + } + + if ($Days -lt 1) { $Days = 1 } + if ($Days -gt 90) { $Days = 90 } + #endregion + + $StartTime = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $TypeKey = ($ObjectType ?? '').Trim().ToLowerInvariant() + $Warnings = [System.Collections.Generic.List[string]]::new() + $Sources = [System.Collections.Generic.List[string]]::new() + + #region Resolve object and classify source lanes + $ResolveUri = if ($TypeKey -eq 'conditionalaccesspolicy') { + "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$ObjectId" + } else { + "https://graph.microsoft.com/v1.0/directoryObjects/$ObjectId" + } + + $ResolvedObject = $null + $ResolvedAs = $null + $ResolvedDisplayName = $null + try { + $ResolvedObject = New-GraphGetRequest -uri $ResolveUri -tenantid $TenantFilter -ErrorAction Stop + $ODataType = $ResolvedObject.'@odata.type' -replace '^#microsoft\.graph\.', '' + $ResolvedAs = if ($TypeKey -eq 'conditionalaccesspolicy') { 'conditionalAccessPolicy' } else { $ODataType ?? 'directoryObject' } + $ResolvedDisplayName = $ResolvedObject.displayName ?? $ResolvedObject.userPrincipalName ?? $ResolvedObject.appId ?? $ObjectId + } catch { + $ResolvedAs = if ($TypeKey) { $TypeKey } else { 'directoryObject' } + $ResolvedDisplayName = $ObjectId + } + + # Classify which audit sources to query based on resolved type + $ExchangeOnlyTypes = @('mailbox', 'sharedmailbox', 'distributionlist', 'mailcontact', 'resource', 'roommailbox', 'equipmentmailbox') + $EntraOnlyTypes = @('application', 'serviceprincipal', 'device', 'administrativeunit', 'conditionalaccesspolicy') + + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $false + $ExchangeAnchor = $null + + if ($TypeKey -in $ExchangeOnlyTypes) { + # Caller explicitly said this is an Exchange object — skip Graph, go Exchange only + $QueryDirectoryAudits = $false + $QueryExchangeAudit = $true + $ExchangeAnchor = $ResolvedObject.userPrincipalName ?? $ResolvedObject.mail + } elseif ($TypeKey -in $EntraOnlyTypes) { + # Pure Entra object — Graph directoryAudits only + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $false + } elseif ($ResolvedObject.mail -and $ResolvedAs -in @('user', 'group')) { + # Mail-enabled user or group — query both + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $true + $ExchangeAnchor = $ResolvedObject.userPrincipalName ?? $ResolvedObject.mail + } + # else: unknown type or resolution failed — default is Graph directoryAudits only + #endregion + + #region Helper: parse modifiedProperties + $ParseModifiedProperties = { + param([array]$Properties) + foreach ($Prop in $Properties) { + $OldVal = $null + $NewVal = $null + if ($Prop.oldValue -and $Prop.oldValue -ne '[]' -and $Prop.oldValue -ne 'null') { + $OldVal = try { $Prop.oldValue | ConvertFrom-Json -ErrorAction Stop } catch { $Prop.oldValue } + } + if ($Prop.newValue -and $Prop.newValue -ne '[]' -and $Prop.newValue -ne 'null') { + $NewVal = try { $Prop.newValue | ConvertFrom-Json -ErrorAction Stop } catch { $Prop.newValue } + } + if ($null -ne $OldVal -or $null -ne $NewVal) { + [PSCustomObject]@{ + property = $Prop.displayName + oldValue = $OldVal + newValue = $NewVal + } + } + } + } + #endregion + + #region Helper: normalize initiatedBy + $NormalizeActor = { + param($InitiatedBy) + if ($InitiatedBy.user) { + [PSCustomObject]@{ + displayName = $InitiatedBy.user.displayName ?? $InitiatedBy.user.userPrincipalName + id = $InitiatedBy.user.id + upn = $InitiatedBy.user.userPrincipalName + type = 'user' + } + } elseif ($InitiatedBy.app) { + [PSCustomObject]@{ + displayName = $InitiatedBy.app.displayName + id = $InitiatedBy.app.servicePrincipalId ?? $InitiatedBy.app.appId + upn = $null + type = 'app' + } + } else { + [PSCustomObject]@{ + displayName = 'Unknown' + id = $null + upn = $null + type = 'unknown' + } + } + } + #endregion + + #region Query Graph directoryAudits + [array]$DirectoryTimeline = @() + if ($QueryDirectoryAudits) { + try { + $Filter = "activityDateTime ge $StartTime and targetResources/any(s:s/id eq '$ObjectId')" + $Uri = "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=$Filter&`$orderby=activityDateTime desc" + + Write-LogMessage -API $APIName -message "Object history: querying directoryAudits for $ObjectId (last $Days days)" -Sev 'Debug' -tenant $TenantFilter + + [array]$RawAudits = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -ComplexFilter -ErrorAction Stop + + [array]$DirectoryTimeline = @(foreach ($Event in $RawAudits) { + $TargetResource = $Event.targetResources | Where-Object { $_.id -eq $ObjectId } | Select-Object -First 1 + $TargetResource = $TargetResource ?? ($Event.targetResources | Select-Object -First 1) + + [PSCustomObject]@{ + id = $Event.id + timestamp = $Event.activityDateTime + activity = $Event.activityDisplayName + category = $Event.category + operationType = $Event.operationType + result = $Event.result + actor = & $NormalizeActor $Event.initiatedBy + target = $TargetResource.displayName ?? $TargetResource.userPrincipalName ?? $ObjectId + changes = @(& $ParseModifiedProperties ($TargetResource.modifiedProperties ?? @())) + source = 'directoryAudit' + } + }) + [void]$Sources.Add('directoryAudit') + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Object history: directoryAudits failed - $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + [void]$Warnings.Add("Directory audit query failed: $($ErrorMessage.NormalizedError)") + } + } + #endregion + + #region Query Exchange Unified Audit Log + [array]$ExchangeTimeline = @() + if ($QueryExchangeAudit) { + if ($ExchangeAnchor) { + try { + $SessionId = "ObjectHistory_$(Get-Random -Minimum 10000 -Maximum 99999)" + $SearchParam = @{ + SessionCommand = 'ReturnLargeSet' + ObjectIds = @($ExchangeAnchor) + SessionId = $SessionId + StartDate = (Get-Date).AddDays(-$Days) + EndDate = (Get-Date) + ResultSize = 5000 + } + + $ExchangeLogs = [System.Collections.Generic.List[object]]::new() + $MaxPages = 10 + $Page = 0 + do { + $Batch = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SearchParam -Anchor $ExchangeAnchor) + foreach ($Item in $Batch) { [void]$ExchangeLogs.Add($Item) } + $Page++ + } while ($Batch.Count -eq 5000 -and $Page -lt $MaxPages) + + [array]$ExchangeTimeline = @(foreach ($Log in $ExchangeLogs) { + $AuditData = try { $Log.AuditData | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $AuditData) { continue } + + [PSCustomObject]@{ + id = $AuditData.Id ?? $Log.Identity + timestamp = $Log.CreationDate ?? $AuditData.CreationTime + activity = $AuditData.Operation + category = 'ExchangeItem' + operationType = $AuditData.Operation + result = if ($AuditData.ResultStatus -eq 'Succeeded' -or $AuditData.ResultStatus -eq 'True') { 'success' } else { $AuditData.ResultStatus ?? 'success' } + actor = [PSCustomObject]@{ + displayName = $AuditData.UserId + id = $AuditData.UserId + upn = $AuditData.UserId + type = 'user' + } + target = $AuditData.ObjectId ?? $ExchangeAnchor + changes = @( + if ($AuditData.Parameters) { + foreach ($Param in $AuditData.Parameters) { + [PSCustomObject]@{ + property = $Param.Name + oldValue = $null + newValue = $Param.Value + } + } + } + ) + source = 'exchangeAudit' + } + }) + [void]$Sources.Add('exchangeAudit') + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Object history: Exchange UAL failed - $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + [void]$Warnings.Add("Exchange audit query failed: $($ErrorMessage.NormalizedError)") + } + } else { + [void]$Warnings.Add('Exchange audit skipped: could not determine mailbox anchor (UPN/mail)') + } + } + #endregion + + #region Merge and sort timeline + $Timeline = @($DirectoryTimeline + $ExchangeTimeline | Where-Object { $_ } | Sort-Object -Property timestamp -Descending) + #endregion + + $Body = [PSCustomObject]@{ + objectId = $ObjectId + objectType = $ObjectType + resolvedObject = $ResolvedObject + resolvedAs = $ResolvedAs + resolvedDisplayName = $ResolvedDisplayName + days = $Days + activityFromUtc = $StartTime + totalEvents = $Timeline.Count + sources = @($Sources) + warnings = @($Warnings) + timeline = @($Timeline) + } + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + } +} From 5644578fe74abd3f331fbc0cd4adfac1becfab2c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 12:18:10 +0200 Subject: [PATCH 53/68] fixed #5930 --- .../Invoke-ExecQuarantineManagement.ps1 | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 index deb23870dd96..44f942384722 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 @@ -18,14 +18,19 @@ function Invoke-ExecQuarantineManagement { if ($ActionType -eq 'Release') { $params['ReleaseToAll'] = $true + if ($Request.Body.Identity -is [string]) { + $params['Identity'] = $Request.Body.Identity + } else { + $params['Identities'] = $Request.Body.Identity + $params['Identity'] = '000' + } } else { $params['ActionType'] = $ActionType - } - - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity - } else { - $params['Identities'] = $Request.Body.Identity + if ($Request.Body.Identity -is [string]) { + $params['Identities'] = @($Request.Body.Identity) + } else { + $params['Identities'] = $Request.Body.Identity + } $params['Identity'] = '000' } New-ExoRequest -tenantid $TenantFilter -cmdlet 'Release-QuarantineMessage' -cmdParams $params From 67ea958492a04d806604db545165157941b59063 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 12:24:48 +0200 Subject: [PATCH 54/68] fixes #5973 --- .../Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 index 6fa95268d1cc..3ef10d9e5e15 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 @@ -58,6 +58,9 @@ function Invoke-CIPPStandardSpoofWarn { return } + # Sanitize AllowList — the API may return @('') instead of @() for an empty list + $CurrentInfo.AllowList = @($CurrentInfo.AllowList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + # Get state value using null-coalescing operator $state = $Settings.state.value ?? $Settings.state From b7773c632ef1e955e57f6ced95b56eaba01403eb Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 12:33:44 +0200 Subject: [PATCH 55/68] pushing new compliance menus --- .../Invoke-AddRetentionCompliancePolicy.ps1 | 47 ++++++++++++++++++ ...e-AddRetentionCompliancePolicyTemplate.ps1 | 48 +++++++++++++++++++ .../Invoke-EditRetentionCompliancePolicy.ps1 | 46 ++++++++++++++++++ .../Invoke-ListRetentionCompliancePolicy.ps1 | 30 ++++++++++++ ...ListRetentionCompliancePolicyTemplates.ps1 | 26 ++++++++++ ...Invoke-RemoveRetentionCompliancePolicy.ps1 | 39 +++++++++++++++ ...emoveRetentionCompliancePolicyTemplate.ps1 | 36 ++++++++++++++ 7 files changed, 272 insertions(+) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..fec42ad1649a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 @@ -0,0 +1,47 @@ +Function Invoke-AddRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, RuleParams + $RuleParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).RuleParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $PolicyParams = $RequestParams | Select-Object -Property * -ExcludeProperty RuleParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-RetentionCompliancePolicy' -cmdParams $PolicyParams -Compliance -AsApp -useSystemMailbox $true + + if ($RuleParams) { + $RuleHash = @{} + $RuleParams.PSObject.Properties | ForEach-Object { $RuleHash[$_.Name] = $_.Value } + $RuleHash['Policy'] = $RequestParams.Name + if (-not $RuleHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($RuleHash['Name'])) { + $RuleHash['Name'] = "$($RequestParams.Name) Rule" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-RetentionComplianceRule' -cmdParams $RuleHash -Compliance -AsApp -useSystemMailbox $true + } + + "Successfully created Retention compliance policy $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Retention compliance policy $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create Retention compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create Retention compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..79896013ae2d --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddRetentionCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Comment, Enabled, RestrictiveRetention, ExchangeLocation, ExchangeLocationException, ModernGroupLocation, ModernGroupLocationException, OneDriveLocation, OneDriveLocationException, SharePointLocation, SharePointLocationException, SkypeLocation, SkypeLocationException, PublicFolderLocation, TeamsChannelLocation, TeamsChannelLocationException, TeamsChatLocation, TeamsChatLocationException, ApplyComplianceTag, RetentionDuration, RetentionAction, RetentionDurationDisplayHint, ExpirationDateOption, RuleParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'RetentionCompliancePolicyTemplate' + } + $Result = "Successfully created Retention Compliance Policy Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Retention Compliance Policy Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..72db204c7d83 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 @@ -0,0 +1,46 @@ +Function Invoke-EditRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $State = $Request.Query.State ?? $Request.Body.State + + try { + $Params = @{ + Identity = $Identity + } + + if ($State) { + $Params['Enabled'] = ($State -eq 'enable' -or $State -eq $true -or $State -eq 'true') + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-RetentionCompliancePolicy' -cmdParams $Params -Compliance -AsApp -useSystemMailbox $true + $Result = "Updated Retention compliance policy $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating Retention compliance policy $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..1c5290932c8f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 @@ -0,0 +1,30 @@ +Function Invoke-ListRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionCompliancePolicy' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + $Rules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionComplianceRule' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + $GraphRequest = $Policies | Select-Object *, + @{l = 'AssociatedRules'; e = { $name = $_.Name; @($Rules | Where-Object { $_.Policy -eq $name }) } }, + @{l = 'RuleCount'; e = { $name = $_.Name; (@($Rules | Where-Object { $_.Policy -eq $name })).Count } } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 new file mode 100644 index 000000000000..98dace219fbc --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListRetentionCompliancePolicyTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.RetentionCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'RetentionCompliancePolicyTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..a6061dc75730 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 @@ -0,0 +1,39 @@ +Function Invoke-RemoveRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $ForceRemoval = $Request.Body.ForceDeletion -eq $true + + try { + $Params = @{ + Identity = $Identity + } + if ($ForceRemoval) { $Params['ForceDeletion'] = $true } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-RetentionCompliancePolicy' -cmdParams $Params -Compliance -AsApp -useSystemMailbox $true + $Result = "Deleted Retention compliance policy $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete Retention compliance policy $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..69d7b60eca4e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveRetentionCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'RetentionCompliancePolicyTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Retention Compliance Policy template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Retention Compliance Policy template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} From 584a6fe6479840c71cca4a8b5205e735b76fe919 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 8 May 2026 12:37:09 +0200 Subject: [PATCH 56/68] feat: support bulk manager and sponsor updates - Updated Set-CIPPManager and Set-CIPPSponsor functions to accept multiple users for bulk processing. - Modified Invoke-EditUser and Invoke-PatchUser to handle bulk updates for managers and sponsors. - Enhanced logging for success and error messages during updates. --- Modules/CIPPCore/Public/New-CIPPUserTask.ps1 | 8 +- Modules/CIPPCore/Public/Set-CIPPManager.ps1 | 52 +++++-- Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 | 51 +++++-- .../Administration/Users/Invoke-EditUser.ps1 | 8 +- .../Administration/Users/Invoke-PatchUser.ps1 | 140 ++++++++++++++---- 5 files changed, 198 insertions(+), 61 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index a91904f4724a..7fdfab465719 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -81,13 +81,13 @@ function New-CIPPUserTask { } if ($UserObj.setManager) { - $ManagerResult = Set-CIPPManager -User $CreationResults.Username -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResult) + $ManagerResults = Set-CIPPManager -Users $CreationResults.Username -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) } if ($UserObj.setSponsor) { - $SponsorResult = Set-CIPPSponsor -User $CreationResults.Username -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResult) + $SponsorResults = Set-CIPPSponsor -Users $CreationResults.Username -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) } return @{ diff --git a/Modules/CIPPCore/Public/Set-CIPPManager.ps1 b/Modules/CIPPCore/Public/Set-CIPPManager.ps1 index c1394b04936d..b71367bceb88 100644 --- a/Modules/CIPPCore/Public/Set-CIPPManager.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPManager.ps1 @@ -1,23 +1,49 @@ function Set-CIPPManager { [CmdletBinding()] param ( - $User, - $Manager, + [Alias('User')] + [string[]] $Users, + [string] $Manager, $TenantFilter, $APIName = 'Set Manager', $Headers ) - try { - $ManagerBody = [PSCustomObject]@{'@odata.id' = "https://graph.microsoft.com/beta/users/$($Manager)" } - $ManagerBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $ManagerBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($User)/manager/`$ref" -tenantid $TenantFilter -type PUT -body $ManagerBodyJSON - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Set $User's manager to $Manager" -Sev 'Info' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to Set Manager. Error:$($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $_ - throw "Failed to set manager: $($ErrorMessage.NormalizedError)" + if ($Users.Count -eq 0) { + return @() } - return "Set $User's manager to $Manager" -} + $RequestId = 0 + $Requests = foreach ($User in $Users) { + @{ + id = ($RequestId++).ToString() + method = 'PUT' + url = "users/$User/manager/`$ref" + body = @{ '@odata.id' = "https://graph.microsoft.com/beta/users/$Manager" } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $Responses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) + + $Results = foreach ($Response in @($Responses)) { + $ResponseIndex = [int]$Response.id + $User = $Users[$ResponseIndex] + $Success = [int]$Response.status -in @(200, 204) + $ErrorMessage = if ($Response.body.error.message) { $Response.body.error.message } else { "Unknown error (Status: $($Response.status))" } + $Result = if ($Success) { "Set $User's manager to $Manager" } else { "Failed to set $User's manager: $ErrorMessage" } + $Severity = if ($Success) { 'Info' } else { 'Error' } + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev $Severity + + [pscustomobject]@{ + User = $User + Manager = $Manager + Success = $Success + Result = $Result + Status = $Response.status + } + } + + return @($Results) +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 b/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 index ad2444c01b9f..8bbb7bfb9850 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 @@ -1,22 +1,49 @@ function Set-CIPPSponsor { [CmdletBinding()] param ( - $User, - $Sponsor, + [Alias('User')] + [string[]] $Users, + [string] $Sponsor, $TenantFilter, $APIName = 'Set Sponsor', $Headers ) - try { - $SponsorBody = [PSCustomObject]@{'@odata.id' = "https://graph.microsoft.com/beta/users/$($Sponsor)" } - $SponsorBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $SponsorBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($User)/sponsors/`$ref" -tenantid $TenantFilter -type PUT -body $SponsorBodyJSON - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Set $User's sponsor to $Sponsor" -Sev 'Info' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to Set Sponsor. Error:$($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $_ - throw "Failed to set sponsor: $($_.Exception.Message)" + if ($Users.Count -eq 0) { + return @() } - return "Set $user's sponsor to $Sponsor" + + $RequestId = 0 + $Requests = foreach ($User in $Users) { + @{ + id = ($RequestId++).ToString() + method = 'PUT' + url = "users/$User/sponsors/`$ref" + body = @{ '@odata.id' = "https://graph.microsoft.com/beta/users/$Sponsor" } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $Responses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) + + $Results = foreach ($Response in @($Responses)) { + $ResponseIndex = [int]$Response.id + $User = $Users[$ResponseIndex] + $Success = [int]$Response.status -in @(200, 204) + $ErrorMessage = if ($Response.body.error.message) { $Response.body.error.message } else { "Unknown error (Status: $($Response.status))" } + $Result = if ($Success) { "Set $User's sponsor to $Sponsor" } else { "Failed to set $User's sponsor: $ErrorMessage" } + $Severity = if ($Success) { 'Info' } else { 'Error' } + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev $Severity + + [pscustomobject]@{ + User = $User + Sponsor = $Sponsor + Success = $Success + Result = $Result + Status = $Response.status + } + } + + return @($Results) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index b6f95c20f112..417b90b22cc6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -242,13 +242,13 @@ function Invoke-EditUser { } if ($Request.body.setManager.value) { - $ManagerResult = Set-CIPPManager -User $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResult) + $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) } if ($Request.body.setSponsor.value) { - $SponsorResult = Set-CIPPSponsor -User $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResult) + $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 index c4c269b9c338..b71874b17ca5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 @@ -14,7 +14,7 @@ function Invoke-PatchUser { $HttpResponse = [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK - Body = @{'Results' = @("Default response, you should never see this.") } + Body = @{'Results' = @('Default response, you should never see this.') } } try { @@ -36,22 +36,37 @@ function Invoke-PatchUser { # Group users by tenant filter $UsersByTenant = $Users | Group-Object -Property tenantFilter - $TotalSuccessCount = 0 - $AllErrorMessages = @() + $TotalPatchSuccessCount = 0 + $TotalManagerSuccessCount = 0 + $TotalSponsorSuccessCount = 0 + $AllErrorMessages = [System.Collections.Generic.List[string]]::new() + $HasManagerUpdates = @($Users | Where-Object { -not [string]::IsNullOrWhiteSpace($_.manager) }).Count -gt 0 + $HasSponsorUpdates = @($Users | Where-Object { -not [string]::IsNullOrWhiteSpace($_.sponsor) }).Count -gt 0 + $HasRelationshipUpdates = $HasManagerUpdates -or $HasSponsorUpdates # Process each tenant separately foreach ($TenantGroup in $UsersByTenant) { $tenantFilter = $TenantGroup.Name $TenantUsers = $TenantGroup.Group + $UsersWithManager = $TenantUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.manager) } + $ManagerGroups = $UsersWithManager | Group-Object -Property manager + $UsersWithSponsor = $TenantUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.sponsor) } + $SponsorGroups = $UsersWithSponsor | Group-Object -Property sponsor # Build bulk requests for this tenant $int = 0 - $BulkRequests = foreach ($User in $TenantUsers) { - # Remove the id and tenantFilter properties from the body since they're not user properties - $PatchBody = $User | Select-Object -Property * -ExcludeProperty id, tenantFilter + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequestUsers = [System.Collections.Generic.List[object]]::new() + foreach ($User in $TenantUsers) { + # Remove routing and relationship properties from the body since they're not normal PATCH properties. + $PatchBody = $User | Select-Object -Property * -ExcludeProperty id, tenantFilter, manager, sponsor - @{ - id = $int++ + if (@($PatchBody.PSObject.Properties).Count -eq 0) { + continue + } + + $BulkRequest = @{ + id = ($int++).ToString() method = 'PATCH' url = "users/$($User.id)" body = $PatchBody @@ -59,38 +74,107 @@ function Invoke-PatchUser { 'Content-Type' = 'application/json' } } + [void]$BulkRequests.Add($BulkRequest) + [void]$BulkRequestUsers.Add($User) } # Execute bulk request for this tenant - $BulkResults = New-GraphBulkRequest -tenantid $tenantFilter -Requests @($BulkRequests) - - # Process results for this tenant - for ($i = 0; $i -lt $BulkResults.Count; $i++) { - $result = $BulkResults[$i] - $user = $TenantUsers[$i] - - if ($result.status -eq 200 -or $result.status -eq 204) { - $TotalSuccessCount++ - Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Successfully patched user $($user.id)" -Sev 'Info' - } else { - $errorMsg = if ($result.body.error.message) { - $result.body.error.message + if ($BulkRequests.Count -gt 0) { + $BulkResults = New-GraphBulkRequest -tenantid $tenantFilter -Requests ($BulkRequests.ToArray()) + + # Process results for this tenant + foreach ($BulkResult in @($BulkResults)) { + $ResultIndex = [int]$BulkResult.id + $User = $BulkRequestUsers[$ResultIndex] + + if ($BulkResult.status -eq 200 -or $BulkResult.status -eq 204) { + $TotalPatchSuccessCount++ + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Successfully patched user $($User.id)" -Sev 'Info' } else { - "Unknown error (Status: $($result.status))" + $ErrorMessage = if ($BulkResult.body.error.message) { + $BulkResult.body.error.message + } else { + "Unknown error (Status: $($BulkResult.status))" + } + [void]$AllErrorMessages.Add("Failed to patch user $($User.id) in tenant $($tenantFilter): $ErrorMessage") + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Failed to patch user $($User.id). Error: $ErrorMessage" -Sev 'Error' + } + } + } + + foreach ($ManagerGroup in $ManagerGroups) { + $UserIds = @($ManagerGroup.Group | ForEach-Object { $_.id }) + $ManagerUpn = $ManagerGroup.Name + + try { + $ManagerResults = Set-CIPPManager -Users $UserIds -Manager $ManagerUpn -TenantFilter $tenantFilter -Headers $Headers -APIName $APIName + + foreach ($ManagerResult in @($ManagerResults)) { + if ($ManagerResult.Success) { + $TotalManagerSuccessCount++ + } else { + [void]$AllErrorMessages.Add("Failed to set manager for $($ManagerResult.User) in tenant $($tenantFilter): $($ManagerResult.Result)") + } + } + } catch { + foreach ($UserId in $UserIds) { + [void]$AllErrorMessages.Add("Failed to set manager for $UserId in tenant $($tenantFilter): $($_.Exception.Message)") + } + } + } + + foreach ($SponsorGroup in $SponsorGroups) { + $UserIds = @($SponsorGroup.Group | ForEach-Object { $_.id }) + $SponsorUpn = $SponsorGroup.Name + + try { + $SponsorResults = Set-CIPPSponsor -Users $UserIds -Sponsor $SponsorUpn -TenantFilter $tenantFilter -Headers $Headers -APIName $APIName + + foreach ($SponsorResult in @($SponsorResults)) { + if ($SponsorResult.Success) { + $TotalSponsorSuccessCount++ + } else { + [void]$AllErrorMessages.Add("Failed to set sponsor for $($SponsorResult.User) in tenant $($tenantFilter): $($SponsorResult.Result)") + } + } + } catch { + foreach ($UserId in $UserIds) { + [void]$AllErrorMessages.Add("Failed to set sponsor for $UserId in tenant $($tenantFilter): $($_.Exception.Message)") } - $AllErrorMessages += "Failed to patch user $($user.id) in tenant $($tenantFilter): $errorMsg" - Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Failed to patch user $($user.id). Error: $errorMsg" -Sev 'Error' } } } # Build final response + $TenantCount = ($Users | Select-Object -Property tenantFilter -Unique).Count + $RelationshipResults = [System.Collections.Generic.List[string]]::new() + if ($HasManagerUpdates) { + [void]$RelationshipResults.Add("$TotalManagerSuccessCount manager assignment$(if($TotalManagerSuccessCount -ne 1){'s'})") + } + if ($HasSponsorUpdates) { + [void]$RelationshipResults.Add("$TotalSponsorSuccessCount sponsor assignment$(if($TotalSponsorSuccessCount -ne 1){'s'})") + } + $RelationshipResultMessage = [string]::Join(' and ', $RelationshipResults.ToArray()) + + $SuccessMessage = if ($HasRelationshipUpdates -and $TotalPatchSuccessCount -gt 0) { + "Successfully patched $TotalPatchSuccessCount user$(if($TotalPatchSuccessCount -ne 1){'s'}) and updated $RelationshipResultMessage across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } elseif ($HasRelationshipUpdates) { + "Successfully updated $RelationshipResultMessage across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } else { + "Successfully patched $TotalPatchSuccessCount user$(if($TotalPatchSuccessCount -ne 1){'s'}) across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } + if ($AllErrorMessages.Count -eq 0) { - $TenantCount = ($Users | Select-Object -Property tenantFilter -Unique).Count - $HttpResponse.Body = @{'Results' = @("Successfully patched $TotalSuccessCount user$(if($TotalSuccessCount -ne 1){'s'}) across $TenantCount tenant$(if($TenantCount -ne 1){'s'})") } + $HttpResponse.Body = @{'Results' = @($SuccessMessage) } } else { $HttpResponse.StatusCode = [HttpStatusCode]::BadRequest - $HttpResponse.Body = @{'Results' = $AllErrorMessages + @("Successfully patched $TotalSuccessCount of $($Users.Count) users") } + $PartialSuccessMessage = if ($HasRelationshipUpdates) { $SuccessMessage } else { "Successfully patched $TotalPatchSuccessCount of $($Users.Count) users" } + $Results = [System.Collections.Generic.List[string]]::new() + foreach ($ErrorMessage in $AllErrorMessages) { + [void]$Results.Add($ErrorMessage) + } + [void]$Results.Add($PartialSuccessMessage) + $HttpResponse.Body = @{'Results' = @($Results.ToArray()) } } } @@ -100,4 +184,4 @@ function Invoke-PatchUser { } return $HttpResponse -} \ No newline at end of file +} From a8a0c9d57e4e8bc1ebf7e802325f12bdcdb328b4 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 12:55:16 +0200 Subject: [PATCH 57/68] fixes #5967 --- .../Applications/Invoke-AddOfficeApp.ps1 | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 index ff880318819f..0187c47e1f53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 @@ -19,8 +19,21 @@ function Invoke-AddOfficeApp { try { $ExistingO365 = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' -tenantid $Tenant | Where-Object { $_.displayName -eq 'Microsoft 365 Apps for Windows 10 and later' } if (!$ExistingO365) { - # Check if custom XML is provided - if ($Request.Body.useCustomXml -and $Request.Body.customXml) { + # Check if this is a template deployment with IntuneBody (saved from existing app) + if ($Request.Body.IntuneBody) { + $IntuneBody = $Request.Body.IntuneBody + if ($IntuneBody -is [string]) { + $IntuneBody = $IntuneBody | ConvertFrom-Json -Depth 100 + } + # Remove read-only properties that the Graph API won't accept on create + $ReadOnlyProps = @('id', 'createdDateTime', 'lastModifiedDateTime', 'uploadState', 'publishingState', 'isAssigned', 'roleScopeTagIds', 'dependentAppCount', 'supersedingAppCount', 'supersededAppCount', 'committedContentVersion', 'fileName', 'size') + foreach ($prop in $ReadOnlyProps) { + if ($IntuneBody.PSObject.Properties[$prop]) { + $IntuneBody.PSObject.Properties.Remove($prop) + } + } + $ObjBody = $IntuneBody + } elseif ($Request.Body.useCustomXml -and $Request.Body.customXml) { # Use custom XML configuration $ObjBody = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.officeSuiteApp' @@ -76,7 +89,7 @@ function Invoke-AddOfficeApp { 'officeSuiteAppDefaultFileFormat' = 'OfficeOpenXMLFormat' 'localesToInstall' = @($Request.Body.languages.value) 'shouldUninstallOlderVersionsOfOffice' = [bool]$Request.Body.RemoveVersions - 'updateChannel' = $Request.Body.updateChannel.value + 'updateChannel' = if ($Request.Body.updateChannel.value) { $Request.Body.updateChannel.value } else { $Request.Body.updateChannel } 'useSharedComputerActivation' = [bool]$Request.Body.SharedComputerActivation 'productIds' = $products 'largeIcon' = @{ From 6c440c0121ceaba7eb1b7cf7305b06b76ad95ef5 Mon Sep 17 00:00:00 2001 From: Joachim Date: Fri, 8 May 2026 13:54:24 +0200 Subject: [PATCH 58/68] Fix usageLocation autocomplete object in JIT Admin (#5910) The autocomplete component sends {label, value} objects but the Graph API expects a plain string for usageLocation. Extract .value before passing to API, matching the pattern used by other user endpoints (Invoke-AddUserDefaults, Invoke-EditUser, etc). --- .../Administration/Users/Invoke-AddJITAdminTemplate.ps1 | 4 ++-- .../Administration/Users/Invoke-EditJITAdminTemplate.ps1 | 4 ++-- .../Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 index 141a2754cf0b..0f12d80773df 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 @@ -112,8 +112,8 @@ function Invoke-AddJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } - if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUsageLocation)) { - $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation + if ($Request.Body.defaultUsageLocation) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation.value ?? $Request.Body.defaultUsageLocation } # defaultDomain is only saved for specific tenant templates (not AllTenants) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 index b3282e64647b..4abb488e7f03 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 @@ -130,8 +130,8 @@ function Invoke-EditJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } - if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUsageLocation)) { - $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation + if ($Request.Body.defaultUsageLocation) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation.value ?? $Request.Body.defaultUsageLocation } # defaultDomain is only saved for specific tenant templates (not AllTenants) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 index 2f522cc0a66d..f42a54b0baad 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 @@ -63,7 +63,7 @@ function Invoke-ExecJITAdmin { 'FirstName' = $Request.Body.FirstName 'LastName' = $Request.Body.LastName 'UserPrincipalName' = $Username - 'UsageLocation' = $Request.Body.usageLocation + 'UsageLocation' = $Request.Body.usageLocation.value ?? $Request.Body.usageLocation } Expiration = $Expiration StartDate = $Start From 3b609649a3b7ac970f51088c467ab897ba4ad2ba Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 8 May 2026 13:57:08 +0200 Subject: [PATCH 59/68] fix(jit-admin): honor TAP lifetime policy bounds Fixes https://github.com/KelvinTegelaar/CIPP/issues/5965 --- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 4 +- .../Users/Invoke-ExecJITAdmin.ps1 | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index a7202202bef0..75d7971388a4 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -26,7 +26,7 @@ function Set-CIPPAuthenticationPolicy { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage - Return "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" + return "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" } switch ($AuthenticationMethodId) { @@ -114,7 +114,7 @@ function Set-CIPPAuthenticationPolicy { throw "Setting $AuthenticationMethodId to enabled is not allowed" } } - Default { + default { Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." -sev Error throw "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 index 2f522cc0a66d..069233e2b32d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 @@ -129,14 +129,42 @@ function Invoke-ExecJITAdmin { #Region TAP creation if ($Request.Body.UseTAP) { try { - if ($Start -gt (Get-Date)) { - $TapParams = @{ - startDateTime = [System.DateTimeOffset]::FromUnixTimeSeconds($Request.Body.StartDate).DateTime + $LifetimeMinutes = $null + $RequestedMinutes = $null + $ParsedRequestLifetime = $false + if (![string]::IsNullOrWhiteSpace($Request.Body.tapLifetimeInMinutes)) { + try { + $RequestedMinutes = [int]$Request.Body.tapLifetimeInMinutes + $ParsedRequestLifetime = $true + } catch { + Write-Warning "Failed to parse TAP lifetime from request: $($_.Exception.Message)" + } + } + + if ($null -eq $RequestedMinutes) { + $RequestedMinutes = [int](($Expiration - $Start).TotalMinutes) + } + + try { + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass' -tenantid $TenantFilter + $PolicyMax = [int]($Policy.maximumLifetimeInMinutes ?? 1440) + $PolicyMin = [Math]::Min([int]($Policy.minimumLifetimeInMinutes ?? 1), $PolicyMax) + $LifetimeMinutes = [Math]::Min([Math]::Max($RequestedMinutes, $PolicyMin), $PolicyMax) + } catch { + Write-Warning "Failed to determine TAP lifetime from policy: $($_.Exception.Message)" + if ($ParsedRequestLifetime) { + $LifetimeMinutes = $RequestedMinutes } - $TapBody = ConvertTo-Json -Depth 5 -InputObject $TapParams - } else { - $TapBody = '{}' } + + $TapParams = @{} + if ($Start -gt (Get-Date)) { + $TapParams.startDateTime = [System.DateTimeOffset]::FromUnixTimeSeconds($Request.Body.StartDate).DateTime + } + if ($LifetimeMinutes -gt 0) { + $TapParams.lifetimeInMinutes = [int]$LifetimeMinutes + } + $TapBody = if ($TapParams.Count) { ConvertTo-Json -Depth 5 -InputObject $TapParams } else { '{}' } # Write-Information "https://graph.microsoft.com/beta/users/$Username/authentication/temporaryAccessPassMethods" # Retry creating the TAP up to 10 times, since it can fail due to the user not being fully created yet. Sometimes it takes 2 reties, sometimes it takes 8+. Very annoying. -Bobby $Retries = 0 From 2529b6aae81fe9ae10248c74fad67c41d9188a6a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 May 2026 14:07:43 +0200 Subject: [PATCH 60/68] fixes #5925 --- ...PPStandardTenantAllowBlockListTemplate.ps1 | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 index 38ab5cedcbbc..c3d4522eacd2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 @@ -63,16 +63,42 @@ function Invoke-CIPPStandardTenantAllowBlockListTemplate { }) if ($Settings.remediate -eq $true) { + # Track entries submitted across templates to handle overlapping entries without relying on Exchange replication + $SubmittedEntries = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.HashSet[string]]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($TemplateData in $ResolvedTemplates) { try { $Entries = @($TemplateData.entries -split '[,;]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + $ListType = [string]$TemplateData.listType + + # Get existing entries to avoid duplicate errors that block the entire batch + if (-not $SubmittedEntries.ContainsKey($ListType)) { + $SubmittedEntries[$ListType] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + try { + $ExistingItems = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TenantAllowBlockListItems' -cmdParams @{ + ListType = $ListType + } + foreach ($Item in @($ExistingItems)) { + [void]$SubmittedEntries[$ListType].Add($Item.Value) + } + } catch { + # If we can't fetch existing items, continue with empty set + } + } + + $NewEntries = @($Entries | Where-Object { -not $SubmittedEntries[$ListType].Contains($_) }) + + if ($NewEntries.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "All entries from Tenant Allow/Block List template '$($TemplateData.templateName)' already exist for $Tenant" -sev 'Info' + continue + } $ExoParams = @{ tenantid = $Tenant cmdlet = 'New-TenantAllowBlockListItems' cmdParams = @{ - Entries = $Entries - ListType = [string]$TemplateData.listType + Entries = $NewEntries + ListType = $ListType Notes = [string]$TemplateData.notes $TemplateData.listMethod = [bool]$true } @@ -85,14 +111,13 @@ function Invoke-CIPPStandardTenantAllowBlockListTemplate { } New-ExoRequest @ExoParams - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully deployed Tenant Allow/Block List template '$($TemplateData.templateName)' with entries: $($TemplateData.entries)" -sev 'Info' + foreach ($Entry in $NewEntries) { + [void]$SubmittedEntries[$ListType].Add($Entry) + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully deployed Tenant Allow/Block List template '$($TemplateData.templateName)' with entries: $($NewEntries -join ', ')" -sev 'Info' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - if ($ErrorMessage -like '*already exists*') { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Tenant Allow/Block List entries from template '$($TemplateData.templateName)' already exist for $Tenant" -sev 'Info' - } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy Tenant Allow/Block List template '$($TemplateData.templateName)' for $Tenant. Error: $ErrorMessage" -sev 'Error' - } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy Tenant Allow/Block List template '$($TemplateData.templateName)' for $Tenant. Error: $ErrorMessage" -sev 'Error' } } } From 74124f4f4a3b81958217bbf22f9a0c26c218ac22 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 09:51:00 -0400 Subject: [PATCH 61/68] chore: update DNSHealth to 1.1.7 --- .../DNSHealth/{1.1.6 => 1.1.7}/DNSHealth.psd1 | 2 +- .../DNSHealth/{1.1.6 => 1.1.7}/DNSHealth.psm1 | 0 .../MailProviders/AppRiver.json | 0 .../MailProviders/BarracudaESS.json | 0 .../MailProviders/Google.json | 0 .../MailProviders/HornetSecurity.json | 0 .../MailProviders/Intermedia.json | 0 .../MailProviders/Microsoft365.json | 0 .../MailProviders/Mimecast.json | 0 .../{1.1.6 => 1.1.7}/MailProviders/Null.json | 0 .../MailProviders/Proofpoint.json | 0 .../MailProviders/Reflexion.json | 0 .../MailProviders/Sophos.json | 0 .../MailProviders/SpamTitan.json | 0 .../MailProviders/SymantecCloud.json | 0 .../MailProviders/_template.json | 0 .../{1.1.6 => 1.1.7}/PSGetModuleInfo.xml | 38 +++++++++---------- 17 files changed, 20 insertions(+), 20 deletions(-) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/DNSHealth.psd1 (99%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/DNSHealth.psm1 (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/AppRiver.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/BarracudaESS.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Google.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/HornetSecurity.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Intermedia.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Microsoft365.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Mimecast.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Null.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Proofpoint.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Reflexion.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/Sophos.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/SpamTitan.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/SymantecCloud.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/MailProviders/_template.json (100%) rename Modules/DNSHealth/{1.1.6 => 1.1.7}/PSGetModuleInfo.xml (85%) diff --git a/Modules/DNSHealth/1.1.6/DNSHealth.psd1 b/Modules/DNSHealth/1.1.7/DNSHealth.psd1 similarity index 99% rename from Modules/DNSHealth/1.1.6/DNSHealth.psd1 rename to Modules/DNSHealth/1.1.7/DNSHealth.psd1 index 92d590ea5bcf..be713cdf7187 100644 --- a/Modules/DNSHealth/1.1.6/DNSHealth.psd1 +++ b/Modules/DNSHealth/1.1.7/DNSHealth.psd1 @@ -12,7 +12,7 @@ RootModule = 'DNSHealth.psm1' # Version number of this module. - ModuleVersion = '1.1.6' + ModuleVersion = '1.1.7' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/Modules/DNSHealth/1.1.6/DNSHealth.psm1 b/Modules/DNSHealth/1.1.7/DNSHealth.psm1 similarity index 100% rename from Modules/DNSHealth/1.1.6/DNSHealth.psm1 rename to Modules/DNSHealth/1.1.7/DNSHealth.psm1 diff --git a/Modules/DNSHealth/1.1.6/MailProviders/AppRiver.json b/Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/AppRiver.json rename to Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/BarracudaESS.json b/Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/BarracudaESS.json rename to Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Google.json b/Modules/DNSHealth/1.1.7/MailProviders/Google.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Google.json rename to Modules/DNSHealth/1.1.7/MailProviders/Google.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/HornetSecurity.json b/Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/HornetSecurity.json rename to Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Intermedia.json b/Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Intermedia.json rename to Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Microsoft365.json b/Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Microsoft365.json rename to Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Mimecast.json b/Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Mimecast.json rename to Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Null.json b/Modules/DNSHealth/1.1.7/MailProviders/Null.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Null.json rename to Modules/DNSHealth/1.1.7/MailProviders/Null.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Proofpoint.json b/Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Proofpoint.json rename to Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Reflexion.json b/Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Reflexion.json rename to Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Sophos.json b/Modules/DNSHealth/1.1.7/MailProviders/Sophos.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Sophos.json rename to Modules/DNSHealth/1.1.7/MailProviders/Sophos.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/SpamTitan.json b/Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/SpamTitan.json rename to Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/SymantecCloud.json b/Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/SymantecCloud.json rename to Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/_template.json b/Modules/DNSHealth/1.1.7/MailProviders/_template.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/_template.json rename to Modules/DNSHealth/1.1.7/MailProviders/_template.json diff --git a/Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml b/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml similarity index 85% rename from Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml rename to Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml index 0a8245a31c45..441517b7b04a 100644 --- a/Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml +++ b/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml @@ -7,13 +7,13 @@ DNSHealth - 1.1.6 + 1.1.7 Module CIPP DNS Health Check Module John Duprey johnduprey 2023 John Duprey -
2026-04-24T17:42:26-04:00
+
2026-05-08T13:48:38-04:00
@@ -36,18 +36,14 @@ - Cmdlet + Workflow - RoleCapability - - - - Command + Function @@ -72,15 +68,15 @@ - DscResource + RoleCapability - Workflow + DscResource - Function + Command @@ -104,6 +100,10 @@ + + Cmdlet + + @@ -127,24 +127,24 @@ True True 0 - 477 + 491 31557 - 4/24/2026 5:42:26 PM -04:00 - 4/24/2026 5:42:26 PM -04:00 - 4/24/2026 5:42:26 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 PSModule PSFunction_Read-DmarcPolicy PSCommand_Read-DmarcPolicy PSFunction_Read-MtaStsPolicy PSCommand_Read-MtaStsPolicy PSFunction_Add-MailProvider PSCommand_Add-MailProvider PSFunction_Get-MailProvider PSCommand_Get-MailProvider PSFunction_Read-DkimRecord PSCommand_Read-DkimRecord PSFunction_Read-MtaStsRecord PSCommand_Read-MtaStsRecord PSFunction_Read-MXRecord PSCommand_Read-MXRecord PSFunction_Read-NSRecord PSCommand_Read-NSRecord PSFunction_Read-SPFRecord PSCommand_Read-SPFRecord PSFunction_Read-TlsRptRecord PSCommand_Read-TlsRptRecord PSFunction_Read-WhoisRecord PSCommand_Read-WhoisRecord PSFunction_Remove-MailProvider PSCommand_Remove-MailProvider PSFunction_Resolve-DnsHttpsQuery PSCommand_Resolve-DnsHttpsQuery PSFunction_Set-DnsResolver PSCommand_Set-DnsResolver PSFunction_Test-DNSSEC PSCommand_Test-DNSSEC PSFunction_Test-HttpsCertificate PSCommand_Test-HttpsCertificate PSFunction_Test-MtaSts PSCommand_Test-MtaSts PSIncludes_Function False - 2026-04-24T17:42:26Z - 1.1.6 + 2026-05-08T13:48:38Z + 1.1.7 John Duprey false Module - DNSHealth.nuspec|MailProviders\Microsoft365.json|MailProviders\Sophos.json|DNSHealth.psd1|MailProviders\SymantecCloud.json|MailProviders\SpamTitan.json|MailProviders\AppRiver.json|DNSHealth.psm1|MailProviders\Intermedia.json|MailProviders\_template.json|MailProviders\BarracudaESS.json|MailProviders\Reflexion.json|MailProviders\HornetSecurity.json|MailProviders\Google.json|MailProviders\Proofpoint.json|MailProviders\Null.json|MailProviders\Mimecast.json + DNSHealth.nuspec|MailProviders\SymantecCloud.json|MailProviders\Microsoft365.json|MailProviders\Sophos.json|DNSHealth.psd1|MailProviders\Intermedia.json|MailProviders\SpamTitan.json|MailProviders\AppRiver.json|DNSHealth.psm1|MailProviders\Reflexion.json|MailProviders\_template.json|MailProviders\BarracudaESS.json|MailProviders\Null.json|MailProviders\HornetSecurity.json|MailProviders\Google.json|MailProviders\Proofpoint.json|MailProviders\Mimecast.json a300d2b0-d468-46d1-88a3-e442a76b655b 7.0
- /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.6 + /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.7 From 176aa964d6eeca842fdab25746f639236fd4470d Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 11:17:17 -0400 Subject: [PATCH 62/68] fix: sharing capability based on desired state for file requests --- .../Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 11cba32e784f..95b4c14ac299 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -166,7 +166,7 @@ function Invoke-CIPPStandardSPFileRequests { OneDriveRequestFilesLinkEnabled = $WantedState CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } - SharingCapability = 'External Users and Guests (Anyone)' + SharingCapability = if ($WantedState -eq $true) { 'External Users and Guests (Anyone)' } else { $SharingCapabilityEnum[$CurrentState.SharingCapability] } } Set-CIPPStandardsCompareField -FieldName 'standards.SPFileRequests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From f105398cf41da36b04d74b2fb3cc27db6eaf5c48 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 11:47:42 -0400 Subject: [PATCH 63/68] chore: update dnshealth to 1.1.8 --- .../DNSHealth/{1.1.7 => 1.1.8}/DNSHealth.psd1 | 4 +- .../DNSHealth/{1.1.7 => 1.1.8}/DNSHealth.psm1 | 106 ++++++++++++++++++ .../MailProviders/AppRiver.json | 0 .../MailProviders/BarracudaESS.json | 0 .../MailProviders/Google.json | 0 .../MailProviders/HornetSecurity.json | 0 .../MailProviders/Intermedia.json | 0 .../MailProviders/Microsoft365.json | 0 .../MailProviders/Mimecast.json | 0 .../{1.1.7 => 1.1.8}/MailProviders/Null.json | 0 .../MailProviders/Proofpoint.json | 0 .../MailProviders/Reflexion.json | 0 .../MailProviders/Sophos.json | 0 .../MailProviders/SpamTitan.json | 0 .../MailProviders/SymantecCloud.json | 0 .../MailProviders/_template.json | 0 .../{1.1.7 => 1.1.8}/PSGetModuleInfo.xml | 24 ++-- 17 files changed, 121 insertions(+), 13 deletions(-) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/DNSHealth.psd1 (92%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/DNSHealth.psm1 (96%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/AppRiver.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/BarracudaESS.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Google.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/HornetSecurity.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Intermedia.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Microsoft365.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Mimecast.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Null.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Proofpoint.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Reflexion.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/Sophos.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/SpamTitan.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/SymantecCloud.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/MailProviders/_template.json (100%) rename Modules/DNSHealth/{1.1.7 => 1.1.8}/PSGetModuleInfo.xml (79%) diff --git a/Modules/DNSHealth/1.1.7/DNSHealth.psd1 b/Modules/DNSHealth/1.1.8/DNSHealth.psd1 similarity index 92% rename from Modules/DNSHealth/1.1.7/DNSHealth.psd1 rename to Modules/DNSHealth/1.1.8/DNSHealth.psd1 index be713cdf7187..a9d8c70eac4b 100644 --- a/Modules/DNSHealth/1.1.7/DNSHealth.psd1 +++ b/Modules/DNSHealth/1.1.8/DNSHealth.psd1 @@ -12,7 +12,7 @@ RootModule = 'DNSHealth.psm1' # Version number of this module. - ModuleVersion = '1.1.7' + ModuleVersion = '1.1.8' # Supported PSEditions # CompatiblePSEditions = @() @@ -69,7 +69,7 @@ # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. - FunctionsToExport = @('Read-DmarcPolicy','Read-MtaStsPolicy','Add-MailProvider','Get-MailProvider','Read-DkimRecord','Read-MtaStsRecord','Read-MXRecord','Read-NSRecord','Read-SPFRecord','Read-TlsRptRecord','Read-WhoisRecord','Remove-MailProvider','Resolve-DnsHttpsQuery','Set-DnsResolver','Test-DNSSEC','Test-HttpsCertificate','Test-MtaSts') + FunctionsToExport = @('Read-DmarcPolicy','Read-MtaStsPolicy','Add-MailProvider','Get-MailProvider','Read-AutoDiscoverRecord','Read-DkimRecord','Read-MtaStsRecord','Read-MXRecord','Read-NSRecord','Read-SPFRecord','Read-TlsRptRecord','Read-WhoisRecord','Remove-MailProvider','Resolve-DnsHttpsQuery','Set-DnsResolver','Test-DNSSEC','Test-HttpsCertificate','Test-MtaSts') # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. #CmdletsToExport = '*' diff --git a/Modules/DNSHealth/1.1.7/DNSHealth.psm1 b/Modules/DNSHealth/1.1.8/DNSHealth.psm1 similarity index 96% rename from Modules/DNSHealth/1.1.7/DNSHealth.psm1 rename to Modules/DNSHealth/1.1.8/DNSHealth.psm1 index e299b961092a..113996bb69e9 100644 --- a/Modules/DNSHealth/1.1.7/DNSHealth.psm1 +++ b/Modules/DNSHealth/1.1.8/DNSHealth.psm1 @@ -923,6 +923,112 @@ function Get-MailProvider { return $Providers } #EndRegion './Public/Records/Get-MailProvider.ps1' 95 +#Region './Public/Records/Read-AutoDiscoverRecord.ps1' -1 + +function Read-AutoDiscoverRecord { + <# + .SYNOPSIS + Check AutoDiscover DNS records for a domain + + .DESCRIPTION + Resolves AutoDiscover DNS records (CNAME, A, and SRV) and validates the configuration. + For Microsoft 365, the expected setup is a CNAME pointing to autodiscover.outlook.com. + + .PARAMETER Domain + Domain to check AutoDiscover records for + + .PARAMETER ExpectedTarget + Expected CNAME/SRV target to validate against. Defaults to autodiscover.outlook.com + + .EXAMPLE + PS> Read-AutoDiscoverRecord -Domain example.com + + .EXAMPLE + PS> Read-AutoDiscoverRecord -Domain example.com -ExpectedTarget 'autodiscover.custom.com' + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Domain, + + [Parameter(Mandatory = $false)] + [string]$ExpectedTarget = 'autodiscover.outlook.com' + ) + + $AutoDiscoverDomain = "autodiscover.$Domain" + $SrvDomain = "_autodiscover._tcp.$Domain" + + $ValidationPasses = [System.Collections.Generic.List[string]]::new() + $ValidationWarns = [System.Collections.Generic.List[string]]::new() + $ValidationFails = [System.Collections.Generic.List[string]]::new() + + $CnameRecord = $null + $ARecord = $null + $SrvRecord = $null + $RecordType = 'None' + + # Query autodiscover hostname - A query returns CNAME in chain if present + $DnsResult = Resolve-DnsHttpsQuery -Domain $AutoDiscoverDomain + if ($DnsResult.Answer) { + $CnameRecord = ($DnsResult.Answer | Where-Object { $_.type -eq 5 }).data -replace '\.$' + if ($CnameRecord) { + $RecordType = 'CNAME' + if ($CnameRecord -eq $ExpectedTarget) { + $ValidationPasses.Add("AutoDiscover CNAME correctly points to $ExpectedTarget") | Out-Null + } else { + $ValidationWarns.Add("AutoDiscover CNAME points to $CnameRecord (expected $ExpectedTarget)") | Out-Null + } + } else { + $ARecord = @(($DnsResult.Answer | Where-Object { $_.type -eq 1 }).data) + if ($ARecord.Count -gt 0) { + $RecordType = 'A' + $ValidationWarns.Add("AutoDiscover is configured with an A record ($($ARecord -join ', ')) instead of the recommended CNAME to $ExpectedTarget") | Out-Null + } + } + } + + # Check SRV record + $SrvResult = Resolve-DnsHttpsQuery -Domain $SrvDomain -RecordType SRV + if ($SrvResult.Answer) { + $SrvData = ($SrvResult.Answer | Where-Object { $_.type -eq 33 }).data + if ($SrvData) { + # SRV data format: priority weight port target + $SrvParts = $SrvData -split '\s+' + $SrvTarget = if ($SrvParts.Count -ge 4) { $SrvParts[3] -replace '\.$' } else { $SrvData -replace '\.$' } + $SrvRecord = $SrvTarget + + if ($RecordType -eq 'None') { + $RecordType = 'SRV' + } + + if ($SrvTarget -eq $ExpectedTarget) { + $ValidationPasses.Add("AutoDiscover SRV record correctly points to $ExpectedTarget") | Out-Null + } else { + $ValidationWarns.Add("AutoDiscover SRV record points to $SrvTarget (expected $ExpectedTarget)") | Out-Null + } + } + } + + # No records found at all + if (-not $CnameRecord -and ($null -eq $ARecord -or $ARecord.Count -eq 0) -and -not $SrvRecord) { + $ValidationFails.Add("No AutoDiscover DNS records found (checked CNAME, A, and SRV for $Domain)") | Out-Null + } + + [PSCustomObject]@{ + Domain = $AutoDiscoverDomain + Record = if ($CnameRecord) { $CnameRecord } elseif ($ARecord) { $ARecord -join ', ' } elseif ($SrvRecord) { $SrvRecord } else { $null } + RecordType = $RecordType + CnameRecord = $CnameRecord + ARecord = $ARecord + SrvRecord = $SrvRecord + ExpectedTarget = $ExpectedTarget + ValidationPasses = @($ValidationPasses) + ValidationWarns = @($ValidationWarns) + ValidationFails = @($ValidationFails) + } +} +#EndRegion './Public/Records/Read-AutoDiscoverRecord.ps1' 104 #Region './Public/Records/Read-DkimRecord.ps1' -1 function Read-DkimRecord { diff --git a/Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json b/Modules/DNSHealth/1.1.8/MailProviders/AppRiver.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json rename to Modules/DNSHealth/1.1.8/MailProviders/AppRiver.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json b/Modules/DNSHealth/1.1.8/MailProviders/BarracudaESS.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json rename to Modules/DNSHealth/1.1.8/MailProviders/BarracudaESS.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Google.json b/Modules/DNSHealth/1.1.8/MailProviders/Google.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Google.json rename to Modules/DNSHealth/1.1.8/MailProviders/Google.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json b/Modules/DNSHealth/1.1.8/MailProviders/HornetSecurity.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json rename to Modules/DNSHealth/1.1.8/MailProviders/HornetSecurity.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json b/Modules/DNSHealth/1.1.8/MailProviders/Intermedia.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json rename to Modules/DNSHealth/1.1.8/MailProviders/Intermedia.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json b/Modules/DNSHealth/1.1.8/MailProviders/Microsoft365.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json rename to Modules/DNSHealth/1.1.8/MailProviders/Microsoft365.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json b/Modules/DNSHealth/1.1.8/MailProviders/Mimecast.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json rename to Modules/DNSHealth/1.1.8/MailProviders/Mimecast.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Null.json b/Modules/DNSHealth/1.1.8/MailProviders/Null.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Null.json rename to Modules/DNSHealth/1.1.8/MailProviders/Null.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json b/Modules/DNSHealth/1.1.8/MailProviders/Proofpoint.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json rename to Modules/DNSHealth/1.1.8/MailProviders/Proofpoint.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json b/Modules/DNSHealth/1.1.8/MailProviders/Reflexion.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json rename to Modules/DNSHealth/1.1.8/MailProviders/Reflexion.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/Sophos.json b/Modules/DNSHealth/1.1.8/MailProviders/Sophos.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/Sophos.json rename to Modules/DNSHealth/1.1.8/MailProviders/Sophos.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json b/Modules/DNSHealth/1.1.8/MailProviders/SpamTitan.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json rename to Modules/DNSHealth/1.1.8/MailProviders/SpamTitan.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json b/Modules/DNSHealth/1.1.8/MailProviders/SymantecCloud.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json rename to Modules/DNSHealth/1.1.8/MailProviders/SymantecCloud.json diff --git a/Modules/DNSHealth/1.1.7/MailProviders/_template.json b/Modules/DNSHealth/1.1.8/MailProviders/_template.json similarity index 100% rename from Modules/DNSHealth/1.1.7/MailProviders/_template.json rename to Modules/DNSHealth/1.1.8/MailProviders/_template.json diff --git a/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml b/Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml similarity index 79% rename from Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml rename to Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml index 441517b7b04a..a5693b78886a 100644 --- a/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml +++ b/Modules/DNSHealth/1.1.8/PSGetModuleInfo.xml @@ -7,13 +7,13 @@ DNSHealth - 1.1.7 + 1.1.8 Module CIPP DNS Health Check Module John Duprey johnduprey 2023 John Duprey -
2026-05-08T13:48:38-04:00
+
2026-05-08T15:46:09-04:00
@@ -51,6 +51,7 @@ Read-MtaStsPolicy Add-MailProvider Get-MailProvider + Read-AutoDiscoverRecord Read-DkimRecord Read-MtaStsRecord Read-MXRecord @@ -84,6 +85,7 @@ Read-MtaStsPolicy Add-MailProvider Get-MailProvider + Read-AutoDiscoverRecord Read-DkimRecord Read-MtaStsRecord Read-MXRecord @@ -127,15 +129,15 @@ True True 0 - 491 - 31557 - 5/8/2026 1:48:38 PM -04:00 - 5/8/2026 1:48:38 PM -04:00 - 5/8/2026 1:48:38 PM -04:00 - PSModule PSFunction_Read-DmarcPolicy PSCommand_Read-DmarcPolicy PSFunction_Read-MtaStsPolicy PSCommand_Read-MtaStsPolicy PSFunction_Add-MailProvider PSCommand_Add-MailProvider PSFunction_Get-MailProvider PSCommand_Get-MailProvider PSFunction_Read-DkimRecord PSCommand_Read-DkimRecord PSFunction_Read-MtaStsRecord PSCommand_Read-MtaStsRecord PSFunction_Read-MXRecord PSCommand_Read-MXRecord PSFunction_Read-NSRecord PSCommand_Read-NSRecord PSFunction_Read-SPFRecord PSCommand_Read-SPFRecord PSFunction_Read-TlsRptRecord PSCommand_Read-TlsRptRecord PSFunction_Read-WhoisRecord PSCommand_Read-WhoisRecord PSFunction_Remove-MailProvider PSCommand_Remove-MailProvider PSFunction_Resolve-DnsHttpsQuery PSCommand_Resolve-DnsHttpsQuery PSFunction_Set-DnsResolver PSCommand_Set-DnsResolver PSFunction_Test-DNSSEC PSCommand_Test-DNSSEC PSFunction_Test-HttpsCertificate PSCommand_Test-HttpsCertificate PSFunction_Test-MtaSts PSCommand_Test-MtaSts PSIncludes_Function + 495 + 32431 + 5/8/2026 3:46:09 PM -04:00 + 5/8/2026 3:46:09 PM -04:00 + 5/8/2026 3:46:09 PM -04:00 + PSModule PSFunction_Read-DmarcPolicy PSCommand_Read-DmarcPolicy PSFunction_Read-MtaStsPolicy PSCommand_Read-MtaStsPolicy PSFunction_Add-MailProvider PSCommand_Add-MailProvider PSFunction_Get-MailProvider PSCommand_Get-MailProvider PSFunction_Read-AutoDiscoverRecord PSCommand_Read-AutoDiscoverRecord PSFunction_Read-DkimRecord PSCommand_Read-DkimRecord PSFunction_Read-MtaStsRecord PSCommand_Read-MtaStsRecord PSFunction_Read-MXRecord PSCommand_Read-MXRecord PSFunction_Read-NSRecord PSCommand_Read-NSRecord PSFunction_Read-SPFRecord PSCommand_Read-SPFRecord PSFunction_Read-TlsRptRecord PSCommand_Read-TlsRptRecord PSFunction_Read-WhoisRecord PSCommand_Read-WhoisRecord PSFunction_Remove-MailProvider PSCommand_Remove-MailProvider PSFunction_Resolve-DnsHttpsQuery PSCommand_Resolve-DnsHttpsQuery PSFunction_Set-DnsResolver PSCommand_Set-DnsResolver PSFunction_Test-DNSSEC PSCommand_Test-DNSSEC PSFunction_Test-HttpsCertificate PSCommand_Test-HttpsCertificate PSFunction_Test-MtaSts PSCommand_Test-MtaSts PSIncludes_Function False - 2026-05-08T13:48:38Z - 1.1.7 + 2026-05-08T15:46:09Z + 1.1.8 John Duprey false Module @@ -144,7 +146,7 @@ 7.0
- /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.7 + /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.8 From d9c6203983e5a30d877bc8cccb318866bffc209e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 12:59:37 -0400 Subject: [PATCH 64/68] fix: update expiration days logic for SharePoint and OneDrive file requests --- .../Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 95b4c14ac299..e61f462f717e 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -164,8 +164,8 @@ function Invoke-CIPPStandardSPFileRequests { $ExpectedValue = @{ CoreRequestFilesLinkEnabled = $WantedState OneDriveRequestFilesLinkEnabled = $WantedState - CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } - OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } + CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays) { $ExpirationDays } else { $CurrentState.CoreRequestFilesLinkExpirationInDays } + OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays) { $ExpirationDays } else { $CurrentState.OneDriveRequestFilesLinkExpirationInDays } SharingCapability = if ($WantedState -eq $true) { 'External Users and Guests (Anyone)' } else { $SharingCapabilityEnum[$CurrentState.SharingCapability] } } Set-CIPPStandardsCompareField -FieldName 'standards.SPFileRequests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant From 933a6dcdab4c5c29987482dbe5dee165ffad1922 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Fri, 8 May 2026 19:54:26 +0200 Subject: [PATCH 65/68] Fix for 5979 --- .../Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 11cba32e784f..5219de031e03 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -157,8 +157,8 @@ function Invoke-CIPPStandardSPFileRequests { $CurrentValue = @{ CoreRequestFilesLinkEnabled = $CurrentState.CoreRequestFilesLinkEnabled OneDriveRequestFilesLinkEnabled = $CurrentState.OneDriveRequestFilesLinkEnabled - CoreRequestFilesLinkExpirationInDays = $CurrentState.CoreRequestFilesLinkExpirationInDays - OneDriveRequestFilesLinkExpirationInDays = $CurrentState.OneDriveRequestFilesLinkExpirationInDays + CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $CurrentState.CoreRequestFilesLinkExpirationInDays } else { $null } + OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $CurrentState.OneDriveRequestFilesLinkExpirationInDays } else { $null } SharingCapability = $SharingCapabilityEnum[$CurrentState.SharingCapability] } $ExpectedValue = @{ From 4a466be5c371ed280c758361e83d4676f3b82923 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 14:57:25 -0400 Subject: [PATCH 66/68] fix: prevent stale template list from skewing applied standards report --- .../Standards/Invoke-ListStandardsCompare.ps1 | 34 ++++++++++++++++++ .../Invoke-listStandardTemplates.ps1 | 36 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 index 0b94eb63d919..b3f9b53f8c4c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListStandardsCompare.ps1 @@ -13,6 +13,26 @@ function Invoke-ListStandardsCompare { $TenantFilter = $Request.Query.tenantFilter $TemplateFilter = $Request.Query.templateId + # Get-CIPPStandards is the authoritative source for what is currently in scope. + $StandardParams = @{} + if ($TemplateFilter) { $StandardParams.TemplateId = $TemplateFilter } + if ($TenantFilter) { $StandardParams.TenantFilter = $TenantFilter } + $StandardList = Get-CIPPStandards @StandardParams + + $ScopedTemplateGuids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ScopedQuarantineNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Entry in $StandardList) { + switch ($Entry.Standard) { + { $_ -in @('IntuneTemplate', 'ConditionalAccessTemplate') } { + if ($Entry.Settings.TemplateList.value) { $null = $ScopedTemplateGuids.Add($Entry.Settings.TemplateList.value) } + } + 'QuarantineTemplate' { + $DisplayName = $Entry.Settings.displayName.value ?? $Entry.Settings.displayName + if ($DisplayName) { $null = $ScopedQuarantineNames.Add($DisplayName) } + } + } + } + $Filters = [system.collections.generic.list[string]]::new() if ($TenantFilter) { $Filters.Add("PartitionKey eq '{0}'" -f $TenantFilter) @@ -34,6 +54,20 @@ function Invoke-ListStandardsCompare { $FieldValue = $Standard.Value $Tenant = $Standard.PartitionKey + # Skip rows for template types no longer in scope per the current standard list. + if ($FieldName -match '^standards\.(IntuneTemplate|ConditionalAccessTemplate)\.(.+)$') { + if (-not $ScopedTemplateGuids.Contains($Matches[2])) { continue } + } elseif ($ScopedQuarantineNames.Count -gt 0 -and $FieldName -match '^standards\.QuarantineTemplate\.(.+)$') { + # Decode hex-encoded display name and check if it's still in scope + $HexEncoded = $Matches[1] + $Chars = [System.Collections.Generic.List[char]]::new() + for ($i = 0; $i -lt $HexEncoded.Length; $i += 2) { + $Chars.Add([char][Convert]::ToInt32($HexEncoded.Substring($i, 2), 16)) + } + $DecodedName = -join $Chars + if (-not $ScopedQuarantineNames.Contains($DecodedName)) { continue } + } + # decode field names that are hex encoded (e.g. QuarantineTemplates) if ($FieldName -match '^(standards\.QuarantineTemplate\.)(.+)$') { $Prefix = $Matches[1] diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index 698fbb42deb3..de82fde161b3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -35,6 +35,42 @@ function Invoke-listStandardTemplates { $Data.excludedTenants = @() } } + + # Re-expand TemplateList-Tags live so stale addedFields snapshots don't show removed templates + if ($Data.standards) { + foreach ($StandardName in $Data.standards.PSObject.Properties.Name) { + $StandardConfig = $Data.standards.$StandardName + $Items = if ($StandardConfig -is [System.Collections.IEnumerable] -and $StandardConfig -isnot [string]) { $StandardConfig } else { @($StandardConfig) } + foreach ($Item in $Items) { + if ($Item.'TemplateList-Tags' -and $Item.'TemplateList-Tags'.value) { + if (-not $IntuneTemplatesCache) { + $IntuneTable = Get-CippTable -tablename 'templates' + $IntuneFilter = "PartitionKey eq 'IntuneTemplate'" + $IntuneTemplatesCache = Get-CIPPAzDataTableEntity @IntuneTable -Filter $IntuneFilter + } + $PackageName = $Item.'TemplateList-Tags'.value + $LiveExpanded = @($IntuneTemplatesCache | Where-Object package -EQ $PackageName | ForEach-Object { + $TplJson = $_.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + [pscustomobject]@{ + GUID = $_.RowKey + displayName = if ($TplJson.displayName) { $TplJson.displayName } else { $_.RowKey } + name = if ($TplJson.displayName) { $TplJson.displayName } else { $_.RowKey } + } + }) + if ($Item.'TemplateList-Tags'.addedFields) { + $Item.'TemplateList-Tags'.addedFields | Add-Member -NotePropertyName 'templates' -NotePropertyValue $LiveExpanded -Force + } + if ($Item.'TemplateList-Tags'.rawData) { + $Item.'TemplateList-Tags'.rawData | Add-Member -NotePropertyName 'templates' -NotePropertyValue $LiveExpanded -Force + } + if (-not $Item.'TemplateList-Tags'.addedFields -and -not $Item.'TemplateList-Tags'.rawData) { + $Item.'TemplateList-Tags' | Add-Member -NotePropertyName 'addedFields' -NotePropertyValue ([pscustomobject]@{ templates = $LiveExpanded }) -Force + } + } + } + } + } + $Data } } | Sort-Object -Property templateName From bf8a33a6dae91aa0aaa3e880b70ae0acedd078a5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 17:09:55 -0400 Subject: [PATCH 67/68] feat: add Invoke-ListResellerRelationshipLink function for retrieving reseller relationship links implements https://github.com/KelvinTegelaar/CIPP/issues/5963 --- .../Invoke-ListResellerRelationshipLink.ps1 | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListResellerRelationshipLink.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListResellerRelationshipLink.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListResellerRelationshipLink.ps1 new file mode 100644 index 000000000000..1d48e1a8591e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListResellerRelationshipLink.ps1 @@ -0,0 +1,37 @@ +function Invoke-ListResellerRelationshipLink { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Relationship.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $StatusCode = [HttpStatusCode]::OK + $Body = @{} + + # Get the indirect reseller relationship invite link + try { + $RelationshipRequest = New-GraphGetRequest -uri 'https://api.partnercenter.microsoft.com/v1/customers/relationshiprequests?dualRoleIndirectRelationship=false' -scope 'https://api.partnercenter.microsoft.com/.default' -NoAuthCheck $true + $Body.inviteUrl = $RelationshipRequest.url + } catch { + $Body.inviteUrl = $null + $Body.inviteUrlError = "Failed to retrieve relationship invite link: $($_.Exception.Message)" + Write-Information "ListResellerRelationshipLink: Failed to get invite URL - $($_.Exception.Message)" + } + + # Get indirect providers (for Tier 2 / indirect resellers) + try { + $RelationshipsResponse = New-GraphGetRequest -uri 'https://api.partnercenter.microsoft.com/v1/relationships?relationship_type=IsIndirectResellerOf' -scope 'https://api.partnercenter.microsoft.com/.default' -NoAuthCheck $true + $Body.indirectProviders = @($RelationshipsResponse.items) + } catch { + $Body.indirectProviders = @() + Write-Information "ListResellerRelationshipLink: Failed to get indirect providers - $($_.Exception.Message)" + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} From e99b4aaabd288134f1ea5fed81ad071dc10d19c5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 19:17:20 -0400 Subject: [PATCH 68/68] chore: bump version to 10.4.4 --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 804b0ab6ca5e..473ab1bfce11 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.4.3", + "defaultVersion": "10.4.4", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 44c1a8619b2b..622a6f75b792 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.4.3 +10.4.4