diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index 59ffac3d4b91..bd8fe152ce5e 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -82,4 +82,4 @@ "Pages": [], "Hidden": false } -] \ No newline at end of file +] diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 index bee6da069645..be40e229a330 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 @@ -25,6 +25,13 @@ function Push-IntuneReportExportSubmit { 'UserId', 'UserName', 'EmailAddress' ) } + 'AppInstallStatusAggregate' { + @( + 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', + 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', + 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' + ) + } default { throw "Unknown Intune report '$ReportName'" } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index 99d2fb1ad5fe..bc672c406a38 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -49,11 +49,11 @@ function Get-CIPPAlertIntunePolicyConflicts { } $AlertableStatuses = @( - if ($Config.AlertErrors) { 'error'; 'failed' } + if ($Config.AlertErrors) { 'error' } if ($Config.AlertConflicts) { 'conflict' } ) - if (-not $AlertableStatuses) { + if (-not $AlertableStatuses -and -not ($Config.IncludeApplications -and $Config.AlertErrors)) { return } @@ -64,56 +64,66 @@ function Get-CIPPAlertIntunePolicyConflicts { $Issues = [System.Collections.Generic.List[object]]::new() - if ($Config.IncludePolicies) { - try { - $ManagedDevices = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,userPrincipalName&`$expand=deviceConfigurationStates(`$select=displayName,state,settingStates)" -tenantid $TenantFilter - - foreach ($Device in $ManagedDevices) { - $PolicyStates = $Device.deviceConfigurationStates | Where-Object { $_.state -and ($AlertableStatuses -contains $_.state) } - foreach ($State in $PolicyStates) { - $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 - }) + if ($Config.IncludePolicies -and $AlertableStatuses) { + $PolicySources = @( + @{ Type = 'IntuneDeviceCompliancePolicies'; Kind = 'Compliance' } + @{ Type = 'IntuneDeviceConfigurations'; Kind = 'Configuration' } + ) + + foreach ($Source in $PolicySources) { + try { + $PolicyItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Source.Type | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($PolicyItem in $PolicyItems) { + $Policy = try { $PolicyItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $Policy.id) { continue } + + $StatusItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type "$($Source.Type)_$($Policy.id)" | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($StatusItem in $StatusItems) { + $State = try { $StatusItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $State.status -or ($AlertableStatuses -notcontains $State.status.ToLowerInvariant())) { continue } + + $Issues.Add([PSCustomObject]@{ + Message = "$($Source.Kind) policy '$($Policy.displayName)' is $($State.status) on device '$($State.deviceDisplayName)' for $($State.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Policy' + PolicyType = $Source.Kind + PolicyName = $Policy.displayName + IssueStatus = $State.status + DeviceName = $State.deviceDisplayName + UserPrincipalName = $State.userPrincipalName + DeviceId = $State.id + }) + } } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached $($Source.Kind) policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } - if ($Config.IncludeApplications) { + if ($Config.IncludeApplications -and $Config.AlertErrors) { try { - $Applications = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName&`$expand=deviceStatuses(`$select=installState,deviceName,userPrincipalName,deviceId)" -tenantid $TenantFilter - - foreach ($App in $Applications) { - $BadStatuses = $App.deviceStatuses | Where-Object { - $_.installState -and ($AlertableStatuses -contains $_.installState.ToLowerInvariant()) - } - - foreach ($Status in $BadStatuses) { - $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 - }) - } + $AppItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppInstallStatusAggregate' | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($AppItem in $AppItems) { + $App = try { $AppItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $App -or [int]($App.failedDeviceCount) -le 0) { continue } + + $Issues.Add([PSCustomObject]@{ + Message = "App '$($App.displayName)' failed to install on $($App.failedDeviceCount) device(s) ($($App.failedDevicePercentage)%)." + Tenant = $TenantFilter + Type = 'Application' + AppName = $App.displayName + IssueStatus = 'failed' + FailedDeviceCount = [int]$App.failedDeviceCount + FailedUserCount = [int]$App.failedUserCount + FailedPercentage = $App.failedDevicePercentage + Platform = $App.platform + }) } } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached Intune app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/CIPPCore.psd1 b/Modules/CIPPCore/CIPPCore.psd1 index 15305aaf91a5..f995ce80f6ed 100644 --- a/Modules/CIPPCore/CIPPCore.psd1 +++ b/Modules/CIPPCore/CIPPCore.psd1 @@ -45,7 +45,7 @@ # RequiredModules = @() # Assemblies that must be loaded prior to importing this module - # RequiredAssemblies = @() + RequiredAssemblies = @('..\..\Shared\CIPPSharp\bin\CIPPSharp.dll') # Script files (.ps1) that are run in the caller's environment prior to importing this module. # ScriptsToProcess = @() diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 new file mode 100644 index 000000000000..eeb49beeb868 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 @@ -0,0 +1,113 @@ +function Test-CippApiClientRoleGrant { + <# + .SYNOPSIS + Validates that the caller of an API client management action is permitted to + create, modify, reset, or delete an API client holding the supplied role(s). + + .DESCRIPTION + Prevents privilege escalation through the ApiClients table. The ExecApiClient + endpoint is gated at CIPP.Extension.ReadWrite (editor-grantable), but the role + assigned to an API client becomes that client's effective privilege at request + time (see Test-CIPPAccess). Without this check an editor could mint a client + with the 'superadmin' role, or reset the secret of an existing superadmin + client, and escalate. + + A caller may only manage a client whose effective permissions are a subset of + the caller's own effective permissions. Superadmins may grant any role. Roles + are compared by computed permission set (built-in and custom), matching exactly + how Test-CIPPAccess evaluates an API client (single role, no base-role ceiling). + + .PARAMETER Request + The HTTP request, used to resolve the caller's roles. Handles both interactive + user principals and API-client principals. + + .PARAMETER Role + One or more roles to validate, e.g. the requested new role and the existing + client's current role. An empty/missing role is treated as the runtime + 'cipp-api' fallback that Test-CIPPAccess applies to roleless clients. + + .OUTPUTS + [pscustomobject] with Allowed [bool] and Message [string]. Fails closed. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Request, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Role + ) + + function New-Denial { + param([string]$Message) + [pscustomobject]@{ Allowed = $false; Message = $Message } + } + + # Resolve the caller's roles. Mirror Test-CIPPAccess's principal detection so this + # works whether the caller is an interactive user or an API client. + try { + if ($Request.Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Request.Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $CallerClient = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name' + if ($CallerClient.Role) { + $CallerRoles = @($CallerClient.Role) + } else { + $CallerRoles = @('cipp-api') + } + } else { + $CallerRoles = @(Get-CIPPAccessRole -Request $Request) + } + } catch { + return (New-Denial "Unable to resolve your roles for authorization: $($_.Exception.Message)") + } + + if (-not $CallerRoles -or $CallerRoles.Count -eq 0) { + return (New-Denial 'Unable to determine your roles; cannot authorize this API client operation.') + } + + # Superadmin may grant or manage any role. + if ($CallerRoles -contains 'superadmin') { + return [pscustomobject]@{ Allowed = $true; Message = $null } + } + + $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly') + $CallerPermissions = @(Get-CippAllowedPermissions -UserRoles $CallerRoles) + + # Normalize: a roleless client resolves to the 'cipp-api' fallback at request time, + # so validate against that to mirror real client evaluation and stay future-proof. + $TargetRoles = @($Role | ForEach-Object { + if ([string]::IsNullOrWhiteSpace($_)) { 'cipp-api' } else { $_.Trim() } + } | Sort-Object -Unique) + + foreach ($TargetRole in $TargetRoles) { + # anonymous/authenticated are SWA placeholder roles, never valid client roles. + if (@('anonymous', 'authenticated') -contains $TargetRole) { + return (New-Denial "The role '$TargetRole' cannot be assigned to an API client.") + } + + # Confirm the role exists. 'cipp-api' is an implicit runtime fallback and may + # legitimately not be present in the CustomRoles table, so it is exempt. + if ($DefaultRoles -notcontains $TargetRole -and $TargetRole -ne 'cipp-api') { + try { + $null = Get-CIPPRolePermissions -RoleName $TargetRole + } catch { + return (New-Denial "The role '$TargetRole' does not exist.") + } + } + + # Effective permissions a client holding this role would receive, computed the + # same way Test-CIPPAccess evaluates an API client (single role, no base ceiling). + $RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole)) + $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ }) + + if ($Escalation.Count -gt 0) { + return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.") + } + } + + return [pscustomobject]@{ Allowed = $true; Message = $null } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 index c772192523cd..242e0657ba34 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 @@ -31,15 +31,19 @@ function Start-IntuneReportExportOrchestrator { return } - $Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks $LicensedTenants.Count + $ReportNames = @('AppInvRawData', 'AppInstallStatusAggregate') + + $Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks ($LicensedTenants.Count * $ReportNames.Count) $Batch = foreach ($Tenant in $LicensedTenants) { - [PSCustomObject]@{ - FunctionName = 'IntuneReportExportSubmit' - TenantFilter = $Tenant.defaultDomainName - ReportName = 'AppInvRawData' - QueueId = $Queue.RowKey - QueueName = "Intune Export Submit - $($Tenant.defaultDomainName)" + foreach ($ReportName in $ReportNames) { + [PSCustomObject]@{ + FunctionName = 'IntuneReportExportSubmit' + TenantFilter = $Tenant.defaultDomainName + ReportName = $ReportName + QueueId = $Queue.RowKey + QueueName = "Intune Export Submit ($ReportName) - $($Tenant.defaultDomainName)" + } } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index da1a515e285b..b4110b8b18ba 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -28,10 +28,10 @@ function Start-UserTasksOrchestrator { } } else { $4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $24HoursAgo = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - # Pending = orchestrator queued, Running = actively executing - # Pick up: Planned, Failed-Planned, stuck Pending (>24hr), or stuck Running (>4hr for large AllTenants tasks) - $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$24HoursAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" + $1HourAgo = (Get-Date).AddHours(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + # Pending = orchestrator claimed but executor not yet started, Running = actively executing + # Pick up: Planned, Failed-Planned, stuck Pending (>1hr - orphaned claim), or stuck Running/Processing (>4hr for large AllTenants tasks) + $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$1HourAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter } diff --git a/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 b/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 new file mode 100644 index 000000000000..5db262728b0c --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 @@ -0,0 +1,33 @@ +function Get-CippCustomScriptAllowedCommand { + <# + .SYNOPSIS + Single source of truth for the custom-test command allowlist. + + .DESCRIPTION + Used by both Test-CustomScriptSecurity (static pre-check) and + New-CippSandboxInitialSessionState (the ConstrainedLanguage runspace) so the + validator and the sandbox can never drift apart. + + Notes: + - New-Object is intentionally NOT allowed — it is the primary sandbox-escape + vector and is blocked by ConstrainedLanguage anyway. + - Data access is limited to Get-CIPPTestData. The lower-level New-CIPPDbRequest / + Get-CIPPDbItem are not exposed: the sandbox serves pre-fetched, tenant-locked + cache data only. + #> + [CmdletBinding()] + param() + + @( + # Data shaping + 'ForEach-Object', 'Where-Object', 'Select-Object', 'Sort-Object', 'Group-Object', + 'Measure-Object', 'Compare-Object', 'Get-Unique', 'Get-Member', 'Select-String', + + # Conversion / utility + 'ConvertTo-Json', 'ConvertFrom-Json', 'Get-Date', 'Get-Random', 'New-TimeSpan', + 'New-Guid', 'Write-Output', + + # CIPP read-only data access (provided as a CLM-safe proxy in the sandbox) + 'Get-CIPPTestData' + ) +} diff --git a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 new file mode 100644 index 000000000000..daa7b4a32e14 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 @@ -0,0 +1,78 @@ +function Get-CippSandboxData { + <# + .SYNOPSIS + Pre-fetches the tenant-locked cache data a custom test requests. + + .DESCRIPTION + Runs on the trusted (FullLanguage) side before the script enters the sandbox. + Inspects the script AST for Get-CIPPTestData calls, resolves each requested -Type, + and fetches that data for the supplied tenant via the real Get-CIPPTestData. The + result is a hashtable keyed by Type that the sandbox proxy serves. + + Because only the requested types for THIS tenant are fetched and injected, the + sandbox is structurally unable to read any other tenant's data. + + -Type must be a string literal. Dynamic type names cannot be pre-fetched and are + rejected with a clear error (rather than silently returning empty data). + + .PARAMETER ScriptContent + The (already text-replaced, already validated) script content. + + .PARAMETER TenantFilter + The tenant to fetch data for. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptContent, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $Ast = [System.Management.Automation.Language.Parser]::ParseInput($ScriptContent, [ref]$null, [ref]$null) + + $Calls = $Ast.FindAll({ + param($Node) + $Node -is [System.Management.Automation.Language.CommandAst] -and + $Node.GetCommandName() -eq 'Get-CIPPTestData' + }, $true) + + $Data = @{} + + foreach ($Call in $Calls) { + $Type = $null + $HasType = $false + $TypeIsLiteral = $true + + for ($i = 0; $i -lt $Call.CommandElements.Count; $i++) { + $Element = $Call.CommandElements[$i] + if ($Element -is [System.Management.Automation.Language.CommandParameterAst] -and $Element.ParameterName -ieq 'Type') { + $HasType = $true + $Value = if ($Element.Argument) { + $Element.Argument + } elseif ($i + 1 -lt $Call.CommandElements.Count) { + $Call.CommandElements[$i + 1] + } else { + $null + } + if ($Value -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $Type = $Value.Value + } else { + $TypeIsLiteral = $false + } + } + } + + if ($HasType -and -not $TypeIsLiteral) { + throw "Custom test sandbox requires a literal -Type for Get-CIPPTestData (for example: Get-CIPPTestData -Type 'Users'). Dynamic or computed type names are not supported." + } + + $Key = if ($Type) { $Type } else { '' } + if (-not $Data.ContainsKey($Key)) { + $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type) + } + } + + return $Data +} diff --git a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 index 134f8aeeeb79..1e27e7b39b31 100644 --- a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 +++ b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 @@ -352,7 +352,17 @@ function Get-GraphRequestList { if (!$QueueThresholdExceeded) { #nextLink should ONLY be used in direct calls with manual pagination. It should not be used in queueing - if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') { $GraphRequest.uri = $nextLink } + if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') { + try { + $ParsedNextLink = [System.Uri]$nextLink + if ($ParsedNextLink.Host -ne 'graph.microsoft.com') { + throw "Invalid nextLink host: $($ParsedNextLink.Host)" + } + } catch { + throw "Invalid nextLink URL: $nextLink" + } + $GraphRequest.uri = $nextLink + } $GraphRequestResults = New-GraphGetRequest @GraphRequest -Caller $Caller -ErrorAction Stop $GraphRequestResults = $GraphRequestResults | Select-Object *, @{n = 'Tenant'; e = { $TenantFilter } }, @{n = 'CippStatus'; e = { 'Good' } } diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 06f258ec80f4..fda21748c514 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -124,6 +124,7 @@ function Invoke-CIPPDBCacheCollection { 'IntuneScripts' 'IntuneReusableSettings' 'DetectedApps' + 'IntuneAppInstallStatus' 'MDEOnboarding' ) Compliance = @( diff --git a/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 b/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 new file mode 100644 index 000000000000..1ab201e70838 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 @@ -0,0 +1,114 @@ +function Invoke-CippSandboxScript { + <# + .SYNOPSIS + Executes custom-test script content inside a ConstrainedLanguage sandbox runspace. + + .DESCRIPTION + Compiles and runs the script in a fresh runspace built from the cached sandbox + InitialSessionState (ConstrainedLanguage + command allowlist + Get-CIPPTestData + proxy). The script is compiled via AddScript on the trusted side — never via + [scriptblock]::Create inside the runspace (which CLM blocks) — so it executes + constrained. + + Pre-fetched, tenant-locked cache data is injected as $CIPPSandboxData for the proxy. + Parameters are bound by name; passing a parameter the script does not declare is + harmless (ignored), matching how the test runner supplies -TenantFilter. + + The sandbox imports no CIPP modules — it only needs the proxy and injected data — so + runspace creation is cheap. (A runspace pool can be layered on later if profiling + shows creation is hot; per-call creation keeps it concurrency-safe for now.) + + .PARAMETER ScriptContent + The validated, text-replaced script to run. + + .PARAMETER SandboxData + Hashtable of pre-fetched cache data keyed by Type (from Get-CippSandboxData). + + .PARAMETER ScriptParameters + Named parameters to bind to the script (e.g. TenantFilter and custom params). + + .PARAMETER TimeoutSeconds + Wall-clock execution limit. A script that exceeds it (e.g. an infinite loop) has its + pipeline stopped and is reported as a terminating timeout. + + .OUTPUTS + PSCustomObject with Output, Errors, HadErrors, Terminating, TimedOut. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptContent, + + [Parameter(Mandatory = $false)] + [hashtable]$SandboxData = @{}, + + [Parameter(Mandatory = $false)] + [hashtable]$ScriptParameters = @{}, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 600)] + [int]$TimeoutSeconds = 60 + ) + + # Cache the (reusable) ISS template for the lifetime of the worker process. + if (-not $script:CippSandboxInitialSessionState) { + $script:CippSandboxInitialSessionState = New-CippSandboxInitialSessionState + } + + $Runspace = [runspacefactory]::CreateRunspace($script:CippSandboxInitialSessionState) + $Runspace.Open() + try { + # Trusted host (FullLanguage) seeds the locked tenant's data for the proxy. + $Runspace.SessionStateProxy.SetVariable('CIPPSandboxData', $SandboxData) + + $PowerShell = [powershell]::Create() + $PowerShell.Runspace = $Runspace + try { + $null = $PowerShell.AddScript($ScriptContent) + foreach ($Key in $ScriptParameters.Keys) { + $null = $PowerShell.AddParameter($Key, $ScriptParameters[$Key]) + } + + # Run asynchronously so a runaway script can be cancelled on timeout. + $AsyncResult = $PowerShell.BeginInvoke() + $Completed = $AsyncResult.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + + if (-not $Completed) { + # Exceeded the wall-clock limit (e.g. infinite loop). Stop the pipeline. + try { $PowerShell.Stop() } catch {} + return [PSCustomObject]@{ + Output = @() + Errors = @("Script exceeded the ${TimeoutSeconds}s execution limit and was cancelled.") + HadErrors = $true + Terminating = $true + TimedOut = $true + } + } + + try { + $Output = $PowerShell.EndInvoke($AsyncResult) + } catch { + # Terminating error inside the script. + return [PSCustomObject]@{ + Output = @() + Errors = @($_) + HadErrors = $true + Terminating = $true + TimedOut = $false + } + } + + return [PSCustomObject]@{ + Output = $Output + Errors = @($PowerShell.Streams.Error) + HadErrors = $PowerShell.HadErrors + Terminating = $false + TimedOut = $false + } + } finally { + $PowerShell.Dispose() + } + } finally { + $Runspace.Dispose() + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 index fbce430ac03c..ecbb09963557 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 @@ -38,7 +38,15 @@ function New-CIPPIntuneTemplate { } 'managedAppPolicies' { $Type = 'AppProtection' - $Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($urlname)('$($ID)')" -tenantid $TenantFilter + $AppProtectionUrl = switch (($ODataType -replace '#microsoft.graph.', '')) { + 'androidManagedAppProtection' { 'androidManagedAppProtections' } + 'iosManagedAppProtection' { 'iosManagedAppProtections' } + 'windowsManagedAppProtection' { 'windowsManagedAppProtections' } + 'mdmWindowsInformationProtectionPolicy' { 'mdmWindowsInformationProtectionPolicies' } + 'targetedManagedAppConfiguration' { 'targetedManagedAppConfigurations' } + default { 'managedAppPolicies' } + } + $Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($AppProtectionUrl)('$($ID)')" -tenantid $TenantFilter $DisplayName = $Template.displayName $TemplateJson = ConvertTo-Json -InputObject $Template -Depth 100 -Compress } diff --git a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 index 530a45c29fe5..88b2a0bcf661 100644 --- a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 @@ -248,108 +248,118 @@ function New-CIPPTemplateRun { } 'intunecompliance' { Write-Information "Create Intune Compliance Policy Templates for $TenantFilter" - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$top=999' -tenantid $TenantFilter | ForEach-Object { - $Policy = $_ - $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $_) - $ExistingPolicy = $ExistingTemplates | Where-Object { $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 - if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { - "Intune Compliance Policy $($_.DisplayName) found, SHA matches, skipping template creation" - continue - } + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$top=999' -tenantid $TenantFilter + foreach ($Policy in $Policies) { + try { + $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { + "Intune Compliance Policy $($Policy.displayName) found, SHA matches, skipping template creation" + continue + } - $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'deviceCompliancePolicies' -ID $Policy.id - if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { - "Intune Compliance Policy $($Template.DisplayName) found, updating template" - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $ExistingPolicy.GUID - } | ConvertTo-Json -Compress + $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'deviceCompliancePolicies' -ID $Policy.id + if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { + "Intune Compliance Policy $($Template.DisplayName) found, updating template" + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $ExistingPolicy.GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = $ExistingPolicy.GUID - PartitionKey = 'IntuneTemplate' - Package = $ExistingPolicy.Package - GUID = $ExistingPolicy.GUID - SHA = $Hash - Source = $ExistingPolicy.Source - } -Force - } else { - "Intune Compliance Policy $($Template.DisplayName) not found in existing templates, creating new template" - $GUID = (New-Guid).GUID - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $GUID - } | ConvertTo-Json -Compress + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = $ExistingPolicy.GUID + PartitionKey = 'IntuneTemplate' + Package = $ExistingPolicy.Package + GUID = $ExistingPolicy.GUID + SHA = $Hash + Source = $ExistingPolicy.Source + } -Force + } else { + "Intune Compliance Policy $($Template.DisplayName) not found in existing templates, creating new template" + $GUID = (New-Guid).GUID + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = "$GUID" - PartitionKey = 'IntuneTemplate' - SHA = $Hash - GUID = "$GUID" - Source = $TenantFilter - } -Force + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = "$GUID" + PartitionKey = 'IntuneTemplate' + SHA = $Hash + GUID = "$GUID" + Source = $TenantFilter + } -Force + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + "Failed to create a template of the Intune Compliance Policy with ID: $($Policy.id). Error: $ErrorMessage" } } } 'intuneprotection' { Write-Information "Create Intune Protection Policy Templates for $TenantFilter" - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies?$top=999' -tenantid $TenantFilter | ForEach-Object { - $Policy = $_ - $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $_) - $ExistingPolicy = $ExistingTemplates | Where-Object { $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 - if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { - "Intune Protection Policy $($_.DisplayName) found, SHA matches, skipping template creation" - continue - } + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies?$top=999' -tenantid $TenantFilter + foreach ($Policy in $Policies) { + try { + $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { + "Intune Protection Policy $($Policy.displayName) found, SHA matches, skipping template creation" + continue + } - $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'managedAppPolicies' -ID $Policy.id - if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { - "Intune Protection Policy $($Template.DisplayName) found, updating template" - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $ExistingPolicy.GUID - } | ConvertTo-Json -Compress + $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'managedAppPolicies' -ID $Policy.id -ODataType $Policy.'@odata.type' + if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { + "Intune Protection Policy $($Template.DisplayName) found, updating template" + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $ExistingPolicy.GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = $ExistingPolicy.GUID - PartitionKey = 'IntuneTemplate' - Package = $ExistingPolicy.Package - SHA = $Hash - GUID = $ExistingPolicy.GUID - Source = $ExistingPolicy.Source - } -Force - } else { - "Intune Protection Policy $($Template.DisplayName) not found in existing templates, creating new template" - $GUID = (New-Guid).GUID - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $GUID - } | ConvertTo-Json -Compress + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = $ExistingPolicy.GUID + PartitionKey = 'IntuneTemplate' + Package = $ExistingPolicy.Package + SHA = $Hash + GUID = $ExistingPolicy.GUID + Source = $ExistingPolicy.Source + } -Force + } else { + "Intune Protection Policy $($Template.DisplayName) not found in existing templates, creating new template" + $GUID = (New-Guid).GUID + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = "$GUID" - PartitionKey = 'IntuneTemplate' - SHA = $Hash - GUID = "$GUID" - Source = $TenantFilter - } -Force + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = "$GUID" + PartitionKey = 'IntuneTemplate' + SHA = $Hash + GUID = "$GUID" + Source = $TenantFilter + } -Force + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + "Failed to create a template of the Intune Protection Policy with ID: $($Policy.id). Error: $ErrorMessage" } } } diff --git a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 index 8b86e90ceceb..fb995c5e2986 100644 --- a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 +++ b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 @@ -4,12 +4,15 @@ function New-CippCustomScriptExecution { Executes a custom PowerShell script in a restricted environment .DESCRIPTION - Runs user-provided PowerShell scripts with strict security constraints: - - Only data manipulation cmdlets allowed - - Read-only access to CIPPDB via Get-CIPPTestData - - No file system, network, or write operations - - PowerShell 7.4 syntax supported - - Script output can be produced via pipeline output or explicit return + Runs user-provided PowerShell scripts inside an isolated ConstrainedLanguage + sandbox runspace: + - LanguageMode = ConstrainedLanguage blocks New-Object on arbitrary types and all + .NET method/reflection access (the real containment boundary). + - A command allowlist (Get-CippCustomScriptAllowedCommand) hides everything else. + - Read-only data access via a Get-CIPPTestData proxy that serves only pre-fetched, + tenant-locked cache data — the script cannot reach storage or other tenants. + - No file system, network, or write operations. + - Script output can be produced via pipeline output or explicit return. .PARAMETER ScriptGuid The GUID of the script to execute from the database @@ -69,24 +72,22 @@ function New-CippCustomScriptExecution { $Parameters = @{} } - # Validate script security constraints using AST parsing - Test-CustomScriptSecurity -ScriptContent $ScriptContent - - # Replace %variable% placeholders with tenant/custom values + # Replace %variable% placeholders FIRST, then validate the final text. Validating + # before replacement would let substituted content bypass the check. $ScriptContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $ScriptContent - # Create script block from user content - $ScriptBlock = [scriptblock]::Create($ScriptContent) + # Fast static pre-check (friendly errors). ConstrainedLanguage is the real boundary. + Test-CustomScriptSecurity -ScriptContent $ScriptContent - # Lock tenant for data access functions — scripts cannot query other tenants - # Module-scoped variable: isolated per runspace (no cross-invocation interference) - # and inaccessible to user scripts (AST blocks $script: access) - $script:CIPPLockedTenant = $TenantFilter + # Pre-fetch the tenant-locked cache data the script asks for (trusted side), so the + # sandbox proxy can serve it. The sandbox itself has no storage/tenant access. + $SandboxData = Get-CippSandboxData -ScriptContent $ScriptContent -TenantFilter $TenantFilter + + # Build script parameters (TenantFilter + custom). TenantFilter is supplied for + # scripts that declare it; data access is tenant-locked regardless. $ScriptParams = @{ TenantFilter = $TenantFilter } - - # Add custom parameters if any foreach ($key in $Parameters.Keys) { if ($key -ne 'TenantFilter' -and $key -ne 'tenantFilter') { $ScriptParams[$key] = $Parameters[$key] @@ -95,13 +96,26 @@ function New-CippCustomScriptExecution { Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Executing script with parameters: $($ScriptParams.Keys -join ', ')" -sev 'Debug' - # Execute the script in current session (already has CIPP functions loaded) - # The AST validation ensures only safe commands are used - # Use splatting to pass named parameters - try { - $Result = & $ScriptBlock @ScriptParams - } finally { - $script:CIPPLockedTenant = $null + # Execute inside the ConstrainedLanguage sandbox. + $Execution = Invoke-CippSandboxScript -ScriptContent $ScriptContent -SandboxData $SandboxData -ScriptParameters $ScriptParams + + # Deduplicate errors: a single bad expression in a pipeline (e.g. [pscustomobject] + # inside ForEach-Object) emits the same error once per item, which is just noise. + $ErrorText = (@($Execution.Errors | ForEach-Object { $_.ToString() }) | Select-Object -Unique) -join '; ' + + $Result = $Execution.Output + # Treat a null-only result as "no output" — a failed expression (e.g. [type]::new() + # under CLM) emits a single $null, which must not mask the error as a real result. + $HasOutput = @($Result | Where-Object { $null -ne $_ }).Count -gt 0 + + # Surface failures to the caller (e.g. the Run Test UI) instead of returning null and + # leaving the error only in the logbook. Terminating errors always fail; non-terminating + # errors fail only when they left no usable output (the typical CLM-rejection case). + if ($Execution.Terminating -or ($Execution.HadErrors -and -not $HasOutput)) { + throw "Custom script execution failed: $ErrorText" + } + if ($Execution.HadErrors) { + Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Custom script produced non-terminating errors: $ErrorText" -sev 'Warning' } # Convert result to array if it's not already @@ -114,7 +128,6 @@ function New-CippCustomScriptExecution { } } catch { - $script:CIPPLockedTenant = $null Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Failed to execute custom script: $($_.Exception.Message)" -sev 'Error' throw } diff --git a/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 b/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 new file mode 100644 index 000000000000..2211055f9bac --- /dev/null +++ b/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 @@ -0,0 +1,52 @@ +function New-CippSandboxInitialSessionState { + <# + .SYNOPSIS + Builds the ConstrainedLanguage InitialSessionState used to run custom tests. + + .DESCRIPTION + - LanguageMode = ConstrainedLanguage. This is what actually contains user scripts: + it blocks New-Object on arbitrary types and all .NET method/reflection access, + which the previous AST allowlist could not. + - Command allowlist: every command from CreateDefault() that is NOT in + Get-CippCustomScriptAllowedCommand is set Private (invisible to the user script). + - Get-CIPPTestData is added as a CLM-safe proxy that serves only the host-injected, + tenant-locked cache data ($CIPPSandboxData). It is Constant so a test cannot shadow + it to feed bogus data to a later test in the same suite. + + The ISS is a reusable template; callers create a fresh runspace from it per execution. + #> + [CmdletBinding()] + param() + + $Allowed = Get-CippCustomScriptAllowedCommand + + $InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $InitialSessionState.LanguageMode = [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage + + foreach ($Entry in @($InitialSessionState.Commands)) { + if ($Entry.Name -notin $Allowed) { + $Entry.Visibility = [System.Management.Automation.SessionStateEntryVisibility]::Private + } + } + + # CLM-safe data proxy. No script-level .NET — indexes the injected hashtable only. + $ProxyBody = @' +param([string]$TenantFilter, [string]$Type) +$Key = if ($Type) { $Type } else { '' } +if ($CIPPSandboxData -and $CIPPSandboxData.ContainsKey($Key)) { + return $CIPPSandboxData[$Key] +} +return @() +'@ + + $ProxyEntry = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new( + 'Get-CIPPTestData', + $ProxyBody, + [System.Management.Automation.ScopedItemOptions]::Constant, + $null + ) + $ProxyEntry.Visibility = [System.Management.Automation.SessionStateEntryVisibility]::Public + $InitialSessionState.Commands.Add($ProxyEntry) + + return $InitialSessionState +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 index f7fde93d58a8..e60b4ea632e4 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 @@ -8,6 +8,7 @@ function Remove-CIPPMailboxRule { $Headers, $RuleId, $RuleName, + $MailboxObjectId, [switch]$RemoveAllRules ) @@ -38,9 +39,15 @@ function Remove-CIPPMailboxRule { } else { # Only delete 1 rule try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $RuleId } - $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" - Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $RuleId } + $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" + Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + } catch { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $MailboxObjectId -cmdParams @{Identity = $RuleId } + $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" + Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + } # Remove from cache if it exists try { diff --git a/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 b/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 index 240f7fb4456e..c47d54dc0d3e 100644 --- a/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 +++ b/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 @@ -54,19 +54,8 @@ function Test-CustomScriptSecurity { } } - # ALLOWLIST: Only these commands are permitted - $AllowedCommands = @( - # Data manipulation cmdlets - 'ForEach-Object', 'Where-Object', 'Select-Object', 'Group-Object', - 'Measure-Object', 'Sort-Object', 'Compare-Object', 'Get-Member', - - # Utility cmdlets - 'Get-Date', 'Get-Random', 'New-Object', 'New-Guid', 'New-TimeSpan', - 'ConvertTo-Json', 'ConvertFrom-Json', 'Write-Output', 'Write-Host', - - # CIPP data access (read-only) - 'New-CIPPDbRequest', 'Get-CIPPDbItem', 'Get-CIPPTestData' - ) + # ALLOWLIST: shared with the sandbox runspace so validator and execution never drift. + $AllowedCommands = Get-CippCustomScriptAllowedCommand # Find all command invocations (exclude hashtable key assignments and property access) $Commands = $Ast.FindAll({ @@ -122,4 +111,73 @@ function Test-CustomScriptSecurity { throw "Security violation: .NET type '$typeName' is not allowed. Only these types are permitted: $($AllowedTypes -join ', ')" } } + + # The checks below are not a security boundary (ConstrainedLanguage is) — they catch the + # most common patterns that pass validation but fail under CLM at run time, so the user + # gets a helpful message at save time instead of a confusing error during Run Test. + + # Block [pscustomobject]/[psobject] conversions: hashtable-to-object conversion is not + # supported under ConstrainedLanguage. + $ConvertExpressions = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ConvertExpressionAst] + }, $true) + + $BlockedConvertTypes = @('pscustomobject', 'psobject', + 'System.Management.Automation.PSObject', 'System.Management.Automation.PSCustomObject') + + foreach ($convert in $ConvertExpressions) { + if ($convert.Type.TypeName.FullName -in $BlockedConvertTypes) { + $lineNumber = $convert.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: [pscustomobject]/[psobject] conversions are not supported (custom tests run in ConstrainedLanguage). Build result rows with Select-Object @{Name='X'; Expression={ ... }} and return a hashtable, e.g. @{ CIPPStatus = 'Info'; CIPPResults = `$rows }." + } + } + + # Block reflection / .NET member access reachable from allowed type literals + # (e.g. [System.String].Assembly.GetType(...)). CLM blocks these at run time anyway. + $ReflectionMembers = @( + 'Assembly', 'Module', 'BaseType', 'DeclaringType', 'GetType', + 'GetMethod', 'GetMethods', 'GetProperty', 'GetProperties', + 'GetField', 'GetFields', 'GetMember', 'GetMembers', + 'GetConstructor', 'GetConstructors', 'InvokeMember' + ) + + $MemberExpressions = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.MemberExpressionAst] + }, $true) + + foreach ($member in $MemberExpressions) { + if ($member.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $member.Member.Value -in $ReflectionMembers) { + $lineNumber = $member.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: reflection / .NET member access ('$($member.Member.Value)') is not allowed in custom tests." + } + } + + # Require a literal -Type on Get-CIPPTestData so the sandbox can pre-fetch its data. + $TestDataCalls = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Get-CIPPTestData' + }, $true) + + foreach ($call in $TestDataCalls) { + for ($i = 0; $i -lt $call.CommandElements.Count; $i++) { + $element = $call.CommandElements[$i] + if ($element -is [System.Management.Automation.Language.CommandParameterAst] -and $element.ParameterName -ieq 'Type') { + $value = if ($element.Argument) { + $element.Argument + } elseif ($i + 1 -lt $call.CommandElements.Count) { + $call.CommandElements[$i + 1] + } else { + $null + } + if ($value -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { + $lineNumber = $call.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: Get-CIPPTestData -Type must be a literal value (for example: -Type 'Users'). Dynamic or computed type names are not supported." + } + } + } + } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 new file mode 100644 index 000000000000..329145fd4b64 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 @@ -0,0 +1,122 @@ +function Set-CIPPDBCacheIntuneAppInstallStatus { + <# + .SYNOPSIS + Caches per-application install status counts from the AppInstallStatusAggregate + export submitted earlier. + + .DESCRIPTION + The AppInstallStatusAggregate report is the only tenant-wide app install report Intune + exposes without a per-app filter, so it carries rollup counts (FailedDeviceCount etc.) + rather than per-device detail. Get-CIPPAlertIntunePolicyConflicts reads the cached rows + to flag applications that are failing to install. + + .PARAMETER TenantFilter + The tenant to cache app install status for. + + .PARAMETER QueueId + Optional queue ID for progress tracking. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + $ReportName = 'AppInstallStatusAggregate' + + try { + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" + + if (-not $JobRow) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping app install status cache" -sev Info + return + } + + $JobId = $JobRow.JobId + if (-not $JobId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'IntuneReportJobs row missing JobId - removing' -sev Warning + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + switch ($Job.status) { + 'completed' { } + 'failed' { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + default { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + return + } + } + + if (-not $Job.url) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId completed but no url returned" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + $ZipBytes = (Invoke-WebRequest -Uri $Job.url -UseBasicParsing -ErrorAction Stop).Content + if ($ZipBytes -isnot [byte[]]) { throw "Expected binary content from $ReportName download" } + + $JsonText = $null + $ZipStream = [System.IO.MemoryStream]::new($ZipBytes, $false) + try { + $Archive = [System.IO.Compression.ZipArchive]::new($ZipStream, [System.IO.Compression.ZipArchiveMode]::Read) + try { + $Entry = $Archive.Entries | Where-Object { $_.Name -like '*.json' } | Select-Object -First 1 + if (-not $Entry) { throw "No JSON entry in $ReportName archive" } + $EntryStream = $Entry.Open() + try { + $Reader = [System.IO.StreamReader]::new($EntryStream) + try { $JsonText = $Reader.ReadToEnd() } finally { $Reader.Dispose() } + } finally { $EntryStream.Dispose() } + } finally { $Archive.Dispose() } + } finally { + $ZipStream.Dispose() + $ZipBytes = $null + } + + $ExportRows = @(($JsonText | ConvertFrom-Json).values) + $JsonText = $null + + $AppStatuses = foreach ($Row in $ExportRows) { + if (-not $Row.ApplicationId) { continue } + [pscustomobject]@{ + id = $Row.ApplicationId + displayName = $Row.DisplayName + publisher = $Row.Publisher + platform = $Row.AppPlatform ?? $Row.Platform + appVersion = $Row.AppVersion + installedDeviceCount = [int]($Row.InstalledDeviceCount ?? 0) + failedDeviceCount = [int]($Row.FailedDeviceCount ?? 0) + failedUserCount = [int]($Row.FailedUserCount ?? 0) + pendingInstallDeviceCount = [int]($Row.PendingInstallDeviceCount ?? 0) + notInstalledDeviceCount = [int]($Row.NotInstalledDeviceCount ?? 0) + failedDevicePercentage = [double]($Row.FailedDevicePercentage ?? 0) + } + } + $AppStatuses = @($AppStatuses) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppInstallStatusAggregate' -Data $AppStatuses -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AppStatuses.Count) app install status rows from export $JobId" -sev Info + + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 index 4acc51cee07a..850092d9183b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 @@ -28,7 +28,7 @@ function Set-CIPPDBCacheIntunePolicies { $PolicyTypes = @( @{ Type = 'DeviceCompliancePolicies'; Uri = '/deviceManagement/deviceCompliancePolicies?$top=999&$expand=assignments'; FetchDeviceStatuses = $true } - @{ Type = 'DeviceConfigurations'; Uri = '/deviceManagement/deviceConfigurations?$top=999&$expand=assignments' } + @{ Type = 'DeviceConfigurations'; Uri = '/deviceManagement/deviceConfigurations?$top=999&$expand=assignments'; FetchDeviceStatuses = $true } @{ Type = 'ConfigurationPolicies'; Uri = '/deviceManagement/configurationPolicies?$top=999&$expand=assignments,settings' } @{ Type = 'GroupPolicyConfigurations'; Uri = '/deviceManagement/groupPolicyConfigurations?$top=999&$expand=assignments' } @{ Type = 'MobileAppConfigurations'; Uri = '/deviceManagement/mobileAppConfigurations?$top=999&$expand=assignments' } @@ -107,9 +107,8 @@ function Set-CIPPDBCacheIntunePolicies { Add-CIPPDbItem -TenantFilter $TenantFilter -Type "Intune$($PolicyType.Type)" -Data $Policies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Policies.Count) $($PolicyType.Type)" -sev Debug - # Fetch device statuses for compliance policies using bulk requests if ($PolicyType.FetchDeviceStatuses -and ($Policies | Measure-Object).Count -gt 0) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching device statuses for $($Policies.Count) compliance policies using bulk request" -sev Debug + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching device statuses for $($Policies.Count) $($PolicyType.Type) using bulk request" -sev Debug $BaseUri = ($PolicyType.Uri -split '\?')[0] # Build bulk request array diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 index 4e54df51a9db..2f6652f7bd7a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 @@ -30,6 +30,33 @@ function Invoke-ExecApiClient { } 'AddUpdate' { $Results = [System.Collections.Generic.List[object]]::new() + + # Authorize the role assignment BEFORE any side effects (app registration / + # secret creation). A caller may only assign a role whose effective + # permissions are a subset of their own, and may only modify an existing + # client whose current role is likewise within their grant. This blocks + # privilege escalation via the ApiClients table (e.g. editor -> superadmin). + $RequestedRole = [string]$Request.Body.Role.value + $RolesToAuthorize = [System.Collections.Generic.List[string]]::new() + $RolesToAuthorize.Add($RequestedRole) + $ExistingClientForAuth = $null + $AuthClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId + if ($AuthClientId) { + $ExistingClientForAuth = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$($AuthClientId)'" + if ($ExistingClientForAuth) { + $RolesToAuthorize.Add([string]$ExistingClientForAuth.Role) + } + } + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role $RolesToAuthorize + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client role assignment: $($RoleGrant.Message)" -Sev 'Warning' + $Body = @(@{ + resultText = $RoleGrant.Message + state = 'error' + }) + break + } + if ($Request.Body.ClientId -or $Request.Body.AppName) { $ClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId $AddUpdateSuccess = $false @@ -239,6 +266,18 @@ function Invoke-ExecApiClient { state = 'error' } } else { + # Block resetting the secret of a client whose role outranks the caller; + # otherwise an editor could harvest a working superadmin secret. + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role ([string]$Client.Role) + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client secret reset for $($Request.Body.ClientId): $($RoleGrant.Message)" -Sev 'Warning' + $Results = @{ + resultText = $RoleGrant.Message + state = 'error' + } + $Body = @($Results) + break + } $ApiConfig = New-CIPPAPIConfig -ResetSecret -AppId $Request.Body.ClientId -Headers $Request.Headers if ($ApiConfig.ApplicationSecret) { @@ -294,6 +333,16 @@ function Invoke-ExecApiClient { try { if ($Request.Body.ClientId) { $ClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId + # Block deleting a client whose role outranks the caller (tamper/DoS). + $ExistingClientForAuth = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$($ClientId)'" + if ($ExistingClientForAuth) { + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role ([string]$ExistingClientForAuth.Role) + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client deletion for $($ClientId): $($RoleGrant.Message)" -Sev 'Warning' + $Body = @{ Results = $RoleGrant.Message } + break + } + } if ($Request.Body.RemoveAppReg -eq $true) { Write-Information "Deleting API Client: $ClientId from Entra" $App = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$($ClientId)'&`$select=id,appId,web" -NoAuthCheck $true -asapp $true diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 index 006e1bbe0a1b..34d6d5fc81be 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 @@ -14,11 +14,12 @@ Function Invoke-ExecRemoveMailboxRule { $RuleName = $Request.Query.ruleName ?? $Request.Body.ruleName $RuleId = $Request.Query.ruleId ?? $Request.Body.ruleId $Username = $Request.Query.userPrincipalName ?? $Request.Body.userPrincipalName + $MailboxObjectId = $RuleId.Split('\\')[0] Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message 'Accessed this API' -Sev 'Debug' try { # Remove the rule - $Results = Remove-CIPPMailboxRule -username $Username -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers -RuleId $RuleId -RuleName $RuleName + $Results = Remove-CIPPMailboxRule -username $Username -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers -RuleId $RuleId -RuleName $RuleName -MailboxObjectId $MailboxObjectId $StatusCode = [HttpStatusCode]::OK } catch { $Results = $_.Exception.Message diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 index 6e01d3113d33..bbf7e50cc628 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 @@ -58,7 +58,7 @@ function Invoke-AddCustomScript { } 'SetResultMode' { $RequestedMode = $Request.Body.ResultMode - $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo') + $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo', 'AlwaysInvestigate') if ([string]::IsNullOrWhiteSpace($RequestedMode) -or $RequestedMode -notin $ValidResultModes) { throw "ResultMode must be one of: $($ValidResultModes -join ', ')" } @@ -157,7 +157,7 @@ function Invoke-AddCustomScript { throw "ReturnType must be one of: $($ValidReturnTypes -join ', ')" } - $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo') + $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo', 'AlwaysInvestigate') if ($ResultMode -notin $ValidResultModes) { throw "ResultMode must be one of: $($ValidResultModes -join ', ')" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 index 8769763e6435..ad9a21118d49 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 @@ -13,8 +13,8 @@ [CmdletBinding()] param($Request, $TriggerMetadata) - $Owner = $Request.Query.Owner - $Repository = $Request.Query.Repository + $Owner = 'KelvinTegelaar' + $Repository = 'CIPP' if (-not $Owner) { throw 'Owner parameter is required to retrieve release notes.' @@ -35,7 +35,7 @@ $Latest = $false if ($Rows) { $Releases = ConvertFrom-Json -InputObject $Rows.GitHubReleases -Depth 10 - $CurrentVersion = [semver]$global:CippVersion + $CurrentVersion = [semver]($env:CippVersion ?? $env:APP_VERSION) $CurrentMajorMinor = "$($CurrentVersion.Major).$($CurrentVersion.Minor)" foreach ($Release in $Releases) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index 84c0817b8412..08781f6a7140 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -81,6 +81,11 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Trial Autoclaim' + productId = 'autoclaim' + policyValue = 'Failed to retrieve current state, check the logs for details' + }) Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy: $($_.Exception.Message)" -sev Error } } @@ -181,6 +186,11 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Trial Autoclaim' + productId = 'autoclaim' + policyValue = 'Failed to retrieve current state, check the logs for details' + }) Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy after remediation: $($_.Exception.Message)" -sev Error } } diff --git a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 index e37958e93814..f44bb399798e 100644 --- a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 @@ -1,15 +1,27 @@ # Pester tests for Get-CIPPAlertIntunePolicyConflicts -# Verifies aggregation defaults, toggles, and error handling +# The alert reads pre-collected data from the CIPP reporting cache (Get-CIPPDbItem): +# - Intune_ -> per-device compliance/config states (error/conflict) +# - IntuneAppInstallStatusAggregate -> per-app install failure counts +# These tests mock the cache reads and verify aggregation, toggles, and error handling. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $AlertPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1' + $AlertPath = Join-Path $RepoRoot 'Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1' - function New-GraphGetRequest { param($uri, $tenantid) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } - function Write-AlertMessage { param($tenant, $message) } - function Get-NormalizedError { param($message) $message } - function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset) } + + # Build a cache row the way Add-CIPPDbItem stores it: RowKey "-", Data = compressed JSON. + function New-DbItem { + param($Type, $Id, $Object) + [pscustomobject]@{ + RowKey = "$Type-$Id" + Data = ($Object | ConvertTo-Json -Compress -Depth 10) + } + } . $AlertPath } @@ -28,54 +40,42 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $script:CapturedTenant = $tenantFilter } - Mock -CommandName Write-AlertMessage -MockWith { - param($tenant, $message) + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) $script:CapturedErrorMessage = $message } - Mock -CommandName New-GraphGetRequest -MockWith { - param($uri, $tenantid) - if ($uri -like '*deviceManagement/managedDevices*') { - @( - [pscustomobject]@{ - deviceName = 'PC-01' - userPrincipalName = 'user1@contoso.com' - id = 'device-1' - deviceConfigurationStates = @( - [pscustomobject]@{ displayName = 'Policy A'; state = 'conflict' } - ) - } - ) - } elseif ($uri -like '*deviceAppManagement/mobileApps*') { - @( - [pscustomobject]@{ - displayName = 'App A' - deviceStatuses = @( - [pscustomobject]@{ installState = 'error'; deviceName = 'PC-01'; userPrincipalName = 'user1@contoso.com'; deviceId = 'device-1' } - ) - } - ) + # Default cache: one compliance policy in conflict, one config profile in error, one failing app. + Mock -CommandName Get-CIPPDbItem -MockWith { + param($TenantFilter, $Type, [switch]$CountsOnly) + switch ($Type) { + 'IntuneDeviceCompliancePolicies' { New-DbItem $Type 'comp-1' @{ id = 'comp-1'; displayName = 'Compliance A' } } + 'IntuneDeviceCompliancePolicies_comp-1' { New-DbItem $Type 'd1' @{ id = 'd1'; status = 'conflict'; deviceDisplayName = 'PC-01'; userPrincipalName = 'user1@contoso.com' } } + 'IntuneDeviceConfigurations' { New-DbItem $Type 'cfg-1' @{ id = 'cfg-1'; displayName = 'Config A' } } + 'IntuneDeviceConfigurations_cfg-1' { New-DbItem $Type 'd2' @{ id = 'd2'; status = 'error'; deviceDisplayName = 'PC-02'; userPrincipalName = 'user2@contoso.com' } } + 'IntuneAppInstallStatusAggregate' { New-DbItem $Type 'app-1' @{ displayName = 'App A'; failedDeviceCount = 3; failedUserCount = 2; failedDevicePercentage = 12; platform = 'Windows' } } + default { @() } } } } - It 'defaults to aggregated alerting with all mechanisms and statuses' { + It 'defaults to aggregated alerting across compliance, config and app sources' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' $CapturedTenant | Should -Be 'contoso.onmicrosoft.com' $CapturedData | Should -Not -BeNullOrEmpty $CapturedData.Count | Should -Be 1 - $CapturedData[0].PolicyIssues | Should -Be 1 + $CapturedData[0].PolicyIssues | Should -Be 2 # compliance conflict + config error $CapturedData[0].AppIssues | Should -Be 1 - $CapturedData[0].Issues.Count | Should -Be 2 + $CapturedData[0].Issues.Count | Should -Be 3 } It 'emits per-issue alerts when AlertEachIssue is true' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertEachIssue = $true } $CapturedData | Should -Not -BeNullOrEmpty - $CapturedData.Count | Should -Be 2 - ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 1 + $CapturedData.Count | Should -Be 3 + ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 2 ($CapturedData | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 1 } @@ -83,9 +83,7 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ Aggregate = $false } $CapturedData | Should -Not -BeNullOrEmpty - $CapturedData.Count | Should -Be 2 - ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 1 - ($CapturedData | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 1 + $CapturedData.Count | Should -Be 3 } It 'honors IncludePolicies toggle' { @@ -96,42 +94,35 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $CapturedData[0].PolicyIssues | Should -Be 0 $CapturedData[0].AppIssues | Should -Be 1 $CapturedData[0].Issues.Count | Should -Be 1 - ($CapturedData[0].Issues | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 0 } - It 'suppresses conflict-only alerts when AlertConflicts is false' { - # conflict for policy, error for app; expect only app when conflicts suppressed - Mock -CommandName New-GraphGetRequest -MockWith { - param($uri, $tenantid) - if ($uri -like '*deviceManagement/managedDevices*') { - @( - [pscustomobject]@{ - deviceName = 'PC-02' - userPrincipalName = 'user2@contoso.com' - id = 'device-2' - deviceConfigurationStates = @( - [pscustomobject]@{ displayName = 'Policy B'; state = 'conflict' } - ) - } - ) - } elseif ($uri -like '*deviceAppManagement/mobileApps*') { - @( - [pscustomobject]@{ - displayName = 'App B' - deviceStatuses = @( - [pscustomobject]@{ installState = 'error'; deviceName = 'PC-02'; userPrincipalName = 'user2@contoso.com'; deviceId = 'device-2' } - ) - } - ) - } - } + It 'honors IncludeApplications toggle' { + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ IncludeApplications = $false } - Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertConflicts = $false; Aggregate = $false } + $CapturedData | Should -Not -BeNullOrEmpty + $CapturedData[0].PolicyIssues | Should -Be 2 + $CapturedData[0].AppIssues | Should -Be 0 + ($CapturedData[0].Issues | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 0 + } + + It 'suppresses conflict states (and apps) when only AlertConflicts is requested' { + # Only conflicts requested: config 'error' state and app failures are both suppressed, + # leaving just the compliance conflict. + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertErrors = $false; Aggregate = $false } $CapturedData | Should -Not -BeNullOrEmpty $CapturedData.Count | Should -Be 1 - $CapturedData[0].Type | Should -Be 'Application' - $CapturedData[0].IssueStatus | Should -Be 'error' + $CapturedData[0].Type | Should -Be 'Policy' + $CapturedData[0].IssueStatus | Should -Be 'conflict' + $CapturedData[0].PolicyType | Should -Be 'Compliance' + } + + It 'reports aggregate app failure detail' { + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertEachIssue = $true; IncludePolicies = $false } + + $AppIssue = $CapturedData | Where-Object { $_.Type -eq 'Application' } + $AppIssue.FailedDeviceCount | Should -Be 3 + $AppIssue.Message | Should -Match "failed to install on 3 device" } It 'skips processing when license check fails' { @@ -143,13 +134,13 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $CapturedTenant | Should -BeNullOrEmpty } - It 'writes alert message when Graph call fails' { - Mock -CommandName New-GraphGetRequest -MockWith { throw 'Graph failure' } -Verifiable + It 'writes alert message when a cache read fails' { + Mock -CommandName Get-CIPPDbItem -MockWith { throw 'DB failure' } -Verifiable Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' $CapturedData | Should -BeNullOrEmpty - $CapturedErrorMessage | Should -Match 'Failed to query Intune (policy|application) states' - $CapturedErrorMessage | Should -Match 'Graph failure' + $CapturedErrorMessage | Should -Match 'Failed to read cached' + $CapturedErrorMessage | Should -Match 'DB failure' } } diff --git a/host.json b/host.json index 964ab6d101ea..3d53421632b0 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.3", + "defaultVersion": "10.5.4", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index 1e9c35fac856..927fa80836fb 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.3 +10.5.4