Skip to content

Commit bfd51bb

Browse files
authored
Merge pull request #73 from KelvinTegelaar/master
[pull] master from KelvinTegelaar:master
2 parents d324924 + e8cd121 commit bfd51bb

28 files changed

Lines changed: 964 additions & 274 deletions

Config/FeatureFlags.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@
8282
"Pages": [],
8383
"Hidden": false
8484
}
85-
]
85+
]

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ function Push-IntuneReportExportSubmit {
2525
'UserId', 'UserName', 'EmailAddress'
2626
)
2727
}
28+
'AppInstallStatusAggregate' {
29+
@(
30+
'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform',
31+
'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount',
32+
'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage'
33+
)
34+
}
2835
default { throw "Unknown Intune report '$ReportName'" }
2936
}
3037

Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1

Lines changed: 53 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ function Get-CIPPAlertIntunePolicyConflicts {
4949
}
5050

5151
$AlertableStatuses = @(
52-
if ($Config.AlertErrors) { 'error'; 'failed' }
52+
if ($Config.AlertErrors) { 'error' }
5353
if ($Config.AlertConflicts) { 'conflict' }
5454
)
5555

56-
if (-not $AlertableStatuses) {
56+
if (-not $AlertableStatuses -and -not ($Config.IncludeApplications -and $Config.AlertErrors)) {
5757
return
5858
}
5959

@@ -64,56 +64,66 @@ function Get-CIPPAlertIntunePolicyConflicts {
6464

6565
$Issues = [System.Collections.Generic.List[object]]::new()
6666

67-
if ($Config.IncludePolicies) {
68-
try {
69-
$ManagedDevices = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,userPrincipalName&`$expand=deviceConfigurationStates(`$select=displayName,state,settingStates)" -tenantid $TenantFilter
70-
71-
foreach ($Device in $ManagedDevices) {
72-
$PolicyStates = $Device.deviceConfigurationStates | Where-Object { $_.state -and ($AlertableStatuses -contains $_.state) }
73-
foreach ($State in $PolicyStates) {
74-
$Issues.Add([PSCustomObject]@{
75-
Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)."
76-
Tenant = $TenantFilter
77-
Type = 'Policy'
78-
PolicyName = $State.displayName
79-
IssueStatus = $State.state
80-
DeviceName = $Device.deviceName
81-
UserPrincipalName = $Device.userPrincipalName
82-
DeviceId = $Device.id
83-
})
67+
if ($Config.IncludePolicies -and $AlertableStatuses) {
68+
$PolicySources = @(
69+
@{ Type = 'IntuneDeviceCompliancePolicies'; Kind = 'Compliance' }
70+
@{ Type = 'IntuneDeviceConfigurations'; Kind = 'Configuration' }
71+
)
72+
73+
foreach ($Source in $PolicySources) {
74+
try {
75+
$PolicyItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Source.Type | Where-Object { $_.RowKey -notlike '*-Count' }
76+
foreach ($PolicyItem in $PolicyItems) {
77+
$Policy = try { $PolicyItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null }
78+
if (-not $Policy.id) { continue }
79+
80+
$StatusItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type "$($Source.Type)_$($Policy.id)" | Where-Object { $_.RowKey -notlike '*-Count' }
81+
foreach ($StatusItem in $StatusItems) {
82+
$State = try { $StatusItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null }
83+
if (-not $State.status -or ($AlertableStatuses -notcontains $State.status.ToLowerInvariant())) { continue }
84+
85+
$Issues.Add([PSCustomObject]@{
86+
Message = "$($Source.Kind) policy '$($Policy.displayName)' is $($State.status) on device '$($State.deviceDisplayName)' for $($State.userPrincipalName)."
87+
Tenant = $TenantFilter
88+
Type = 'Policy'
89+
PolicyType = $Source.Kind
90+
PolicyName = $Policy.displayName
91+
IssueStatus = $State.status
92+
DeviceName = $State.deviceDisplayName
93+
UserPrincipalName = $State.userPrincipalName
94+
DeviceId = $State.id
95+
})
96+
}
8497
}
98+
} catch {
99+
$ErrorMessage = Get-CippException -Exception $_
100+
Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached $($Source.Kind) policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
85101
}
86-
} catch {
87-
$ErrorMessage = Get-CippException -Exception $_
88-
Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
89102
}
90103
}
91104

92-
if ($Config.IncludeApplications) {
105+
if ($Config.IncludeApplications -and $Config.AlertErrors) {
93106
try {
94-
$Applications = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName&`$expand=deviceStatuses(`$select=installState,deviceName,userPrincipalName,deviceId)" -tenantid $TenantFilter
95-
96-
foreach ($App in $Applications) {
97-
$BadStatuses = $App.deviceStatuses | Where-Object {
98-
$_.installState -and ($AlertableStatuses -contains $_.installState.ToLowerInvariant())
99-
}
100-
101-
foreach ($Status in $BadStatuses) {
102-
$Issues.Add([PSCustomObject]@{
103-
Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)."
104-
Tenant = $TenantFilter
105-
Type = 'Application'
106-
AppName = $App.displayName
107-
IssueStatus = $Status.installState
108-
DeviceName = $Status.deviceName
109-
UserPrincipalName = $Status.userPrincipalName
110-
DeviceId = $Status.deviceId
111-
})
112-
}
107+
$AppItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppInstallStatusAggregate' | Where-Object { $_.RowKey -notlike '*-Count' }
108+
foreach ($AppItem in $AppItems) {
109+
$App = try { $AppItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null }
110+
if (-not $App -or [int]($App.failedDeviceCount) -le 0) { continue }
111+
112+
$Issues.Add([PSCustomObject]@{
113+
Message = "App '$($App.displayName)' failed to install on $($App.failedDeviceCount) device(s) ($($App.failedDevicePercentage)%)."
114+
Tenant = $TenantFilter
115+
Type = 'Application'
116+
AppName = $App.displayName
117+
IssueStatus = 'failed'
118+
FailedDeviceCount = [int]$App.failedDeviceCount
119+
FailedUserCount = [int]$App.failedUserCount
120+
FailedPercentage = $App.failedDevicePercentage
121+
Platform = $App.platform
122+
})
113123
}
114124
} catch {
115125
$ErrorMessage = Get-CippException -Exception $_
116-
Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
126+
Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached Intune app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
117127
}
118128
}
119129

Modules/CIPPCore/CIPPCore.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
# RequiredModules = @()
4646

4747
# Assemblies that must be loaded prior to importing this module
48-
# RequiredAssemblies = @()
48+
RequiredAssemblies = @('..\..\Shared\CIPPSharp\bin\CIPPSharp.dll')
4949

5050
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
5151
# ScriptsToProcess = @()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
function Test-CippApiClientRoleGrant {
2+
<#
3+
.SYNOPSIS
4+
Validates that the caller of an API client management action is permitted to
5+
create, modify, reset, or delete an API client holding the supplied role(s).
6+
7+
.DESCRIPTION
8+
Prevents privilege escalation through the ApiClients table. The ExecApiClient
9+
endpoint is gated at CIPP.Extension.ReadWrite (editor-grantable), but the role
10+
assigned to an API client becomes that client's effective privilege at request
11+
time (see Test-CIPPAccess). Without this check an editor could mint a client
12+
with the 'superadmin' role, or reset the secret of an existing superadmin
13+
client, and escalate.
14+
15+
A caller may only manage a client whose effective permissions are a subset of
16+
the caller's own effective permissions. Superadmins may grant any role. Roles
17+
are compared by computed permission set (built-in and custom), matching exactly
18+
how Test-CIPPAccess evaluates an API client (single role, no base-role ceiling).
19+
20+
.PARAMETER Request
21+
The HTTP request, used to resolve the caller's roles. Handles both interactive
22+
user principals and API-client principals.
23+
24+
.PARAMETER Role
25+
One or more roles to validate, e.g. the requested new role and the existing
26+
client's current role. An empty/missing role is treated as the runtime
27+
'cipp-api' fallback that Test-CIPPAccess applies to roleless clients.
28+
29+
.OUTPUTS
30+
[pscustomobject] with Allowed [bool] and Message [string]. Fails closed.
31+
32+
.FUNCTIONALITY
33+
Internal
34+
#>
35+
[CmdletBinding()]
36+
param(
37+
[Parameter(Mandatory = $true)]
38+
$Request,
39+
40+
[Parameter(Mandatory = $true)]
41+
[AllowEmptyCollection()]
42+
[AllowEmptyString()]
43+
[string[]]$Role
44+
)
45+
46+
function New-Denial {
47+
param([string]$Message)
48+
[pscustomobject]@{ Allowed = $false; Message = $Message }
49+
}
50+
51+
# Resolve the caller's roles. Mirror Test-CIPPAccess's principal detection so this
52+
# works whether the caller is an interactive user or an API client.
53+
try {
54+
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}$') {
55+
$CallerClient = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name'
56+
if ($CallerClient.Role) {
57+
$CallerRoles = @($CallerClient.Role)
58+
} else {
59+
$CallerRoles = @('cipp-api')
60+
}
61+
} else {
62+
$CallerRoles = @(Get-CIPPAccessRole -Request $Request)
63+
}
64+
} catch {
65+
return (New-Denial "Unable to resolve your roles for authorization: $($_.Exception.Message)")
66+
}
67+
68+
if (-not $CallerRoles -or $CallerRoles.Count -eq 0) {
69+
return (New-Denial 'Unable to determine your roles; cannot authorize this API client operation.')
70+
}
71+
72+
# Superadmin may grant or manage any role.
73+
if ($CallerRoles -contains 'superadmin') {
74+
return [pscustomobject]@{ Allowed = $true; Message = $null }
75+
}
76+
77+
$DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly')
78+
$CallerPermissions = @(Get-CippAllowedPermissions -UserRoles $CallerRoles)
79+
80+
# Normalize: a roleless client resolves to the 'cipp-api' fallback at request time,
81+
# so validate against that to mirror real client evaluation and stay future-proof.
82+
$TargetRoles = @($Role | ForEach-Object {
83+
if ([string]::IsNullOrWhiteSpace($_)) { 'cipp-api' } else { $_.Trim() }
84+
} | Sort-Object -Unique)
85+
86+
foreach ($TargetRole in $TargetRoles) {
87+
# anonymous/authenticated are SWA placeholder roles, never valid client roles.
88+
if (@('anonymous', 'authenticated') -contains $TargetRole) {
89+
return (New-Denial "The role '$TargetRole' cannot be assigned to an API client.")
90+
}
91+
92+
# Confirm the role exists. 'cipp-api' is an implicit runtime fallback and may
93+
# legitimately not be present in the CustomRoles table, so it is exempt.
94+
if ($DefaultRoles -notcontains $TargetRole -and $TargetRole -ne 'cipp-api') {
95+
try {
96+
$null = Get-CIPPRolePermissions -RoleName $TargetRole
97+
} catch {
98+
return (New-Denial "The role '$TargetRole' does not exist.")
99+
}
100+
}
101+
102+
# Effective permissions a client holding this role would receive, computed the
103+
# same way Test-CIPPAccess evaluates an API client (single role, no base ceiling).
104+
$RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole))
105+
$Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ })
106+
107+
if ($Escalation.Count -gt 0) {
108+
return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.")
109+
}
110+
}
111+
112+
return [pscustomobject]@{ Allowed = $true; Message = $null }
113+
}

Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ function Start-IntuneReportExportOrchestrator {
3131
return
3232
}
3333

