Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Config/FeatureFlags.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@
"Pages": [],
"Hidden": false
}
]
]
Original file line number Diff line number Diff line change
Expand Up @@ -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'" }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
}

Expand Down
2 changes: 1 addition & 1 deletion Modules/CIPPCore/CIPPCore.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @()
Expand Down
113 changes: 113 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
33 changes: 33 additions & 0 deletions Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1
Original file line number Diff line number Diff line change
@@ -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'
)
}
Loading