Skip to content

Commit e221751

Browse files
authored
Merge pull request #1039 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 8b5a23e + aefefda commit e221751

16 files changed

Lines changed: 679 additions & 141 deletions

Config/FeatureFlags.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"Id": "CopilotAI",
4949
"Name": "Copilot & AI",
5050
"Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.",
51-
"Enabled": true,
51+
"Enabled": false,
5252
"AllowUserToggle": false,
5353
"Timers": [],
5454
"Endpoints": [
@@ -67,7 +67,7 @@
6767
"/copilot/reports/copilot-usage",
6868
"/copilot/reports/copilot-trend"
6969
],
70-
"Hidden": false
70+
"Hidden": true
7171
},
7272
{
7373
"Id": "MCPServer",
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+
}
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+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
function Get-CippSandboxData {
2+
<#
3+
.SYNOPSIS
4+
Pre-fetches the tenant-locked cache data a custom test requests.
5+
6+
.DESCRIPTION
7+
Runs on the trusted (FullLanguage) side before the script enters the sandbox.
8+
Inspects the script AST for Get-CIPPTestData calls, resolves each requested -Type,
9+
and fetches that data for the supplied tenant via the real Get-CIPPTestData. The
10+
result is a hashtable keyed by Type that the sandbox proxy serves.
11+
12+
Because only the requested types for THIS tenant are fetched and injected, the
13+
sandbox is structurally unable to read any other tenant's data.
14+
15+
-Type must be a string literal. Dynamic type names cannot be pre-fetched and are
16+
rejected with a clear error (rather than silently returning empty data).
17+
18+
.PARAMETER ScriptContent
19+
The (already text-replaced, already validated) script content.
20+
21+
.PARAMETER TenantFilter
22+
The tenant to fetch data for.
23+
#>
24+
[CmdletBinding()]
25+
param(
26+
[Parameter(Mandatory = $true)]
27+
[string]$ScriptContent,
28+
29+
[Parameter(Mandatory = $true)]
30+
[string]$TenantFilter
31+
)
32+
33+
$Ast = [System.Management.Automation.Language.Parser]::ParseInput($ScriptContent, [ref]$null, [ref]$null)
34+
35+
$Calls = $Ast.FindAll({
36+
param($Node)
37+
$Node -is [System.Management.Automation.Language.CommandAst] -and
38+
$Node.GetCommandName() -eq 'Get-CIPPTestData'
39+
}, $true)
40+
41+
$Data = @{}
42+
43+
foreach ($Call in $Calls) {
44+
$Type = $null
45+
$HasType = $false
46+
$TypeIsLiteral = $true
47+
48+
for ($i = 0; $i -lt $Call.CommandElements.Count; $i++) {
49+
$Element = $Call.CommandElements[$i]
50+
if ($Element -is [System.Management.Automation.Language.CommandParameterAst] -and $Element.ParameterName -ieq 'Type') {
51+
$HasType = $true
52+
$Value = if ($Element.Argument) {
53+
$Element.Argument
54+
} elseif ($i + 1 -lt $Call.CommandElements.Count) {
55+
$Call.CommandElements[$i + 1]
56+
} else {
57+
$null
58+
}
59+
if ($Value -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
60+
$Type = $Value.Value
61+
} else {
62+
$TypeIsLiteral = $false
63+
}
64+
}
65+
}
66+
67+
if ($HasType -and -not $TypeIsLiteral) {
68+
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."
69+
}
70+
71+
$Key = if ($Type) { $Type } else { '' }
72+
if (-not $Data.ContainsKey($Key)) {
73+
$Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type)
74+
}
75+
}
76+
77+
return $Data
78+
}

Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,17 @@ function Get-GraphRequestList {
352352

353353
if (!$QueueThresholdExceeded) {
354354
#nextLink should ONLY be used in direct calls with manual pagination. It should not be used in queueing
355-
if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') { $GraphRequest.uri = $nextLink }
355+
if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') {
356+
try {
357+
$ParsedNextLink = [System.Uri]$nextLink
358+
if ($ParsedNextLink.Host -ne 'graph.microsoft.com') {
359+
throw "Invalid nextLink host: $($ParsedNextLink.Host)"
360+
}
361+
} catch {
362+
throw "Invalid nextLink URL: $nextLink"
363+
}
364+
$GraphRequest.uri = $nextLink
365+
}
356366

357367
$GraphRequestResults = New-GraphGetRequest @GraphRequest -Caller $Caller -ErrorAction Stop
358368
$GraphRequestResults = $GraphRequestResults | Select-Object *, @{n = 'Tenant'; e = { $TenantFilter } }, @{n = 'CippStatus'; e = { 'Good' } }
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
function Invoke-CippSandboxScript {
2+
<#
3+
.SYNOPSIS
4+
Executes custom-test script content inside a ConstrainedLanguage sandbox runspace.
5+
6+
.DESCRIPTION
7+
Compiles and runs the script in a fresh runspace built from the cached sandbox
8+
InitialSessionState (ConstrainedLanguage + command allowlist + Get-CIPPTestData
9+
proxy). The script is compiled via AddScript on the trusted side — never via
10+
[scriptblock]::Create inside the runspace (which CLM blocks) — so it executes
11+
constrained.
12+
13+
Pre-fetched, tenant-locked cache data is injected as $CIPPSandboxData for the proxy.
14+
Parameters are bound by name; passing a parameter the script does not declare is
15+
harmless (ignored), matching how the test runner supplies -TenantFilter.
16+
17+
The sandbox imports no CIPP modules — it only needs the proxy and injected data — so
18+
runspace creation is cheap. (A runspace pool can be layered on later if profiling
19+
shows creation is hot; per-call creation keeps it concurrency-safe for now.)
20+
21+
.PARAMETER ScriptContent
22+
The validated, text-replaced script to run.
23+
24+
.PARAMETER SandboxData
25+
Hashtable of pre-fetched cache data keyed by Type (from Get-CippSandboxData).
26+
27+
.PARAMETER ScriptParameters
28+
Named parameters to bind to the script (e.g. TenantFilter and custom params).
29+
30+
.PARAMETER TimeoutSeconds
31+
Wall-clock execution limit. A script that exceeds it (e.g. an infinite loop) has its
32+
pipeline stopped and is reported as a terminating timeout.
33+
34+
.OUTPUTS
35+
PSCustomObject with Output, Errors, HadErrors, Terminating, TimedOut.
36+
#>
37+
[CmdletBinding()]
38+
param(
39+
[Parameter(Mandatory = $true)]
40+
[string]$ScriptContent,
41+
42+
[Parameter(Mandatory = $false)]
43+
[hashtable]$SandboxData = @{},
44+
45+
[Parameter(Mandatory = $false)]
46+
[hashtable]$ScriptParameters = @{},
47+
48+
[Parameter(Mandatory = $false)]
49+
[ValidateRange(1, 600)]
50+
[int]$TimeoutSeconds = 60
51+
)
52+
53+
# Cache the (reusable) ISS template for the lifetime of the worker process.
54+
if (-not $script:CippSandboxInitialSessionState) {
55+
$script:CippSandboxInitialSessionState = New-CippSandboxInitialSessionState
56+
}
57+
58+
$Runspace = [runspacefactory]::CreateRunspace($script:CippSandboxInitialSessionState)
59+
$Runspace.Open()
60+
try {
61+
# Trusted host (FullLanguage) seeds the locked tenant's data for the proxy.
62+
$Runspace.SessionStateProxy.SetVariable('CIPPSandboxData', $SandboxData)
63+
64+
$PowerShell = [powershell]::Create()
65+
$PowerShell.Runspace = $Runspace
66+
try {
67+
$null = $PowerShell.AddScript($ScriptContent)
68+
foreach ($Key in $ScriptParameters.Keys) {
69+
$null = $PowerShell.AddParameter($Key, $ScriptParameters[$Key])
70+
}
71+
72+
# Run asynchronously so a runaway script can be cancelled on timeout.
73+
$AsyncResult = $PowerShell.BeginInvoke()
74+
$Completed = $AsyncResult.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))
75+
76+
if (-not $Completed) {
77+
# Exceeded the wall-clock limit (e.g. infinite loop). Stop the pipeline.
78+
try { $PowerShell.Stop() } catch {}
79+
return [PSCustomObject]@{
80+
Output = @()
81+
Errors = @("Script exceeded the ${TimeoutSeconds}s execution limit and was cancelled.")
82+
HadErrors = $true
83+
Terminating = $true
84+
TimedOut = $true
85+
}
86+
}
87+
88+
try {
89+
$Output = $PowerShell.EndInvoke($AsyncResult)
90+
} catch {
91+
# Terminating error inside the script.
92+
return [PSCustomObject]@{
93+
Output = @()
94+
Errors = @($_)
95+
HadErrors = $true
96+
Terminating = $true
97+
TimedOut = $false
98+
}
99+
}
100+
101+
return [PSCustomObject]@{
102+
Output = $Output
103+
Errors = @($PowerShell.Streams.Error)
104+
HadErrors = $PowerShell.HadErrors
105+
Terminating = $false
106+
TimedOut = $false
107+
}
108+
} finally {
109+
$PowerShell.Dispose()
110+
}
111+
} finally {
112+
$Runspace.Dispose()
113+
}
114+
}

Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ function New-CIPPIntuneTemplate {
3838
}
3939
'managedAppPolicies' {
4040
$Type = 'AppProtection'
41-
$Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($urlname)('$($ID)')" -tenantid $TenantFilter
41+
$AppProtectionUrl = switch (($ODataType -replace '#microsoft.graph.', '')) {
42+
'androidManagedAppProtection' { 'androidManagedAppProtections' }
43+
'iosManagedAppProtection' { 'iosManagedAppProtections' }
44+
'windowsManagedAppProtection' { 'windowsManagedAppProtections' }
45+
'mdmWindowsInformationProtectionPolicy' { 'mdmWindowsInformationProtectionPolicies' }
46+
'targetedManagedAppConfiguration' { 'targetedManagedAppConfigurations' }
47+
default { 'managedAppPolicies' }
48+
}
49+
$Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($AppProtectionUrl)('$($ID)')" -tenantid $TenantFilter
4250
$DisplayName = $Template.displayName
4351
$TemplateJson = ConvertTo-Json -InputObject $Template -Depth 100 -Compress
4452
}

0 commit comments

Comments
 (0)