34-
$Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks $LicensedTenants.Count
34+
$ReportNames = @('AppInvRawData', 'AppInstallStatusAggregate')
35+
36+
$Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks ($LicensedTenants.Count * $ReportNames.Count)
3537

3638
$Batch = foreach ($Tenant in $LicensedTenants) {
37-
[PSCustomObject]@{
38-
FunctionName = 'IntuneReportExportSubmit'
39-
TenantFilter = $Tenant.defaultDomainName
40-
ReportName = 'AppInvRawData'
41-
QueueId = $Queue.RowKey
42-
QueueName = "Intune Export Submit - $($Tenant.defaultDomainName)"
39+
foreach ($ReportName in $ReportNames) {
40+
[PSCustomObject]@{
41+
FunctionName = 'IntuneReportExportSubmit'
42+
TenantFilter = $Tenant.defaultDomainName
43+
ReportName = $ReportName
44+
QueueId = $Queue.RowKey
45+
QueueName = "Intune Export Submit ($ReportName) - $($Tenant.defaultDomainName)"
46+
}
4347
}
4448
}
4549

Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ function Start-UserTasksOrchestrator {
2828
}
2929
} else {
3030
$4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
31-
$24HoursAgo = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
32-
# Pending = orchestrator queued, Running = actively executing
33-
# Pick up: Planned, Failed-Planned, stuck Pending (>24hr), or stuck Running (>4hr for large AllTenants tasks)
34-
$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'))"
31+
$1HourAgo = (Get-Date).AddHours(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
32+
# Pending = orchestrator claimed but executor not yet started, Running = actively executing
33+
# Pick up: Planned, Failed-Planned, stuck Pending (>1hr - orphaned claim), or stuck Running/Processing (>4hr for large AllTenants tasks)
34+
$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'))"
3535
$tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter
3636
}
3737

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
function Get-CippCustomScriptAllowedCommand {
2+
<#
3+
.SYNOPSIS
4+
Single source of truth for the custom-test command allowlist.
5+
6+
.DESCRIPTION
7+
Used by both Test-CustomScriptSecurity (static pre-check) and
8+
New-CippSandboxInitialSessionState (the ConstrainedLanguage runspace) so the
9+
validator and the sandbox can never drift apart.
10+
11+
Notes:
12+
- New-Object is intentionally NOT allowed — it is the primary sandbox-escape
13+
vector and is blocked by ConstrainedLanguage anyway.
14+
- Data access is limited to Get-CIPPTestData. The lower-level New-CIPPDbRequest /
15+
Get-CIPPDbItem are not exposed: the sandbox serves pre-fetched, tenant-locked
16+
cache data only.
17+
#>
18+
[CmdletBinding()]
19+
param()
20+
21+
@(
22+
# Data shaping
23+
'ForEach-Object', 'Where-Object', 'Select-Object', 'Sort-Object', 'Group-Object',
24+
'Measure-Object', 'Compare-Object', 'Get-Unique', 'Get-Member', 'Select-String',
25+
26+
# Conversion / utility
27+
'ConvertTo-Json', 'ConvertFrom-Json', 'Get-Date', 'Get-Random', 'New-TimeSpan',
28+
'New-Guid', 'Write-Output',
29+
30+
# CIPP read-only data access (provided as a CLM-safe proxy in the sandbox)
31+
'Get-CIPPTestData'
32+
)
33+
}

0 commit comments

Comments
 (0)