From cacc6423d547bb9362afa5495cc5d8b06e0301b8 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:51:06 +0100 Subject: [PATCH 01/31] feat(exchange): add OOO calendar options (block calendar, decline invitations, cancel meetings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for Exchange cloud-only Set-MailboxAutoReplyConfiguration parameters: CreateOOFEvent, OOFEventSubject, AutoDeclineFutureRequestsWhenOOF, DeclineEventsForScheduledOOF, DeclineAllEventsForScheduledOOF, and DeclineMeetingMessage. These are plumbed through all 3 OOO entry points: - Set-CIPPOutOfOffice: 6 new [bool]/[string] params with PSBoundParameters guards - Get-CIPPOutOfOffice: returns new fields + fixes null-safe StartTime/EndTime - Invoke-ExecSetOoO: extracts calendar params from request body (Scheduled only) - Set-CIPPVacationOOO: accepts StartTime/EndTime + calendar params; uses Scheduled state when dates are provided for Exchange calendar option support - Invoke-ExecScheduleOOOVacation: converts epoch to datetime for Scheduled mode, conditionally attaches calendar params to the Add task All changes are backwards compatible — callers that omit the new parameters get identical behavior via $PSBoundParameters.ContainsKey() guards. --- .../Invoke-ExecScheduleOOOVacation.ps1 | 24 +++++++++ .../Administration/Invoke-ExecSetOoO.ps1 | 19 +++++++ .../CIPPCore/Public/Get-CIPPOutOfOffice.ps1 | 16 ++++-- .../CIPPCore/Public/Set-CIPPOutOfoffice.ps1 | 28 +++++++++- .../CIPPCore/Public/Set-CIPPVacationOOO.ps1 | 51 +++++++++++++++++-- 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 index 22582d8ac03f..79fcbea7d920 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecScheduleOOOVacation.ps1 @@ -26,12 +26,36 @@ function Invoke-ExecScheduleOOOVacation { $UserDisplay = ($UserUPNs | Select-Object -First 3) -join ', ' if ($UserUPNs.Count -gt 3) { $UserDisplay += " (+$($UserUPNs.Count - 3) more)" } + # Convert epoch timestamps to datetime strings for Scheduled mode + $StartTimeStr = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).DateTime.ToString() + $EndTimeStr = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).DateTime.ToString() + $SharedParams = [PSCustomObject]@{ TenantFilter = $TenantFilter Users = $UserUPNs InternalMessage = $InternalMessage ExternalMessage = $ExternalMessage APIName = $APIName + StartTime = $StartTimeStr + EndTime = $EndTimeStr + } + + # Calendar options — conditionally add when truthy in the request body + if ($Request.Body.CreateOOFEvent) { + $SharedParams | Add-Member -NotePropertyName 'CreateOOFEvent' -NotePropertyValue $true + } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.OOFEventSubject)) { + $SharedParams | Add-Member -NotePropertyName 'OOFEventSubject' -NotePropertyValue $Request.Body.OOFEventSubject + } + if ($Request.Body.AutoDeclineFutureRequestsWhenOOF) { + $SharedParams | Add-Member -NotePropertyName 'AutoDeclineFutureRequestsWhenOOF' -NotePropertyValue $true + } + if ($Request.Body.DeclineEventsForScheduledOOF) { + $SharedParams | Add-Member -NotePropertyName 'DeclineEventsForScheduledOOF' -NotePropertyValue $true + $SharedParams | Add-Member -NotePropertyName 'DeclineAllEventsForScheduledOOF' -NotePropertyValue $true + } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.DeclineMeetingMessage)) { + $SharedParams | Add-Member -NotePropertyName 'DeclineMeetingMessage' -NotePropertyValue $Request.Body.DeclineMeetingMessage } # Add task — enables OOO with messages at start date diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 index 07fc25199911..a9df5705c33f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 @@ -51,8 +51,27 @@ function Invoke-ExecSetOoO { $EndTime = $Request.Body.EndTime -match '^\d+$' ? [DateTimeOffset]::FromUnixTimeSeconds([int]$Request.Body.EndTime).DateTime : $Request.Body.EndTime $SplatParams.StartTime = $StartTime $SplatParams.EndTime = $EndTime + + # Calendar options — only pass when explicitly provided in the request body + if ($null -ne $Request.Body.CreateOOFEvent) { + $SplatParams.CreateOOFEvent = [bool]$Request.Body.CreateOOFEvent + } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.OOFEventSubject)) { + $SplatParams.OOFEventSubject = $Request.Body.OOFEventSubject + } + if ($null -ne $Request.Body.AutoDeclineFutureRequestsWhenOOF) { + $SplatParams.AutoDeclineFutureRequestsWhenOOF = [bool]$Request.Body.AutoDeclineFutureRequestsWhenOOF + } + if ($null -ne $Request.Body.DeclineEventsForScheduledOOF) { + $SplatParams.DeclineEventsForScheduledOOF = [bool]$Request.Body.DeclineEventsForScheduledOOF + $SplatParams.DeclineAllEventsForScheduledOOF = [bool]$Request.Body.DeclineEventsForScheduledOOF + } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.DeclineMeetingMessage)) { + $SplatParams.DeclineMeetingMessage = $Request.Body.DeclineMeetingMessage + } } + Write-Information "Setting Out of Office with the following parameters: $($SplatParams | ConvertTo-Json -Depth 10)" $Results = Set-CIPPOutOfOffice @SplatParams $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 b/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 index c5b0af944f3c..fd80a389be8f 100644 --- a/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPOutOfOffice.ps1 @@ -10,11 +10,17 @@ function Get-CIPPOutOfOffice { try { $OutOfOffice = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxAutoReplyConfiguration' -cmdParams @{Identity = $UserID } -Anchor $UserID $Results = @{ - AutoReplyState = $OutOfOffice.AutoReplyState - StartTime = $OutOfOffice.StartTime.ToString('yyyy-MM-dd HH:mm') - EndTime = $OutOfOffice.EndTime.ToString('yyyy-MM-dd HH:mm') - InternalMessage = $OutOfOffice.InternalMessage - ExternalMessage = $OutOfOffice.ExternalMessage + AutoReplyState = $OutOfOffice.AutoReplyState + StartTime = $OutOfOffice.StartTime ? $OutOfOffice.StartTime.ToString('yyyy-MM-dd HH:mm') : $null + EndTime = $OutOfOffice.EndTime ? $OutOfOffice.EndTime.ToString('yyyy-MM-dd HH:mm') : $null + InternalMessage = $OutOfOffice.InternalMessage + ExternalMessage = $OutOfOffice.ExternalMessage + CreateOOFEvent = $OutOfOffice.CreateOOFEvent + OOFEventSubject = $OutOfOffice.OOFEventSubject + AutoDeclineFutureRequestsWhenOOF = $OutOfOffice.AutoDeclineFutureRequestsWhenOOF + DeclineEventsForScheduledOOF = $OutOfOffice.DeclineEventsForScheduledOOF + DeclineAllEventsForScheduledOOF = $OutOfOffice.DeclineAllEventsForScheduledOOF + DeclineMeetingMessage = $OutOfOffice.DeclineMeetingMessage } | ConvertTo-Json return $Results } catch { diff --git a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 index 19f7fae9e445..ab71ea5ca769 100644 --- a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 @@ -12,7 +12,13 @@ function Set-CIPPOutOfOffice { $APIName = 'Set Out of Office', $Headers, $StartTime, - $EndTime + $EndTime, + [bool]$CreateOOFEvent, + [string]$OOFEventSubject, + [bool]$AutoDeclineFutureRequestsWhenOOF, + [bool]$DeclineEventsForScheduledOOF, + [bool]$DeclineAllEventsForScheduledOOF, + [string]$DeclineMeetingMessage ) try { @@ -38,6 +44,26 @@ function Set-CIPPOutOfOffice { $EndTime = $EndTime ? $EndTime : (Get-Date $StartTime).AddDays(7) $CmdParams.StartTime = $StartTime $CmdParams.EndTime = $EndTime + + # Calendar options — only included when explicitly provided + if ($PSBoundParameters.ContainsKey('CreateOOFEvent')) { + $CmdParams.CreateOOFEvent = $CreateOOFEvent + } + if ($PSBoundParameters.ContainsKey('OOFEventSubject')) { + $CmdParams.OOFEventSubject = $OOFEventSubject + } + if ($PSBoundParameters.ContainsKey('AutoDeclineFutureRequestsWhenOOF')) { + $CmdParams.AutoDeclineFutureRequestsWhenOOF = $AutoDeclineFutureRequestsWhenOOF + } + if ($PSBoundParameters.ContainsKey('DeclineEventsForScheduledOOF')) { + $CmdParams.DeclineEventsForScheduledOOF = $DeclineEventsForScheduledOOF + } + if ($PSBoundParameters.ContainsKey('DeclineAllEventsForScheduledOOF')) { + $CmdParams.DeclineAllEventsForScheduledOOF = $DeclineAllEventsForScheduledOOF + } + if ($PSBoundParameters.ContainsKey('DeclineMeetingMessage')) { + $CmdParams.DeclineMeetingMessage = $DeclineMeetingMessage + } } $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxAutoReplyConfiguration' -cmdParams $CmdParams -Anchor $UserID diff --git a/Modules/CIPPCore/Public/Set-CIPPVacationOOO.ps1 b/Modules/CIPPCore/Public/Set-CIPPVacationOOO.ps1 index 9ae967996977..320dc1957515 100644 --- a/Modules/CIPPCore/Public/Set-CIPPVacationOOO.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPVacationOOO.ps1 @@ -6,7 +6,15 @@ function Set-CIPPVacationOOO { [string]$InternalMessage, [string]$ExternalMessage, [string]$APIName = 'OOO Vacation Mode', - $Headers + $Headers, + $StartTime, + $EndTime, + [bool]$CreateOOFEvent, + [string]$OOFEventSubject, + [bool]$AutoDeclineFutureRequestsWhenOOF, + [bool]$DeclineEventsForScheduledOOF, + [bool]$DeclineAllEventsForScheduledOOF, + [string]$DeclineMeetingMessage ) $Results = [System.Collections.Generic.List[string]]::new() @@ -14,22 +22,57 @@ function Set-CIPPVacationOOO { foreach ($upn in $Users) { if ([string]::IsNullOrWhiteSpace($upn)) { continue } try { + # Use Scheduled when StartTime/EndTime are provided (vacation always has dates), + # otherwise fall back to Enabled for backwards compatibility with in-flight tasks + $State = if ($Action -eq 'Add') { + if ($PSBoundParameters.ContainsKey('StartTime') -and $PSBoundParameters.ContainsKey('EndTime')) { 'Scheduled' } else { 'Enabled' } + } else { 'Disabled' } + $SplatParams = @{ UserID = $upn TenantFilter = $TenantFilter - State = if ($Action -eq 'Add') { 'Enabled' } else { 'Disabled' } + State = $State APIName = $APIName Headers = $Headers } - # Only pass messages on Add — Remove only disables, preserving any messages - # the user may have updated themselves during vacation + if ($Action -eq 'Add') { + # Pass start/end times when available + if ($PSBoundParameters.ContainsKey('StartTime')) { + $SplatParams.StartTime = $StartTime + } + if ($PSBoundParameters.ContainsKey('EndTime')) { + $SplatParams.EndTime = $EndTime + } + + # Only pass messages on Add — Remove only disables, preserving any messages + # the user may have updated themselves during vacation if (-not [string]::IsNullOrWhiteSpace($InternalMessage)) { $SplatParams.InternalMessage = $InternalMessage } if (-not [string]::IsNullOrWhiteSpace($ExternalMessage)) { $SplatParams.ExternalMessage = $ExternalMessage } + + # Calendar options — pass through when explicitly provided + if ($PSBoundParameters.ContainsKey('CreateOOFEvent')) { + $SplatParams.CreateOOFEvent = $CreateOOFEvent + } + if ($PSBoundParameters.ContainsKey('OOFEventSubject')) { + $SplatParams.OOFEventSubject = $OOFEventSubject + } + if ($PSBoundParameters.ContainsKey('AutoDeclineFutureRequestsWhenOOF')) { + $SplatParams.AutoDeclineFutureRequestsWhenOOF = $AutoDeclineFutureRequestsWhenOOF + } + if ($PSBoundParameters.ContainsKey('DeclineEventsForScheduledOOF')) { + $SplatParams.DeclineEventsForScheduledOOF = $DeclineEventsForScheduledOOF + } + if ($PSBoundParameters.ContainsKey('DeclineAllEventsForScheduledOOF')) { + $SplatParams.DeclineAllEventsForScheduledOOF = $DeclineAllEventsForScheduledOOF + } + if ($PSBoundParameters.ContainsKey('DeclineMeetingMessage')) { + $SplatParams.DeclineMeetingMessage = $DeclineMeetingMessage + } } $result = Set-CIPPOutOfOffice @SplatParams $Results.Add($result) From aac2f4e6b9aee330a526dbbb4faaecdc7bb2f45a Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:43:10 +0100 Subject: [PATCH 02/31] refactor: replace bulk Graph request with per-method foreach loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation batched all MFA method deletions into a single Graph bulk request, which introduced two problems: 1. Duplicate method types (e.g. two phone numbers) could collide within the same batch, causing one of the requests to fail silently. 2. The success/failure check only inspected a single status code from the bulk response. If one method was removed but another failed, the function logged full success — leaving the user's MFA partially intact despite the log stating otherwise. Switching to a sequential foreach loop eliminates the collision window and tracks successes and failures independently, so partial failures are reported accurately. --- .../Users/Invoke-ExecResetMFA.ps1 | 4 +- .../CIPPCore/Public/Remove-CIPPUserMFA.ps1 | 67 ++++++++++--------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecResetMFA.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecResetMFA.ps1 index 47d72e537885..2c7cc7b4ad9d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecResetMFA.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecResetMFA.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecResetMFA { +function Invoke-ExecResetMFA { <# .FUNCTIONALITY Entrypoint @@ -7,8 +7,8 @@ Function Invoke-ExecResetMFA { #> [CmdletBinding()] param($Request, $TriggerMetadata) - $Headers = $Request.Headers + $Headers = $Request.Headers # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter diff --git a/Modules/CIPPCore/Public/Remove-CIPPUserMFA.ps1 b/Modules/CIPPCore/Public/Remove-CIPPUserMFA.ps1 index 722d0b73a616..bb0c2b64f93f 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPUserMFA.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPUserMFA.ps1 @@ -4,7 +4,7 @@ function Remove-CIPPUserMFA { Remove MFA methods for a user .DESCRIPTION - Remove MFA methods for a user using bulk requests to the Microsoft Graph API + Remove MFA methods for a user using individual requests to the Microsoft Graph API .PARAMETER UserPrincipalName UserPrincipalName of the user to remove MFA methods for @@ -17,7 +17,7 @@ function Remove-CIPPUserMFA { #> [CmdletBinding(SupportsShouldProcess = $true)] - Param( + param( [Parameter(Mandatory = $true)] [string]$UserPrincipalName, [Parameter(Mandatory = $true)] @@ -38,42 +38,49 @@ function Remove-CIPPUserMFA { throw $Message } - $Requests = [System.Collections.Generic.List[object]]::new() - foreach ($Method in $AuthMethods) { - if ($Method.'@odata.type' -and $Method.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod') { - $MethodType = ($Method.'@odata.type' -split '\.')[-1] -replace 'Authentication', '' - $Requests.Add(@{ - id = "$MethodType-$($Method.id)" - method = 'DELETE' - url = ('users/{0}/authentication/{1}s/{2}' -f $UserPrincipalName, $MethodType, $Method.id) - }) - } - } + $RemovableMethods = $AuthMethods | Where-Object { $_.'@odata.type' -and $_.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' } - if (($Requests | Measure-Object).Count -eq 0) { + if (($RemovableMethods | Measure-Object).Count -eq 0) { $Results = "No MFA methods found for user $UserPrincipalName" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev 'Info' return $Results - } else { - if ($PSCmdlet.ShouldProcess("Remove MFA methods for $UserPrincipalName")) { - try { - $Results = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true -ErrorAction Stop - if ($Results.status -eq 204) { - $Message = "Successfully removed MFA methods for user $UserPrincipalName. User must supply MFA at next logon" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -sev 'Info' - return $Message - } else { - $FailedAuthMethods = (($Results | Where-Object { $_.status -ne 204 }).id -split '-')[0] -join ', ' - $Message = "Failed to remove MFA methods for $FailedAuthMethods on user $UserPrincipalName" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -sev 'Error' - throw $Message + } + + if ($PSCmdlet.ShouldProcess("Remove MFA methods for $UserPrincipalName")) { + $Failed = [System.Collections.Generic.List[string]]::new() + $Succeeded = [System.Collections.Generic.List[string]]::new() + foreach ($Method in $RemovableMethods) { + $MethodType = ($Method.'@odata.type' -split '\.')[-1] -replace 'Authentication', '' + switch ($MethodType) { + 'qrCodePinMethod' { + $Uri = 'https://graph.microsoft.com/beta/users/{0}/authentication/{1}' -f $UserPrincipalName, $MethodType + break } + default { + $Uri = 'https://graph.microsoft.com/v1.0/users/{0}/authentication/{1}s/{2}' -f $UserPrincipalName, $MethodType, $Method.id + } + } + try { + $null = New-GraphPOSTRequest -uri $Uri -tenantid $TenantFilter -type DELETE -AsApp $true + $Succeeded.Add($MethodType) } catch { $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to remove MFA methods for user $UserPrincipalName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -sev 'Error' -LogData $ErrorMessage - throw $Message + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove $MethodType for $UserPrincipalName. Error: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + $Failed.Add($MethodType) } } + + if ($Failed.Count -gt 0) { + $Message = if ($Succeeded.Count -gt 0) { + "Successfully removed MFA methods ($($Succeeded -join ', ')) for user $UserPrincipalName. However, failed to remove ($($Failed -join ', ')). User may still have MFA methods assigned." + } else { + "Failed to remove MFA methods ($($Failed -join ', ')) for user $UserPrincipalName" + } + throw $Message + } + + $Message = "Successfully removed MFA methods ($($Succeeded -join ', ')) for user $UserPrincipalName. User must supply MFA at next logon" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -sev 'Info' + return $Message } } From c30c96a56e9ec326aef1e95004caa2a804de174f Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Fri, 20 Mar 2026 00:05:14 +0100 Subject: [PATCH 03/31] support group memberships in user templates and filter unavailable licenses --- .../Users/Invoke-AddUserDefaults.ps1 | 25 +++++++++++++ Modules/CIPPCore/Public/New-CIPPUserTask.ps1 | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 63e6e2b50cfc..6936763f2f50 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -73,6 +73,30 @@ function Invoke-AddUserDefaults { $SetSponsor = $Request.Body.setSponsor $CopyFrom = $Request.Body.copyFrom + # Fetch group memberships from source user if provided + $GroupMemberships = @() + if ($Request.Body.sourceUserId -and $TenantFilter) { + try { + Write-Host "Fetching group memberships from source user: $($Request.Body.sourceUserId)" + $SourceGroups = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Request.Body.sourceUserId)/memberOf" -tenantid $TenantFilter + $GroupMemberships = @($SourceGroups | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.group' -and + $_.groupTypes -notcontains 'DynamicMembership' -and + $_.onPremisesSyncEnabled -ne $true + } | ForEach-Object { + @{ + id = $_.id + displayName = $_.displayName + mailEnabled = $_.mailEnabled + groupTypes = $_.groupTypes + } + }) + Write-Host "Found $($GroupMemberships.Count) group memberships to store in template" + } catch { + Write-Warning "Failed to fetch group memberships: $($_.Exception.Message)" + } + } + # Create template object with all fields from CippAddEditUser $TemplateObject = @{ tenantFilter = $TenantFilter @@ -104,6 +128,7 @@ function Invoke-AddUserDefaults { setManager = $SetManager setSponsor = $SetSponsor copyFrom = $CopyFrom + groupMemberships = $GroupMemberships } # Use existing GUID if editing, otherwise generate new one diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index fb831949ccaa..f8644199a762 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -20,6 +20,23 @@ function New-CIPPUserTask { try { if ($UserObj.licenses.value) { + # Filter out licenses with no available units + try { + $SubscribedSkus = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/subscribedSkus' -tenantid $UserObj.tenantFilter + $FilteredLicenses = @(foreach ($LicenseId in $UserObj.licenses.value) { + $Sku = $SubscribedSkus | Where-Object { $_.skuId -eq $LicenseId } + $Available = [int]$Sku.prepaidUnits.enabled - [int]$Sku.consumedUnits + if ($Sku -and $Available -le 0) { + $null = $Results.Add("Skipped license $($Sku.skuPartNumber): no available units") + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Skipped license $($Sku.skuPartNumber) for $($CreationResults.Username): no available units" -Sev 'Warn' + } else { + $LicenseId + } + }) + $UserObj.licenses = [PSCustomObject]@{ value = $FilteredLicenses } + } catch { + Write-Warning "Failed to check available licenses: $($_.Exception.Message)" + } if ($UserObj.sherwebLicense.value) { $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 $null = $Results.Add('Added Sherweb License, scheduling assignment') @@ -68,6 +85,25 @@ function New-CIPPUserTask { $CopyFrom.Error | ForEach-Object { $Results.Add($_) } } + if ($UserObj.groupMemberships -and ($UserObj.groupMemberships | Measure-Object).Count -gt 0) { + Write-Host "Adding user to $(@($UserObj.groupMemberships).Count) groups from template" + $ODataBind = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $CreationResults.User.id + $AddMemberBody = @{ '@odata.id' = $ODataBind } | ConvertTo-Json -Compress + foreach ($Group in $UserObj.groupMemberships) { + try { + if ($Group.mailEnabled -and $Group.groupTypes -notcontains 'Unified') { + $Params = @{ Identity = $Group.id; Member = $CreationResults.Username; BypassSecurityGroupManagerCheck = $true } + $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $Params -UseSystemMailbox $true + } else { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$($Group.id)/members/`$ref" -tenantid $UserObj.tenantFilter -body $AddMemberBody -Verbose + } + $Results.Add("Added user to group from template: $($Group.displayName)") + } catch { + $Results.Add("Failed to add to group $($Group.displayName): $($_.Exception.Message)") + } + } + } + if ($UserObj.setManager) { $ManagerResult = Set-CIPPManager -User $CreationResults.Username -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers $Results.Add($ManagerResult) From 5ecdc29a503b9dc01006be6353efc86e9f539129 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Mar 2026 22:39:16 -0400 Subject: [PATCH 04/31] fix: text replacement for when tenant filter is unspecified --- .../Public/Get-CIPPTextReplacement.ps1 | 2 +- .../Webhooks/Test-CIPPAuditLogRules.ps1 | 91 ++++++++++++------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index a86ce4751645..7798ab8a4047 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -12,7 +12,7 @@ function Get-CIPPTextReplacement { Get-CIPPTextReplacement -TenantFilter 'contoso.com' -Text 'Hello %tenantname%' #> param ( - [string]$TenantFilter, + [string]$TenantFilter = $env:TenantID, $Text, [switch]$EscapeForJson ) diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 94b7c876fef7..4d35b2a94548 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -520,57 +520,82 @@ function Test-CIPPAuditLogRules { if ($TenantFilter -in $Config.Excluded.value) { continue } - $conditions = $Config.Conditions | ConvertFrom-Json | Where-Object { $Config.Input.value -ne '' } + $conditions = $Config.Conditions | ConvertFrom-Json | Where-Object { $_.Input.value -ne '' } $actions = $Config.Actions - $conditionStrings = [System.Collections.Generic.List[string]]::new() $CIPPClause = [System.Collections.Generic.List[string]]::new() - $AddedLocationCondition = $false + + # Build excluded user keys for location-based conditions + $LocationExcludedUserKeys = @() + $HasGeoCondition = $false foreach ($condition in $conditions) { - if ($condition.Property.label -eq 'CIPPGeoLocation' -and !$AddedLocationCondition) { - $conditionStrings.Add("`$_.HasLocationData -eq `$true") - $CIPPClause.Add('HasLocationData is true') - $ExcludedUsers = $ExcludedUsers | Where-Object { $_.Type -eq 'Location' } - # Build single -notin condition against all excluded user keys - $ExcludedUserKeys = @($ExcludedUsers.RowKey) - if ($ExcludedUserKeys.Count -gt 0) { - $conditionStrings.Add("`$(`$_.CIPPUserKey) -notin @('$($ExcludedUserKeys -join "', '")')") - $CIPPClause.Add("CIPPUserKey not in [$($ExcludedUserKeys -join ', ')]") - } - $AddedLocationCondition = $true + if ($condition.Property.label -eq 'CIPPGeoLocation') { + $HasGeoCondition = $true + $LocationExcludedUsers = $ExcludedUsers | Where-Object { $_.Type -eq 'Location' } + $LocationExcludedUserKeys = @($LocationExcludedUsers.RowKey) } - $value = if ($condition.Input.value -is [array]) { - $arrayAsString = $condition.Input.value | ForEach-Object { - "'$_'" - } - "@($($arrayAsString -join ', '))" - } else { "'$($condition.Input.value)'" } - - $conditionStrings.Add("`$(`$_.$($condition.Property.label)) -$($condition.Operator.value) $value") - $CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $value") + $CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $($condition.Input.value)") } - $finalCondition = $conditionStrings -join ' -AND ' [PSCustomObject]@{ - clause = $finalCondition - expectedAction = $actions - CIPPClause = $CIPPClause - AlertComment = $Config.AlertComment + conditions = $conditions + expectedAction = $actions + CIPPClause = $CIPPClause + AlertComment = $Config.AlertComment + HasGeoCondition = $HasGeoCondition + ExcludedUserKeys = $LocationExcludedUserKeys } } } catch { Write-Warning "Error creating where clause: $($_.Exception.Message)" Write-Information $_.InvocationInfo.PositionMessage - #Write-LogMessage -API 'Webhooks' -message 'Error creating where clause' -LogData (Get-CippException -Exception $_) -sev Error -tenant $TenantFilter throw $_ } $MatchedRules = [System.Collections.Generic.List[string]]::new() + $UnsafeValueRegex = [regex]'[;|`\$\{\}]' $DataToProcess = foreach ($clause in $Where) { try { $ClauseStartTime = Get-Date - Write-Warning "Webhook: Processing clause: $($clause.clause)" + Write-Warning "Webhook: Processing conditions: $($clause.CIPPClause -join ' and ')" Write-Information "Webhook: Available operations in data: $(($ProcessedData.Operation | Select-Object -Unique) -join ', ')" - $ReturnedData = $ProcessedData | Where-Object { Invoke-Expression $clause.clause } + + # Build sanitized condition strings instead of direct evaluation + $conditionStrings = [System.Collections.Generic.List[string]]::new() + $validClause = $true + foreach ($condition in $clause.conditions) { + # Add geo-location prerequisites before the condition itself + if ($condition.Property.label -eq 'CIPPGeoLocation') { + $conditionStrings.Add('$_.HasLocationData -eq $true') + if ($clause.ExcludedUserKeys.Count -gt 0) { + $sanitizedKeys = foreach ($key in $clause.ExcludedUserKeys) { + $keyStr = [string]$key + if ($UnsafeValueRegex.IsMatch($keyStr)) { + Write-Warning "Blocked unsafe excluded user key: '$keyStr'" + $validClause = $false + break + } + "'{0}'" -f ($keyStr -replace "'", "''") + } + if (-not $validClause) { break } + $conditionStrings.Add("`$_.CIPPUserKey -notin @($($sanitizedKeys -join ', '))") + } + } + $sanitized = Test-CIPPConditionFilter -Condition $condition + if ($null -eq $sanitized) { + Write-Warning "Skipping rule due to invalid condition for property '$($condition.Property.label)'" + $validClause = $false + break + } + $conditionStrings.Add($sanitized) + } + + if (-not $validClause -or $conditionStrings.Count -eq 0) { + continue + } + + $WhereString = $conditionStrings -join ' -and ' + $WhereBlock = [ScriptBlock]::Create($WhereString) + $ReturnedData = $ProcessedData | Where-Object $WhereBlock if ($ReturnedData) { Write-Warning "Webhook: There is matching data: $(($ReturnedData.operation | Select-Object -Unique) -join ', ')" $ReturnedData = foreach ($item in $ReturnedData) { @@ -583,10 +608,10 @@ function Test-CIPPAuditLogRules { } $ClauseEndTime = Get-Date $ClauseSeconds = ($ClauseEndTime - $ClauseStartTime).TotalSeconds - Write-Warning "Task took $ClauseSeconds seconds for clause: $($clause.clause)" + Write-Warning "Task took $ClauseSeconds seconds for conditions: $($clause.CIPPClause -join ' and ')" $ReturnedData } catch { - Write-Warning "Error processing clause: $($clause.clause): $($_.Exception.Message)" + Write-Warning "Error processing conditions: $($clause.CIPPClause -join ' and '): $($_.Exception.Message)" } } $Results.MatchedRules = @($MatchedRules | Select-Object -Unique) From 94c0157e1a15b07c18909c1b6c2e1d1a69e97cfe Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Mar 2026 22:44:20 -0400 Subject: [PATCH 05/31] feat: Enhance security and functionality across multiple modules - Improved condition handling in Test-DeltaQueryConditions to sanitize inputs and prevent invalid conditions from being processed. - Added validation for dynamic rules in Invoke-ExecTenantGroup to prevent code injection by restricting allowed operators and properties. - Implemented error handling and validation for conditions in Invoke-AddAlert, ensuring only safe operators and properties are processed. - Updated New-CIPPAlertTemplate to include a CustomSubject parameter for more flexible alert titles. - Refactored Update-CIPPDynamicTenantGroups to utilize a safer evaluation method for dynamic group rules, ensuring only valid conditions are processed. - Enhanced webhook processing in Invoke-CIPPWebhookProcessing to include custom subjects from webhook rules for better context in alerts. --- .../Private/Test-CIPPConditionFilter.ps1 | 78 +++++++ .../Private/Test-CIPPDynamicGroupFilter.ps1 | 210 ++++++++++++++++++ .../Test-DeltaQueryConditions.ps1 | 44 ++-- .../CIPP/Settings/Invoke-ExecTenantGroup.ps1 | 20 ++ .../Administration/Alerts/Invoke-AddAlert.ps1 | 80 ++++--- .../CIPPCore/Public/New-CIPPAlertTemplate.ps1 | 33 +-- .../Update-CIPPDynamicTenantGroups.ps1 | 133 ++--------- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 11 +- 8 files changed, 435 insertions(+), 174 deletions(-) create mode 100644 Modules/CIPPCore/Private/Test-CIPPConditionFilter.ps1 create mode 100644 Modules/CIPPCore/Private/Test-CIPPDynamicGroupFilter.ps1 diff --git a/Modules/CIPPCore/Private/Test-CIPPConditionFilter.ps1 b/Modules/CIPPCore/Private/Test-CIPPConditionFilter.ps1 new file mode 100644 index 000000000000..6369094e852d --- /dev/null +++ b/Modules/CIPPCore/Private/Test-CIPPConditionFilter.ps1 @@ -0,0 +1,78 @@ +function Test-CIPPConditionFilter { + <# + .SYNOPSIS + Returns a sanitized PowerShell condition string for an audit log / delta query condition. + .DESCRIPTION + Validates operator and property name against allowlists, sanitizes input values, + then returns a safe condition string suitable for [ScriptBlock]::Create(). + + This replaces the old Invoke-Expression pattern which was vulnerable to code injection + through unsanitized user-controlled fields. + .PARAMETER Condition + A single condition object with Property.label, Operator.value, and Input.value. + .OUTPUTS + [string] A sanitized PowerShell condition string, or $null if validation fails. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + $Condition + ) + + # Operator allowlist - only these PowerShell comparison operators are permitted + $AllowedOperators = @( + 'eq', 'ne', 'like', 'notlike', 'match', 'notmatch', + 'gt', 'lt', 'ge', 'le', 'in', 'notin', + 'contains', 'notcontains' + ) + + # Property name validation - only alphanumeric, underscores, and dots allowed + $SafePropertyRegex = [regex]'^[a-zA-Z0-9_.]+$' + + # Value sanitization - block characters that enable code injection + $UnsafeValueRegex = [regex]'[;|`\$\{\}]' + + $propertyName = $Condition.Property.label + $operatorValue = $Condition.Operator.value.ToLower() + $inputValue = $Condition.Input.value + + # Validate operator against allowlist + if ($operatorValue -notin $AllowedOperators) { + Write-Warning "Blocked invalid operator '$($Condition.Operator.value)' in condition for property '$propertyName'" + return $null + } + + # Validate property name to prevent injection via property paths + if (-not $SafePropertyRegex.IsMatch($propertyName)) { + Write-Warning "Blocked invalid property name '$propertyName' in condition" + return $null + } + + # Build sanitized condition string + if ($inputValue -is [array]) { + # Sanitize each array element + $sanitizedItems = foreach ($item in $inputValue) { + $itemStr = [string]$item + if ($UnsafeValueRegex.IsMatch($itemStr)) { + Write-Warning "Blocked unsafe value in array for property '$propertyName': '$itemStr'" + return $null + } + $itemStr -replace "'", "''" + } + if ($null -eq $sanitizedItems) { return $null } + $arrayAsString = $sanitizedItems | ForEach-Object { "'$_'" } + $value = "@($($arrayAsString -join ', '))" + } else { + $valueStr = [string]$inputValue + if ($UnsafeValueRegex.IsMatch($valueStr)) { + Write-Warning "Blocked unsafe value for property '$propertyName': '$valueStr'" + return $null + } + $value = "'$($valueStr -replace "'", "''")'" + } + + return "`$(`$_.$propertyName) -$operatorValue $value" +} diff --git a/Modules/CIPPCore/Private/Test-CIPPDynamicGroupFilter.ps1 b/Modules/CIPPCore/Private/Test-CIPPDynamicGroupFilter.ps1 new file mode 100644 index 000000000000..f8138c972094 --- /dev/null +++ b/Modules/CIPPCore/Private/Test-CIPPDynamicGroupFilter.ps1 @@ -0,0 +1,210 @@ +function Test-CIPPDynamicGroupFilter { + <# + .SYNOPSIS + Returns a sanitized PowerShell condition string for a dynamic tenant group rule. + .DESCRIPTION + Validates all user-controlled inputs (property, operator, values) against allowlists + and sanitizes values before building the condition string. Returns a safe condition + string suitable for use in [ScriptBlock]::Create(). + + This replaces the old pattern of directly interpolating unsanitized user input into + scriptblock strings, which was vulnerable to code injection. + .PARAMETER Rule + A single rule object with .property, .operator, and .value fields. + .PARAMETER TenantGroupMembersCache + Hashtable of group memberships keyed by group ID. + .OUTPUTS + [string] A sanitized PowerShell condition string, or $null if validation fails. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + $Rule, + [Parameter(Mandatory = $false)] + [hashtable]$TenantGroupMembersCache = @{} + ) + + $AllowedOperators = @('eq', 'ne', 'like', 'notlike', 'in', 'notin', 'contains', 'notcontains') + $AllowedProperties = @('delegatedAccessStatus', 'availableLicense', 'availableServicePlan', 'tenantGroupMember', 'customVariable') + + # Regex for sanitizing string values - block characters that enable code injection + $SafeValueRegex = [regex]'^[^;|`\$\{\}\(\)]*$' + # Regex for GUID validation + $GuidRegex = [regex]'^[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}$' + # Regex for safe identifiers (variable names, plan names, etc.) + $SafeIdentifierRegex = [regex]'^[a-zA-Z0-9_.\-\s\(\)]+$' + + $Property = $Rule.property + $Operator = [string]($Rule.operator) + $OperatorLower = $Operator.ToLower() + $Value = $Rule.value + + # Validate operator + if ($OperatorLower -notin $AllowedOperators) { + Write-Warning "Blocked invalid operator '$Operator' in dynamic group rule for property '$Property'" + return $null + } + + # Validate property + if ($Property -notin $AllowedProperties) { + Write-Warning "Blocked invalid property '$Property' in dynamic group rule" + return $null + } + + # Helper: sanitize a single string value for safe embedding in a quoted string + function Protect-StringValue { + param([string]$InputValue) + # Escape single quotes by doubling them (PowerShell string escaping) + $escaped = $InputValue -replace "'", "''" + # Block any remaining injection characters + if (-not $SafeValueRegex.IsMatch($escaped)) { + Write-Warning "Blocked unsafe value: '$InputValue'" + return $null + } + return $escaped + } + + # Helper: sanitize and format an array of string values for embedding in @('a','b') + function Protect-StringArray { + param([array]$InputValues) + $sanitized = foreach ($v in $InputValues) { + $clean = Protect-StringValue -InputValue ([string]$v) + if ($null -eq $clean) { return $null } + "'$clean'" + } + return "@($($sanitized -join ', '))" + } + + switch ($Property) { + 'delegatedAccessStatus' { + $safeValue = Protect-StringValue -InputValue ([string]$Value.value) + if ($null -eq $safeValue) { return $null } + return "`$_.delegatedPrivilegeStatus -$OperatorLower '$safeValue'" + } + 'availableLicense' { + if ($OperatorLower -in @('in', 'notin')) { + $arrayValues = @(if ($Value -is [array]) { $Value.guid } else { @($Value.guid) }) + # Validate each GUID + foreach ($g in $arrayValues) { + if (![string]::IsNullOrEmpty($g) -and -not $GuidRegex.IsMatch($g)) { + Write-Warning "Blocked invalid GUID in availableLicense rule: '$g'" + return $null + } + } + $arrayAsString = ($arrayValues | Where-Object { ![string]::IsNullOrEmpty($_) }) | ForEach-Object { "'$_'" } + if ($OperatorLower -eq 'in') { + return "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0" + } else { + return "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0" + } + } else { + $guid = [string]$Value.guid + if (![string]::IsNullOrEmpty($guid) -and -not $GuidRegex.IsMatch($guid)) { + Write-Warning "Blocked invalid GUID in availableLicense rule: '$guid'" + return $null + } + return "`$_.skuId -$OperatorLower '$guid'" + } + } + 'availableServicePlan' { + if ($OperatorLower -in @('in', 'notin')) { + $arrayValues = @(if ($Value -is [array]) { $Value.value } else { @($Value.value) }) + foreach ($v in $arrayValues) { + if (![string]::IsNullOrEmpty($v) -and -not $SafeIdentifierRegex.IsMatch($v)) { + Write-Warning "Blocked invalid service plan name: '$v'" + return $null + } + } + $arrayAsString = ($arrayValues | Where-Object { ![string]::IsNullOrEmpty($_) }) | ForEach-Object { "'$_'" } + if ($OperatorLower -eq 'in') { + return "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0" + } else { + return "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0" + } + } else { + $safeValue = Protect-StringValue -InputValue ([string]$Value.value) + if ($null -eq $safeValue) { return $null } + return "`$_.servicePlans -$OperatorLower '$safeValue'" + } + } + 'tenantGroupMember' { + if ($OperatorLower -in @('in', 'notin')) { + $ReferencedGroupIds = @($Value.value) + # Validate group IDs are GUIDs + foreach ($gid in $ReferencedGroupIds) { + if (![string]::IsNullOrEmpty($gid) -and -not $GuidRegex.IsMatch($gid)) { + Write-Warning "Blocked invalid group ID in tenantGroupMember rule: '$gid'" + return $null + } + } + + $AllMembers = [System.Collections.Generic.HashSet[string]]::new() + foreach ($GroupId in $ReferencedGroupIds) { + if ($TenantGroupMembersCache.ContainsKey($GroupId)) { + foreach ($MemberId in $TenantGroupMembersCache[$GroupId]) { + [void]$AllMembers.Add($MemberId) + } + } + } + + $MemberArray = $AllMembers | ForEach-Object { "'$_'" } + $MemberArrayString = $MemberArray -join ', ' + + if ($OperatorLower -eq 'in') { + return "`$_.customerId -in @($MemberArrayString)" + } else { + return "`$_.customerId -notin @($MemberArrayString)" + } + } else { + $ReferencedGroupId = [string]$Value.value + if (![string]::IsNullOrEmpty($ReferencedGroupId) -and -not $GuidRegex.IsMatch($ReferencedGroupId)) { + Write-Warning "Blocked invalid group ID: '$ReferencedGroupId'" + return $null + } + return "`$_.customerId -$OperatorLower `$script:TenantGroupMembersCache['$ReferencedGroupId']" + } + } + 'customVariable' { + $VariableName = if ($Value.variableName -is [string]) { + $Value.variableName + } elseif ($Value.variableName.value) { + $Value.variableName.value + } else { + [string]$Value.variableName + } + # Validate variable name - alphanumeric, underscores, hyphens, dots only + if (-not $SafeIdentifierRegex.IsMatch($VariableName)) { + Write-Warning "Blocked invalid custom variable name: '$VariableName'" + return $null + } + $ExpectedValue = Protect-StringValue -InputValue ([string]$Value.value) + if ($null -eq $ExpectedValue) { return $null } + + switch ($OperatorLower) { + 'eq' { + return "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -eq '$ExpectedValue')" + } + 'ne' { + return "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -ne '$ExpectedValue')" + } + 'like' { + return "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -like '*$ExpectedValue*')" + } + 'notlike' { + return "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -notlike '*$ExpectedValue*')" + } + default { + Write-Warning "Unsupported operator '$OperatorLower' for customVariable" + return $null + } + } + } + default { + Write-Warning "Unknown property type: $Property" + return $null + } + } +} diff --git a/Modules/CIPPCore/Public/DeltaQueries/Test-DeltaQueryConditions.ps1 b/Modules/CIPPCore/Public/DeltaQueries/Test-DeltaQueryConditions.ps1 index a510320b160a..140509db5853 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/Test-DeltaQueryConditions.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/Test-DeltaQueryConditions.ps1 @@ -106,35 +106,33 @@ function Test-DeltaQueryConditions { $conditions = $Trigger.Conditions | ConvertFrom-Json | Where-Object { $_.Input.value -ne '' -and $_.Input.value -ne $null } if ($conditions) { - # Initialize collections for condition strings - $conditionStrings = [System.Collections.Generic.List[string]]::new() + # Build human-readable clause for logging $CIPPClause = [System.Collections.Generic.List[string]]::new() + foreach ($condition in $conditions) { + $CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $($condition.Input.value)") + } + Write-Information "Testing delta query conditions: $($CIPPClause -join ' and ')" + # Build sanitized condition strings instead of direct evaluation + $conditionStrings = [System.Collections.Generic.List[string]]::new() + $validConditions = $true foreach ($condition in $conditions) { - # Handle array vs single values - $value = if ($condition.Input.value -is [array]) { - $arrayAsString = $condition.Input.value | ForEach-Object { - "'$_'" - } - "@($($arrayAsString -join ', '))" - } else { - "'$($condition.Input.value)'" + $sanitized = Test-CIPPConditionFilter -Condition $condition + if ($null -eq $sanitized) { + Write-Warning "Skipping due to invalid condition for property '$($condition.Property.label)'" + $validConditions = $false + break } - - # Build PowerShell condition string - $conditionStrings.Add("`$(`$_.$($condition.Property.label)) -$($condition.Operator.value) $value") - $CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $value") + $conditionStrings.Add($sanitized) } - # Join all conditions with AND - $finalCondition = $conditionStrings -join ' -AND ' - - Write-Information "Testing delta query conditions: $finalCondition" - Write-Information "Human readable: $($CIPPClause -join ' and ')" - - # Apply conditions to filter the data using a script block instead of Invoke-Expression - $scriptBlock = [scriptblock]::Create("param(`$_) $finalCondition") - $MatchedData = $Data | Where-Object $scriptBlock + if ($validConditions -and $conditionStrings.Count -gt 0) { + $WhereString = $conditionStrings -join ' -and ' + $WhereBlock = [ScriptBlock]::Create($WhereString) + $MatchedData = $Data | Where-Object $WhereBlock + } else { + $MatchedData = @() + } } else { Write-Information 'No valid conditions found in trigger configuration.' $MatchedData = $Data diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 index 73b1a1bce23b..29be42487b60 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecTenantGroup.ps1 @@ -23,6 +23,26 @@ function Invoke-ExecTenantGroup { $dynamicRules = $Request.Body.dynamicRules $ruleLogic = $Request.Body.ruleLogic ?? 'and' + # Validate dynamic rules to prevent code injection + if ($groupType -eq 'dynamic' -and $dynamicRules) { + $AllowedDynamicOperators = @('eq', 'ne', 'like', 'notlike', 'in', 'notin', 'contains', 'notcontains') + $AllowedDynamicProperties = @('delegatedAccessStatus', 'availableLicense', 'availableServicePlan', 'tenantGroupMember', 'customVariable') + foreach ($rule in $dynamicRules) { + if ($rule.operator -and $rule.operator.ToLower() -notin $AllowedDynamicOperators) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Invalid operator in dynamic rule: $($rule.operator)" } + }) + } + if ($rule.property -and $rule.property -notin $AllowedDynamicProperties) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Invalid property in dynamic rule: $($rule.property)" } + }) + } + } + } + $AllowedGroups = Test-CippAccess -Request $Request -GroupList if ($AllowedGroups -notcontains 'AllGroups') { return ([HttpResponseContext]@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index df6818654545..31b38f41bc1e 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -7,31 +7,61 @@ function Invoke-AddAlert { #> [CmdletBinding()] param($Request, $TriggerMetadata) - # Interact with query parameters or the body of the request. - $Tenants = $Request.Body.tenantFilter - $Conditions = $Request.Body.conditions | ConvertTo-Json -Compress -Depth 10 | Out-String - $TenantsJson = $Tenants | ConvertTo-Json -Compress -Depth 10 | Out-String - $excludedTenantsJson = $Request.Body.excludedTenants | ConvertTo-Json -Compress -Depth 10 | Out-String - $Actions = $Request.Body.actions | ConvertTo-Json -Compress -Depth 10 | Out-String - $RowKey = $Request.Body.RowKey ? $Request.Body.RowKey : (New-Guid).ToString() - $CompleteObject = @{ - Tenants = [string]$TenantsJson - excludedTenants = [string]$excludedTenantsJson - Conditions = [string]$Conditions - Actions = [string]$Actions - type = $Request.Body.logbook.value - RowKey = $RowKey - PartitionKey = 'Webhookv2' - AlertComment = [string]$Request.Body.AlertComment - } - $WebhookTable = Get-CippTable -TableName 'WebhookRules' - Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force - $Results = "Added Audit Log Alert for $($Tenants.count) tenants. It may take up to four hours before Microsoft starts delivering these alerts." - Write-LogMessage -API 'AddAlert' -message $Results -sev Info -LogData $CompleteObject -headers $Request.Headers - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{ 'Results' = @($Results) } - }) + try { + + $Conditions = $Request.Body.conditions + Write-Information "Received request to add alert with conditions: $($Conditions | ConvertTo-Json -Compress -Depth 10)" + + # Validate conditions to prevent code injection via operator/property fields + $AllowedOperators = @('eq', 'ne', 'like', 'notlike', 'match', 'notmatch', 'gt', 'lt', 'ge', 'le', 'in', 'notin', 'contains', 'notcontains') + $SafePropertyRegex = [regex]'^[a-zA-Z0-9_.]+$' + foreach ($condition in $Conditions) { + if ($condition.Operator.value -and $condition.Operator.value.ToLower() -notin $AllowedOperators) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ error = "Invalid operator: $($condition.Operator.value)" } + }) + } + if ($condition.Property.label -and -not $SafePropertyRegex.IsMatch($condition.Property.label)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ error = "Invalid property name: $($condition.Property.label)" } + }) + } + } + $Tenants = $Request.Body.tenantFilter + $Conditions = $Request.Body.conditions | ConvertTo-Json -Compress -Depth 10 | Out-String + $TenantsJson = $Tenants | ConvertTo-Json -Compress -Depth 10 | Out-String + $excludedTenantsJson = $Request.Body.excludedTenants | ConvertTo-Json -Compress -Depth 10 | Out-String + $Actions = $Request.Body.actions | ConvertTo-Json -Compress -Depth 10 | Out-String + $RowKey = $Request.Body.RowKey ? $Request.Body.RowKey : (New-Guid).ToString() + $CompleteObject = @{ + Tenants = [string]$TenantsJson + excludedTenants = [string]$excludedTenantsJson + Conditions = [string]$Conditions + Actions = [string]$Actions + type = $Request.Body.logbook.value + RowKey = $RowKey + PartitionKey = 'Webhookv2' + AlertComment = [string]$Request.Body.AlertComment + CustomSubject = [string]$Request.Body.CustomSubject + } + $WebhookTable = Get-CippTable -TableName 'WebhookRules' + Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force + $Results = "Added Audit Log Alert for $($Tenants.count) tenants. It may take up to four hours before Microsoft starts delivering these alerts." + Write-LogMessage -API 'AddAlert' -message $Results -sev Info -LogData $CompleteObject -headers $Request.Headers + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ 'Results' = @($Results) } + }) + } catch { + Write-LogMessage -API 'AddAlert' -message "Error adding alert: $_" -sev Error -headers $Request.Headers + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ error = "Failed to add alert: $_" } + }) + } } diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index b9fba00543b2..923eb9f0b060 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -10,7 +10,8 @@ function New-CIPPAlertTemplate { $CIPPURL, $Tenant, $AuditLogLink, - $AlertComment + $AlertComment, + $CustomSubject ) $Appname = '[{"Application Name":"ACOM Azure Website","Application IDs":"23523755-3a2b-41ca-9315-f81f3f566a95"},{"Application Name":"AEM-DualAuth","Application IDs":"69893ee3-dd10-4b1c-832d-4870354be3d8"},{"Application Name":"ASM Campaign Servicing","Application IDs":"0cb7b9ec-5336-483b-bc31-b15b5788de71"},{"Application Name":"Azure Advanced Threat Protection","Application IDs":"7b7531ad-5926-4f2d-8a1d-38495ad33e17"},{"Application Name":"Azure Data Lake","Application IDs":"e9f49c6b-5ce5-44c8-925d-015017e9f7ad"},{"Application Name":"Azure Lab Services Portal","Application IDs":"835b2a73-6e10-4aa5-a979-21dfda45231c"},{"Application Name":"Azure Portal","Application IDs":"c44b4083-3bb0-49c1-b47d-974e53cbdf3c"},{"Application Name":"AzureSupportCenter","Application IDs":"37182072-3c9c-4f6a-a4b3-b3f91cacffce"},{"Application Name":"Bing","Application IDs":"9ea1ad79-fdb6-4f9a-8bc3-2b70f96e34c7"},{"Application Name":"CPIM Service","Application IDs":"bb2a2e3a-c5e7-4f0a-88e0-8e01fd3fc1f4"},{"Application Name":"CRM Power BI Integration","Application IDs":"e64aa8bc-8eb4-40e2-898b-cf261a25954f"},{"Application Name":"Dataverse","Application IDs":"00000007-0000-0000-c000-000000000000"},{"Application Name":"Enterprise Roaming and Backup","Application IDs":"60c8bde5-3167-4f92-8fdb-059f6176dc0f"},{"Application Name":"IAM Supportability","Application IDs":"a57aca87-cbc0-4f3c-8b9e-dc095fdc8978"},{"Application Name":"IrisSelectionFrontDoor","Application IDs":"16aeb910-ce68-41d1-9ac3-9e1673ac9575"},{"Application Name":"MCAPI Authorization Prod","Application IDs":"d73f4b35-55c9-48c7-8b10-651f6f2acb2e"},{"Application Name":"Media Analysis and Transformation Service","Application IDs":"944f0bd1-117b-4b1c-af26-804ed95e767e
0cd196ee-71bf-4fd6-a57c-b491ffd4fb1e"},{"Application Name":"Microsoft 365 Support Service","Application IDs":"ee272b19-4411-433f-8f28-5c13cb6fd407"},{"Application Name":"Microsoft App Access Panel","Application IDs":"0000000c-0000-0000-c000-000000000000"},{"Application Name":"Microsoft Approval Management","Application IDs":"65d91a3d-ab74-42e6-8a2f-0add61688c74
38049638-cc2c-4cde-abe4-4479d721ed44"},{"Application Name":"Microsoft Authentication Broker","Application IDs":"29d9ed98-a469-4536-ade2-f981bc1d605e"},{"Application Name":"Microsoft Azure CLI","Application IDs":"04b07795-8ddb-461a-bbee-02f9e1bf7b46"},{"Application Name":"Microsoft Azure PowerShell","Application IDs":"1950a258-227b-4e31-a9cf-717495945fc2"},{"Application Name":"Microsoft Bing Search","Application IDs":"cf36b471-5b44-428c-9ce7-313bf84528de"},{"Application Name":"Microsoft Bing Search for Microsoft Edge","Application IDs":"2d7f3606-b07d-41d1-b9d2-0d0c9296a6e8"},{"Application Name":"Microsoft Bing Default Search Engine","Application IDs":"1786c5ed-9644-47b2-8aa0-7201292175b6"},{"Application Name":"Microsoft Defender for Cloud Apps","Application IDs":"3090ab82-f1c1-4cdf-af2c-5d7a6f3e2cc7"},{"Application Name":"Microsoft Docs","Application IDs":"18fbca16-2224-45f6-85b0-f7bf2b39b3f3"},{"Application Name":"Microsoft Dynamics ERP","Application IDs":"00000015-0000-0000-c000-000000000000"},{"Application Name":"Microsoft Edge Insider Addons Prod","Application IDs":"6253bca8-faf2-4587-8f2f-b056d80998a7"},{"Application Name":"Microsoft Exchange Online Protection","Application IDs":"00000007-0000-0ff1-ce00-000000000000"},{"Application Name":"Microsoft Forms","Application IDs":"c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"},{"Application Name":"Microsoft Graph","Application IDs":"00000003-0000-0000-c000-000000000000"},{"Application Name":"Microsoft Intune Web Company Portal","Application IDs":"74bcdadc-2fdc-4bb3-8459-76d06952a0e9"},{"Application Name":"Microsoft Intune Windows Agent","Application IDs":"fc0f3af4-6835-4174-b806-f7db311fd2f3"},{"Application Name":"Microsoft Learn","Application IDs":"18fbca16-2224-45f6-85b0-f7bf2b39b3f3"},{"Application Name":"Microsoft Office","Application IDs":"d3590ed6-52b3-4102-aeff-aad2292ab01c"},{"Application Name":"Microsoft Office 365 Portal","Application IDs":"00000006-0000-0ff1-ce00-000000000000"},{"Application Name":"Microsoft Office Web Apps Service","Application IDs":"67e3df25-268a-4324-a550-0de1c7f97287"},{"Application Name":"Microsoft Online Syndication Partner Portal","Application IDs":"d176f6e7-38e5-40c9-8a78-3998aab820e7"},{"Application Name":"Microsoft password reset service","Application IDs":"93625bc8-bfe2-437a-97e0-3d0060024faa"},{"Application Name":"Microsoft Power BI","Application IDs":"871c010f-5e61-4fb1-83ac-98610a7e9110"},{"Application Name":"Microsoft Storefronts","Application IDs":"28b567f6-162c-4f54-99a0-6887f387bbcc"},{"Application Name":"Microsoft Stream Portal","Application IDs":"cf53fce8-def6-4aeb-8d30-b158e7b1cf83"},{"Application Name":"Microsoft Substrate Management","Application IDs":"98db8bd6-0cc0-4e67-9de5-f187f1cd1b41"},{"Application Name":"Microsoft Support","Application IDs":"fdf9885b-dd37-42bf-82e5-c3129ef5a302"},{"Application Name":"Microsoft Teams","Application IDs":"1fec8e78-bce4-4aaf-ab1b-5451cc387264"},{"Application Name":"Microsoft Teams Services","Application IDs":"cc15fd57-2c6c-4117-a88c-83b1d56b4bbe"},{"Application Name":"Microsoft Teams Web Client","Application IDs":"5e3ce6c0-2b1f-4285-8d4b-75ee78787346"},{"Application Name":"Microsoft Whiteboard Services","Application IDs":"95de633a-083e-42f5-b444-a4295d8e9314"},{"Application Name":"O365 Suite UX","Application IDs":"4345a7b9-9a63-4910-a426-35363201d503"},{"Application Name":"Office 365 Exchange Online","Application IDs":"00000002-0000-0ff1-ce00-000000000000"},{"Application Name":"Office 365 Management","Application IDs":"00b41c95-dab0-4487-9791-b9d2c32c80f2"},{"Application Name":"Office 365 Search Service","Application IDs":"66a88757-258c-4c72-893c-3e8bed4d6899"},{"Application Name":"Office 365 SharePoint Online","Application IDs":"00000003-0000-0ff1-ce00-000000000000"},{"Application Name":"Office Delve","Application IDs":"94c63fef-13a3-47bc-8074-75af8c65887a"},{"Application Name":"Office Online Add-in SSO","Application IDs":"93d53678-613d-4013-afc1-62e9e444a0a5"},{"Application Name":"Office Online Client AAD- Augmentation Loop","Application IDs":"2abdc806-e091-4495-9b10-b04d93c3f040"},{"Application Name":"Office Online Client AAD- Loki","Application IDs":"b23dd4db-9142-4734-867f-3577f640ad0c"},{"Application Name":"Office Online Client AAD- Maker","Application IDs":"17d5e35f-655b-4fb0-8ae6-86356e9a49f5"},{"Application Name":"Office Online Client MSA- Loki","Application IDs":"b6e69c34-5f1f-4c34-8cdf-7fea120b8670"},{"Application Name":"Office Online Core SSO","Application IDs":"243c63a3-247d-41c5-9d83-7788c43f1c43"},{"Application Name":"Office Online Search","Application IDs":"a9b49b65-0a12-430b-9540-c80b3332c127"},{"Application Name":"Office.com","Application IDs":"4b233688-031c-404b-9a80-a4f3f2351f90"},{"Application Name":"Office365 Shell WCSS-Client","Application IDs":"89bee1f7-5e6e-4d8a-9f3d-ecd601259da7"},{"Application Name":"OfficeClientService","Application IDs":"0f698dd4-f011-4d23-a33e-b36416dcb1e6"},{"Application Name":"OfficeHome","Application IDs":"4765445b-32c6-49b0-83e6-1d93765276ca"},{"Application Name":"OfficeShredderWacClient","Application IDs":"4d5c2d63-cf83-4365-853c-925fd1a64357"},{"Application Name":"OMSOctopiPROD","Application IDs":"62256cef-54c0-4cb4-bcac-4c67989bdc40"},{"Application Name":"OneDrive SyncEngine","Application IDs":"ab9b8c07-8f02-4f72-87fa-80105867a763"},{"Application Name":"OneNote","Application IDs":"2d4d3d8e-2be3-4bef-9f87-7875a61c29de"},{"Application Name":"Outlook Mobile","Application IDs":"27922004-5251-4030-b22d-91ecd9a37ea4"},{"Application Name":"Partner Customer Delegated Admin Offline Processor","Application IDs":"a3475900-ccec-4a69-98f5-a65cd5dc5306"},{"Application Name":"Password Breach Authenticator","Application IDs":"bdd48c81-3a58-4ea9-849c-ebea7f6b6360"},{"Application Name":"Power BI Service","Application IDs":"00000009-0000-0000-c000-000000000000"},{"Application Name":"SharedWithMe","Application IDs":"ffcb16e8-f789-467c-8ce9-f826a080d987"},{"Application Name":"SharePoint Online Web Client Extensibility","Application IDs":"08e18876-6177-487e-b8b5-cf950c1e598c"},{"Application Name":"Signup","Application IDs":"b4bddae8-ab25-483e-8670-df09b9f1d0ea"},{"Application Name":"Skype for Business Online","Application IDs":"00000004-0000-0ff1-ce00-000000000000"},{"Application Name":"Sway","Application IDs":"905fcf26-4eb7-48a0-9ff0-8dcc7194b5ba"},{"Application Name":"Universal Store Native Client","Application IDs":"268761a2-03f3-40df-8a8b-c3db24145b6b"},{"Application Name":"Vortex [wsfed enabled]","Application IDs":"5572c4c0-d078-44ce-b81c-6cbf8d3ed39e"},{"Application Name":"Windows Azure Active Directory","Application IDs":"00000002-0000-0000-c000-000000000000"},{"Application Name":"Windows Azure Service Management API","Application IDs":"797f4846-ba00-4fd7-ba43-dac1f8f63013"},{"Application Name":"WindowsDefenderATP Portal","Application IDs":"a3b79187-70b2-4139-83f9-6016c58cd27b"},{"Application Name":"Windows Search","Application IDs":"26a7ee05-5602-4d76-a7ba-eae8b7b67941"},{"Application Name":"Windows Spotlight","Application IDs":"1b3c667f-cde3-4090-b60b-3d2abd0117f0"},{"Application Name":"Windows Store for Business","Application IDs":"45a330b1-b1ec-4cc1-9161-9f03992aa49f"},{"Application Name":"Yammer","Application IDs":"00000005-0000-0ff1-ce00-000000000000"},{"Application Name":"Yammer Web","Application IDs":"c1c74fed-04c9-4704-80dc-9f79a2e515cb"},{"Application Name":"Yammer Web Embed","Application IDs":"e1ef36fd-b883-4dbf-97f0-9ece4b576fc6"}]' | ConvertFrom-Json | Where-Object -Property 'Application IDs' -EQ $data.applicationId # Get the function app root directory by navigating from the module location @@ -103,9 +104,9 @@ function New-CIPPAlertTemplate { } } if ($FoundForwarding -eq $true) { - $Title = "$($TenantFilter) - New forwarding or redirect Rule Detected for $($data.UserId)" + $Title = "$($Tenant) - New forwarding or redirect Rule Detected for $($data.UserId)" } else { - $Title = "$($TenantFilter) - New Rule Detected for $($data.UserId)" + $Title = "$($Tenant) - New Rule Detected for $($data.UserId)" } $RuleTable = ($Data.CIPPParameters | ConvertFrom-Json | ConvertTo-Html -Fragment | Out-String).Replace('', '
') @@ -120,7 +121,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If you believe this is a suspect rule, you can click the button above to start the investigation.

' } 'Set-InboxRule' { - $Title = "$($TenantFilter) - Rule Edit Detected for $($data.UserId)" + $Title = "$($Tenant) - Rule Edit Detected for $($data.UserId)" $RuleTable = ($Data.CIPPParameters | ConvertFrom-Json | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') $IntroText = "

A rule has been edited for the user $($data.UserId). You should check if this rule is not malicious. The rule information can be found in the table below.

$RuleTable" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } @@ -133,7 +134,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If you believe this is a suspect rule, you can click the button above to start the investigation.

' } 'Add member to role.' { - $Title = "$($TenantFilter) - Role change detected for $($data.ObjectId)" + $Title = "$($Tenant) - Role change detected for $($data.ObjectId)" $Table = ($data.CIPPModifiedProperties | ConvertFrom-Json | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') $IntroText = "

$($data.UserId) has added $($data.ObjectId) to the $(($data.'Role.DisplayName')) role. The information about the role can be found in the table below.

$Table" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } @@ -147,7 +148,7 @@ function New-CIPPAlertTemplate { } 'Disable account.' { - $Title = "$($TenantFilter) - $($data.ObjectId) has been disabled" + $Title = "$($Tenant) - $($data.ObjectId) has been disabled" $IntroText = "$($data.ObjectId) has been disabled by $($data.UserId)." if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -159,7 +160,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If this is incorrect, use the user management screen to unblock the users sign-in

' } 'Enable account.' { - $Title = "$($TenantFilter) - $($data.ObjectId) has been enabled" + $Title = "$($Tenant) - $($data.ObjectId) has been enabled" $IntroText = "$($data.ObjectId) has been enabled by $($data.UserId)." $ButtonUrl = "$CIPPURL/identity/administration/users?customerId=$($data.OrganizationId)" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } @@ -171,7 +172,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If this is incorrect, use the user management screen to unblock the users sign-in

' } 'Update StsRefreshTokenValidFrom Timestamp.' { - $Title = "$($TenantFilter) - $($data.ObjectId) has had all sessions revoked" + $Title = "$($Tenant) - $($data.ObjectId) has had all sessions revoked" $IntroText = "$($data.ObjectId) has had their sessions revoked by $($data.UserId)." $ButtonUrl = "$CIPPURL/identity/administration/users?customerId=$($data.OrganizationId)" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } @@ -183,7 +184,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If this is incorrect, use the user management screen to unblock the users sign-in

' } 'Disable Strong Authentication.' { - $Title = "$($TenantFilter) - $($data.ObjectId) has been MFA disabled" + $Title = "$($Tenant) - $($data.ObjectId) has been MFA disabled" $IntroText = "$($data.ObjectId) MFA has been disabled by $($data.UserId)." if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -195,7 +196,7 @@ function New-CIPPAlertTemplate { $AfterButtonText = '

If this is incorrect, use the user management screen to reenable MFA

' } 'Remove Member from a role.' { - $Title = "$($TenantFilter) - Role change detected for $($data.ObjectId)" + $Title = "$($Tenant) - Role change detected for $($data.ObjectId)" $Table = ($data.CIPPModifiedProperties | ConvertFrom-Json | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') $IntroText = "

$($data.UserId) has removed $($data.ObjectId) to the $(($data.ModifiedProperties | Where-Object -Property Name -EQ 'Role.DisplayName').NewValue) role. The information about the role can be found in the table below.

$Table" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } @@ -210,7 +211,7 @@ function New-CIPPAlertTemplate { } 'Reset user password.' { - $Title = "$($TenantFilter) - $($data.ObjectId) has had their password reset" + $Title = "$($Tenant) - $($data.ObjectId) has had their password reset" $IntroText = "$($data.ObjectId) has had their password reset by $($data.userId)." if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -224,7 +225,7 @@ function New-CIPPAlertTemplate { } 'Add service principal.' { if ($Appname) { $AppName = $AppName.'Application Name' } else { $appName = $data.ApplicationId } - $Title = "$($TenantFilter) - Service Principal $($data.ObjectId) has been added." + $Title = "$($Tenant) - Service Principal $($data.ObjectId) has been added." $Table = ($data.ModifiedProperties | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -237,7 +238,7 @@ function New-CIPPAlertTemplate { } 'Remove service principal.' { if ($Appname) { $AppName = $AppName.'Application Name' } else { $appName = $data.ApplicationId } - $Title = "$($TenantFilter) - Service Principal $($data.ObjectId) has been removed." + $Title = "$($Tenant) - Service Principal $($data.ObjectId) has been removed." $Table = ($data.CIPPModifiedProperties | ConvertFrom-Json | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -251,7 +252,7 @@ function New-CIPPAlertTemplate { 'UserLoggedIn' { $Table = ($data | ConvertTo-Html -Fragment -As List | Out-String).Replace('
', '
') if ($Appname) { $AppName = $AppName.'Application Name' } else { $appName = $data.ApplicationId } - $Title = "$($TenantFilter) - a user has logged on from a location you've set up to receive alerts for." + $Title = "$($Tenant) - a user has logged on from a location you've set up to receive alerts for." $IntroText = "$($data.UserId) ($($data.Userkey)) has logged on from IP $($data.ClientIP) to the application $($Appname). According to our database this is located in $($LocationInfo.Country) - $($LocationInfo.City).

You have set up alerts to be notified when this happens. See the table below for more info.$Table" if ($ActionResults) { $IntroText = $IntroText + "

Based on the rule, the following actions have been taken: $($ActionResults -join '
' )

" } if ($LocationInfo) { @@ -277,6 +278,10 @@ function New-CIPPAlertTemplate { } } + if (![string]::IsNullOrWhiteSpace($CustomSubject)) { + $Title = '{0} - {1}' -f $Tenant, $CustomSubject + } + if ($Format -eq 'html') { return [pscustomobject]@{ title = $Title diff --git a/Modules/CIPPCore/Public/TenantGroups/Update-CIPPDynamicTenantGroups.ps1 b/Modules/CIPPCore/Public/TenantGroups/Update-CIPPDynamicTenantGroups.ps1 index 185018453aa2..2d2d6ea3e29d 100644 --- a/Modules/CIPPCore/Public/TenantGroups/Update-CIPPDynamicTenantGroups.ps1 +++ b/Modules/CIPPCore/Public/TenantGroups/Update-CIPPDynamicTenantGroups.ps1 @@ -62,115 +62,10 @@ function Update-CIPPDynamicTenantGroups { try { Write-LogMessage -API 'TenantGroups' -message "Processing dynamic group: $($Group.Name)" -sev Info $Rules = @($Group.DynamicRules | ConvertFrom-Json) - # Build a single Where-Object string for AND logic - $WhereConditions = foreach ($Rule in $Rules) { - $Property = $Rule.property - $Operator = $Rule.operator - $Value = $Rule.value - - switch ($Property) { - 'delegatedAccessStatus' { - "`$_.delegatedPrivilegeStatus -$Operator '$($Value.value)'" - } - 'availableLicense' { - if ($Operator -in @('in', 'notin')) { - $arrayValues = if ($Value -is [array]) { $Value.guid } else { @($Value.guid) } - $arrayAsString = $arrayValues | ForEach-Object { "'$_'" } - if ($Operator -eq 'in') { - "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0" - } else { - "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0" - } - } else { - "`$_.skuId -$Operator '$($Value.guid)'" - } - } - 'availableServicePlan' { - if ($Operator -in @('in', 'notin')) { - $arrayValues = if ($Value -is [array]) { $Value.value } else { @($Value.value) } - $arrayAsString = $arrayValues | ForEach-Object { "'$_'" } - if ($Operator -eq 'in') { - # Keep tenants with ANY of the provided plans - "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0" - } else { - # Exclude tenants with ANY of the provided plans - "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0" - } - } else { - "`$_.servicePlans -$Operator '$($Value.value)'" - } - } - 'tenantGroupMember' { - # Get members of the referenced tenant group(s) - if ($Operator -in @('in', 'notin')) { - # Handle array of group IDs - $ReferencedGroupIds = @($Value.value) - - # Collect all unique member customerIds from all referenced groups - $AllMembers = [System.Collections.Generic.HashSet[string]]::new() - foreach ($GroupId in $ReferencedGroupIds) { - if ($script:TenantGroupMembersCache.ContainsKey($GroupId)) { - foreach ($MemberId in $script:TenantGroupMembersCache[$GroupId]) { - [void]$AllMembers.Add($MemberId) - } - } - } - - # Convert to array string for condition - $MemberArray = $AllMembers | ForEach-Object { "'$_'" } - $MemberArrayString = $MemberArray -join ', ' - - if ($Operator -eq 'in') { - "`$_.customerId -in @($MemberArrayString)" - } else { - "`$_.customerId -notin @($MemberArrayString)" - } - } else { - # Single value with other operators - $ReferencedGroupId = $Value.value - "`$_.customerId -$Operator `$script:TenantGroupMembersCache['$ReferencedGroupId']" - } - } - 'customVariable' { - # Custom variable matching - value contains variable name and expected value - # Handle case where variableName might be an object (autocomplete option) or a string - $VariableName = if ($Value.variableName -is [string]) { - $Value.variableName - } elseif ($Value.variableName.value) { - $Value.variableName.value - } else { - $Value.variableName - } - $ExpectedValue = $Value.value - # Escape single quotes in expected value for the condition string - $EscapedExpectedValue = $ExpectedValue -replace "'", "''" - - switch ($Operator) { - 'eq' { - "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -eq '$EscapedExpectedValue')" - } - 'ne' { - "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -ne '$EscapedExpectedValue')" - } - 'like' { - "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -like '*$EscapedExpectedValue*')" - } - 'notlike' { - "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -notlike '*$EscapedExpectedValue*')" - } - } - } - default { - Write-LogMessage -API 'TenantGroups' -message "Unknown property type: $Property" -sev Warning - $null - } - } - + if (!$Rules -or $Rules.Count -eq 0) { + throw 'No rules found for dynamic group.' } - if (!$WhereConditions) { - throw 'Generating the conditions failed. The conditions seem to be empty.' - } - Write-Information "Generated where conditions: $($WhereConditions | ConvertTo-Json )" + Write-Information "Processing $($Rules.Count) rules for group '$($Group.Name)'" $TenantObj = $AllTenants | ForEach-Object { if ($Rules.property -contains 'availableLicense') { if ($SkuHashtable.ContainsKey($_.customerId)) { @@ -221,10 +116,26 @@ function Update-CIPPDynamicTenantGroups { customVariables = $TenantVariables } } - # Combine all conditions with the specified logic (AND or OR) - $LogicOperator = if ($Group.RuleLogic -eq 'or') { ' -or ' } else { ' -and ' } + # Evaluate rules safely using Test-CIPPDynamicGroupFilter with AND/OR logic + $RuleLogic = if ($Group.RuleLogic -eq 'or') { 'or' } else { 'and' } + + # Build sanitized condition strings from validated rules + $WhereConditions = foreach ($rule in $Rules) { + $condition = Test-CIPPDynamicGroupFilter -Rule $rule -TenantGroupMembersCache $script:TenantGroupMembersCache + if ($null -eq $condition) { + Write-Warning "Skipping invalid rule: $($rule | ConvertTo-Json -Compress)" + continue + } + $condition + } + + if (!$WhereConditions) { + throw 'Generating the conditions failed. All rules were invalid or empty.' + } + + $LogicOperator = if ($RuleLogic -eq 'or') { ' -or ' } else { ' -and ' } $WhereString = $WhereConditions -join $LogicOperator - Write-Information "Evaluating tenants with condition: $WhereString" + Write-Information "Evaluating tenants with sanitized condition: $WhereString" Write-LogMessage -API 'TenantGroups' -message "Evaluating tenants for group '$($Group.Name)' with condition: $WhereString" -sev Info $ScriptBlock = [ScriptBlock]::Create($WhereString) diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 90257fe4a22d..4ece756421d6 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -77,10 +77,19 @@ function Invoke-CippWebhookProcessing { } } + $CustomSubject = $null + if ($Data.CIPPRuleId) { + $WebhookRulesTable = Get-CIPPTable -TableName 'WebhookRules' + $WebhookRule = Get-CIPPAzDataTableEntity @WebhookRulesTable -Filter "PartitionKey eq 'WebhookRule' and RowKey eq '$($Data.CIPPRuleId)'" + if (![string]::IsNullOrEmpty($WebhookRule.CustomSubject)) { + $CustomSubject = $WebhookRule.CustomSubject + } + } + # Save audit log entry to table $LocationInfo = $Data.CIPPLocationInfo | ConvertFrom-Json -ErrorAction SilentlyContinue $AuditRecord = $Data.AuditRecord | ConvertFrom-Json -ErrorAction SilentlyContinue - $GenerateJSON = New-CIPPAlertTemplate -format 'json' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -AlertComment $AlertComment + $GenerateJSON = New-CIPPAlertTemplate -format 'json' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -AlertComment $WebhookRule.AlertComment -CustomSubject $CustomSubject -Tenant $Tenant.defaultDomainName $JsonContent = @{ Title = $GenerateJSON.Title ActionUrl = $GenerateJSON.ButtonUrl From db380d06eaa639ec8c3445372f174f20712843bc Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:07:22 +0800 Subject: [PATCH 06/31] Fix: Silly issue with removing legacy addins --- .../Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 index 0876db936f1a..5d36441ec634 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { ) try { - $CurrentApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications&select=addins" -TenantID $Tenant + $CurrentApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications?`$select=id,addIns" -TenantID $Tenant # Filter to only applications that have the legacy add-ins we're looking for $LegacyProductIds = $LegacyAddins | ForEach-Object { $_.ProductId } @@ -108,7 +108,7 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { # If we performed remediation and need to report/alert, get fresh state if ($RemediationPerformed -and ($Settings.alert -eq $true -or $Settings.report -eq $true)) { try { - $FreshApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications&select=addins" -TenantID $Tenant + $FreshApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications?`$select=addIns" -TenantID $Tenant $LegacyProductIds = $LegacyAddins | ForEach-Object { $_.ProductId } $FreshInstalledApps = $FreshApps | Where-Object { $app = $_ From e5da743cd37101e001a0356ced0a0758e75cc6df Mon Sep 17 00:00:00 2001 From: James Tarran Date: Fri, 20 Mar 2026 15:02:19 +0000 Subject: [PATCH 07/31] Update Add-CIPPW32ScriptApplication.ps1 Added CIPP variable replacement to custom app powershell script block --- Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 5ef92c5a997a..640cb120265a 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -149,7 +149,8 @@ function Add-CIPPW32ScriptApplication { $UninstallScriptId = $null if ($Properties.installScript) { - $InstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.installScript)) + $ReplacedInstallScript = Get-CIPPTextReplacement -Text $Properties.installScript -TenantFilter $TenantFilter + $InstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ReplacedInstallScript)) $InstallScriptBody = @{ '@odata.type' = '#microsoft.graph.win32LobAppInstallPowerShellScript' displayName = 'install.ps1' @@ -172,7 +173,8 @@ function Add-CIPPW32ScriptApplication { } if ($Properties.uninstallScript) { - $UninstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.uninstallScript)) + $ReplacedUninstallScript = Get-CIPPTextReplacement -Text $Properties.uninstallScript -TenantFilter $TenantFilter + $UninstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ReplacedUninstallScript)) $UninstallScriptBody = @{ '@odata.type' = '#microsoft.graph.win32LobAppUninstallPowerShellScript' displayName = 'uninstall.ps1' From 4f4eb48e894396862edfa6267113ba14d17edd3f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 21 Mar 2026 14:33:41 -0400 Subject: [PATCH 08/31] fix: Optimize tenant processing by pre-expanding tenant groups in audit log search creation --- .../Start-AuditLogSearchCreation.ps1 | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 index a8c64da33fec..7f28dac0cbf0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 @@ -27,18 +27,21 @@ function Start-AuditLogSearchCreation { $StartTime = ($Now.AddSeconds(-$Now.Seconds)).AddHours(-1) $EndTime = $Now.AddSeconds(-$Now.Seconds) - Write-Information 'Audit Logs: Creating new searches' + # Pre-expand tenant groups once per config entry to avoid repeated calls per tenant + foreach ($ConfigEntry in $ConfigEntries) { + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'ExpandedTenants' -Value (Expand-CIPPTenantGroups -TenantFilter ($ConfigEntry.Tenants)).value -Force + } + + Write-Information "Audit Logs: Building batch for $($TenantList.Count) tenants across $($ConfigEntries.Count) config entries" $Batch = foreach ($Tenant in $TenantList) { - Write-Information "Processing tenant $($Tenant.defaultDomainName) - $($Tenant.customerId)" $TenantInConfig = $false $MatchingConfigs = [System.Collections.Generic.List[object]]::new() foreach ($ConfigEntry in $ConfigEntries) { if ($ConfigEntry.excludedTenants.value -contains $Tenant.defaultDomainName) { continue } - $TenantsList = Expand-CIPPTenantGroups -TenantFilter ($ConfigEntry.Tenants) - if ($TenantsList.value -contains $Tenant.defaultDomainName -or $TenantsList.value -contains 'AllTenants') { + if ($ConfigEntry.ExpandedTenants -contains $Tenant.defaultDomainName -or $ConfigEntry.ExpandedTenants -contains 'AllTenants') { $TenantInConfig = $true $MatchingConfigs.Add($ConfigEntry) } From 2ef12d93df5a95edcfeed0a7e028350a98a9c584 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sat, 21 Mar 2026 22:58:30 +0100 Subject: [PATCH 09/31] fix(groups): sanitize mailNickname for security group creation --- Modules/CIPPCore/Public/New-CIPPGroup.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index ea08927acaa8..aa014c8f3b80 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -78,10 +78,13 @@ function New-CIPPGroup { } # Determine if we should generate a mailNickname with a GUID, or use the username field - if (-not $GroupObject.Username) { + if (-not $GroupObject.Username -or $NormalizedGroupType -in @('Generic', 'AzureRole')) { $MailNickname = (New-Guid).guid.substring(0, 10) } else { - $MailNickname = $GroupObject.Username + $MailNickname = ($GroupObject.Username -split '@')[0] -replace '[^a-zA-Z0-9_-]', '' + if ([String]::IsNullOrEmpty($MailNickname)) { + $MailNickname = (New-Guid).guid + } } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Creating group $($GroupObject.displayName) of type $NormalizedGroupType$(if ($NeedsEmail) { " with email $Email" })" -Sev Info From 963a98e4d2b4002fde199651c93e515bf987ca8c Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sat, 21 Mar 2026 23:02:08 +0100 Subject: [PATCH 10/31] fix(group-templates): add validation for username and groupType --- .../Administration/Groups/Invoke-AddGroupTemplate.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 index 5f709fd9db24..30340b9bfae5 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 @@ -13,6 +13,12 @@ function Invoke-AddGroupTemplate { if (!$Request.Body.displayName) { throw 'You must enter a displayname' } + if (!$Request.Body.username) { + throw 'You must enter a username' + } + if (!$Request.Body.groupType) { + throw 'You must select a group type' + } # Normalize group type to match New-CIPPGroup expectations # Handle values from ListGroups calculatedGroupType and frontend form values From b25e385fa162f1157a08a8355ff3c11db18d7e1b Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sun, 22 Mar 2026 01:41:55 +0100 Subject: [PATCH 11/31] feat(security): add MDE onboarding status report with caching --- .../Security/Invoke-ListMDEOnboarding.ps1 | 54 ++++++++++++++++++ .../Public/Get-CIPPMDEOnboardingReport.ps1 | 56 +++++++++++++++++++ .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../Public/Set-CIPPDBCacheMDEOnboarding.ps1 | 45 +++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ListMDEOnboarding.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPMDEOnboardingReport.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPDBCacheMDEOnboarding.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ListMDEOnboarding.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ListMDEOnboarding.ps1 new file mode 100644 index 000000000000..9c87485beb35 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Security/Invoke-ListMDEOnboarding.ps1 @@ -0,0 +1,54 @@ +function Invoke-ListMDEOnboarding { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Defender.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + + try { + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPMDEOnboardingReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + Write-Host "Error retrieving MDE onboarding status from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + + $ConnectorId = 'fc780465-2017-40d4-a0c5-307022471b92' + $ConnectorUri = "https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/$ConnectorId" + try { + $ConnectorState = New-GraphGetRequest -uri $ConnectorUri -tenantid $TenantFilter + $PartnerState = $ConnectorState.partnerState + } catch { + $PartnerState = 'unavailable' + } + + $GraphRequest = [PSCustomObject]@{ + Tenant = $TenantFilter + partnerState = $PartnerState + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPMDEOnboardingReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMDEOnboardingReport.ps1 new file mode 100644 index 000000000000..adfc44a8f99b --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMDEOnboardingReport.ps1 @@ -0,0 +1,56 @@ +function Get-CIPPMDEOnboardingReport { + <# + .SYNOPSIS + Generates an MDE onboarding status report from the CIPP Reporting database + .PARAMETER TenantFilter + The tenant to generate the report for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + if ($TenantFilter -eq 'AllTenants') { + $AllItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'MDEOnboarding' + $Tenants = @($AllItems | Where-Object { $_.RowKey -ne 'MDEOnboarding-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPMDEOnboardingReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'MDEOnboardingReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + $Items = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'MDEOnboarding' | Where-Object { $_.RowKey -ne 'MDEOnboarding-Count' } + if (-not $Items) { + throw 'No MDE onboarding data found in reporting database. Sync the report data first.' + } + + $CacheTimestamp = ($Items | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Items) { + $ParsedData = $Item.Data | ConvertFrom-Json + $ParsedData | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $AllResults.Add($ParsedData) + } + + return $AllResults + } catch { + Write-LogMessage -API 'MDEOnboardingReport' -tenant $TenantFilter -message "Failed to generate MDE onboarding report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 0cfeb492af04..194ed10330fd 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -117,6 +117,7 @@ function Invoke-CIPPDBCacheCollection { 'ManagedDeviceEncryptionStates' 'IntuneAppProtectionPolicies' 'DetectedApps' + 'MDEOnboarding' ) } diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMDEOnboarding.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMDEOnboarding.ps1 new file mode 100644 index 000000000000..867563c0871e --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMDEOnboarding.ps1 @@ -0,0 +1,45 @@ +function Set-CIPPDBCacheMDEOnboarding { + <# + .SYNOPSIS + Caches MDE onboarding status for a tenant + .PARAMETER TenantFilter + The tenant to cache MDE onboarding status for + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [String]$TenantFilter, + [String]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching MDE onboarding status' -sev Debug + + $ConnectorId = 'fc780465-2017-40d4-a0c5-307022471b92' + $ConnectorUri = "https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/$ConnectorId" + try { + $ConnectorState = New-GraphGetRequest -uri $ConnectorUri -tenantid $TenantFilter + $PartnerState = $ConnectorState.partnerState + } catch { + $PartnerState = 'unavailable' + } + + $Result = @( + [PSCustomObject]@{ + Tenant = $TenantFilter + partnerState = $PartnerState + RowKey = 'MDEOnboarding' + PartitionKey = $TenantFilter + } + ) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MDEOnboarding' -Data @($Result) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MDEOnboarding' -Data @($Result) -Count + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached MDE onboarding status successfully' -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache MDE onboarding status: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} From 121a2cb031a159183aa161bb5e14a3dfa03f2038 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:37:44 +0800 Subject: [PATCH 12/31] pr --- .gitignore | 1 + Tools/Update-LicenseSKUFiles.ps1 | 289 +++++++++++++++++++++++++------ 2 files changed, 236 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index f9f512565e51..7efae367512a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn.lock /*.ps1 !/profile.ps1 .DS_Store +!/.github/workflows/update_license_skus_backend.yml diff --git a/Tools/Update-LicenseSKUFiles.ps1 b/Tools/Update-LicenseSKUFiles.ps1 index 2bcc8d8c1910..9048c859ffd7 100644 --- a/Tools/Update-LicenseSKUFiles.ps1 +++ b/Tools/Update-LicenseSKUFiles.ps1 @@ -1,81 +1,262 @@ <# .SYNOPSIS -Updates license SKU files and JSON files in the CIPP project. +Updates Microsoft license SKU data for CIPP backend and/or frontend files. .DESCRIPTION -This script downloads the latest license SKU CSV file from Microsoft and updates the ConversionTable.csv files with the latest license SKU data. It also updates the license SKU data in the CIPP repo JSON files. +Downloads the latest Microsoft license CSV and merges it into target files. +Existing file-only SKUs are preserved, matching SKUs are refreshed from the latest CSV, +and newly discovered SKUs are appended and reported. -.PARAMETER None +.PARAMETER Target +Select where to apply updates: backend, frontend, or both. -.EXAMPLE -Update-LicenseSKUFiles.ps1 +.PARAMETER BackendRepoPath +Root path of the CIPP-API repository. -This example runs the script to update the license SKU files and JSON files in the CIPP project. +.PARAMETER FrontendRepoPath +Root path of the CIPP repository. -.NOTES -Date: 2024-09-02 -Version: 1.0 - Initial script +.EXAMPLE +./Update-LicenseSKUFiles.ps1 -Target backend -Needs to be run from the "Tools" folder in the CIPP-API project. +.EXAMPLE +./Update-LicenseSKUFiles.ps1 -Target frontend -FrontendRepoPath C:\repo\CIPP #> +[CmdletBinding()] +param( + [ValidateSet('backend', 'frontend', 'both')] + [string]$Target = 'both', + [string]$BackendRepoPath, + [string]$FrontendRepoPath +) -# TODO: Convert this to a GitHub Action +$ErrorActionPreference = 'Stop' -# Download the latest license SKU CSV file from Microsoft. Saved to the TEMP folder to circumvent a bug where "???" is added to the first property name. $licenseCsvURL = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv' -$TempLicenseDataFile = "$env:TEMP\LicenseSKUs.csv" -Invoke-WebRequest -Uri $licenseCsvURL -OutFile $TempLicenseDataFile -$LicenseDataFile = Get-Item -Path $TempLicenseDataFile -$LicenseData = Import-Csv -Path $LicenseDataFile.FullName -Encoding utf8BOM -Delimiter ',' -# Update ConversionTable.csv with the latest license SKU data -Set-Location $PSScriptRoot -Set-Location .. -$ConversionTableFiles = Get-ChildItem -Path *ConversionTable.csv -Recurse -File -Write-Host "Updating $($ConversionTableFiles.Count) ConversionTable.csv files with the latest license SKU data..." -ForegroundColor Yellow - -foreach ($File in $ConversionTableFiles) { - $LicenseData | Export-Csv -Path $File.FullName -NoTypeInformation -Force -Encoding utf8 -UseQuotes AsNeeded - Write-Host "Updated $($File.FullName) with new license SKU data." -ForegroundColor Green +$TempLicenseDataFile = Join-Path $env:TEMP 'LicenseSKUs.csv' +$CanonicalColumns = @( + 'Product_Display_Name', + 'String_Id', + 'GUID', + 'Service_Plan_Name', + 'Service_Plan_Id', + 'Service_Plans_Included_Friendly_Names' +) + +function Normalize-Value { + param([AllowNull()][object]$Value) + if ($null -eq $Value) { return '' } + return ([string]$Value).Trim() } +function Convert-ToCanonicalRow { + param([object]$Row) -# Update the license SKU data in the CIPP repo JSON files -Set-Location $PSScriptRoot -Set-Location .. -Set-Location .. -Set-Location CIPP\src\data -$LicenseJSONFiles = Get-ChildItem -Path *M365Licenses.json -File + [pscustomobject]@{ + Product_Display_Name = Normalize-Value $Row.Product_Display_Name + String_Id = Normalize-Value $Row.String_Id + GUID = Normalize-Value $Row.GUID + Service_Plan_Name = Normalize-Value $Row.Service_Plan_Name + Service_Plan_Id = Normalize-Value $Row.Service_Plan_Id + Service_Plans_Included_Friendly_Names = Normalize-Value $Row.Service_Plans_Included_Friendly_Names + } +} -Write-Host "Updating $($LicenseJSONFiles.Count) M365 license JSON files with the latest license SKU data..." -ForegroundColor Yellow +function Get-LicenseKey { + param([object]$Row) -foreach ($File in $LicenseJSONFiles) { - ConvertTo-Json -InputObject $LicenseData -Depth 100 | Set-Content -Path $File.FullName -Encoding utf8 - Write-Host "Updated $($File.FullName) with new license SKU data." -ForegroundColor Green + $guid = (Normalize-Value $Row.GUID).ToLowerInvariant() + $stringId = (Normalize-Value $Row.String_Id).ToLowerInvariant() + $servicePlanId = (Normalize-Value $Row.Service_Plan_Id).ToLowerInvariant() + + if ($guid -or $servicePlanId) { + return "$guid|$servicePlanId" + } + + return "$stringId|$($Row.Service_Plan_Name.ToString().Trim().ToLowerInvariant())" } -# Sync ExcludeSkuList.JSON names with the authoritative license data -Set-Location $PSScriptRoot -$ExcludeSkuListPath = Join-Path $PSScriptRoot '..\Config\ExcludeSkuList.JSON' -if (Test-Path $ExcludeSkuListPath) { - Write-Host 'Syncing ExcludeSkuList.JSON product names...' -ForegroundColor Yellow - $GuidToName = @{} - foreach ($license in $LicenseData) { - if (-not $GuidToName.ContainsKey($license.GUID)) { - $GuidToName[$license.GUID] = $license.Product_Display_Name +function Merge-LicenseRows { + param( + [object[]]$ExistingRows, + [object[]]$LatestRows + ) + + $existingByKey = @{} + $existingOrder = New-Object System.Collections.Generic.List[string] + + foreach ($row in $ExistingRows) { + $canonical = Convert-ToCanonicalRow -Row $row + $key = Get-LicenseKey -Row $canonical + if (-not $existingByKey.ContainsKey($key)) { + $existingByKey[$key] = $canonical + $null = $existingOrder.Add($key) } } - $ExcludeSkuList = Get-Content -Path $ExcludeSkuListPath -Encoding utf8 | ConvertFrom-Json - $updatedCount = 0 - foreach ($entry in $ExcludeSkuList) { - if ($GuidToName.ContainsKey($entry.GUID) -and $entry.Product_Display_Name -cne $GuidToName[$entry.GUID]) { - $entry.Product_Display_Name = $GuidToName[$entry.GUID] - $updatedCount++ + + $latestByKey = @{} + $latestOrder = New-Object System.Collections.Generic.List[string] + + foreach ($row in $LatestRows) { + $canonical = Convert-ToCanonicalRow -Row $row + $key = Get-LicenseKey -Row $canonical + if (-not $latestByKey.ContainsKey($key)) { + $latestByKey[$key] = $canonical + $null = $latestOrder.Add($key) } } - $ExcludeSkuList | ConvertTo-Json -Depth 100 | Set-Content -Path $ExcludeSkuListPath -Encoding utf8 - Write-Host "Updated $updatedCount product names in ExcludeSkuList.JSON." -ForegroundColor Green + + $mergedRows = New-Object System.Collections.Generic.List[object] + $newRows = New-Object System.Collections.Generic.List[object] + + foreach ($key in $existingOrder) { + if ($latestByKey.ContainsKey($key)) { + $null = $mergedRows.Add($latestByKey[$key]) + } + else { + $null = $mergedRows.Add($existingByKey[$key]) + } + } + + foreach ($key in $latestOrder) { + if (-not $existingByKey.ContainsKey($key)) { + $null = $mergedRows.Add($latestByKey[$key]) + $null = $newRows.Add($latestByKey[$key]) + } + } + + [pscustomobject]@{ + Rows = @($mergedRows) + NewRows = @($newRows) + } +} + +function Resolve-DefaultBackendPath { + if ($BackendRepoPath) { + return (Resolve-Path -Path $BackendRepoPath).Path + } + + return (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path } -# Clean up the temporary license SKU CSV file -Remove-Item -Path $TempLicenseDataFile -Force +function Resolve-DefaultFrontendPath { + if ($FrontendRepoPath) { + return (Resolve-Path -Path $FrontendRepoPath).Path + } + + $candidatePaths = @( + (Join-Path (Resolve-DefaultBackendPath) '..\CIPP'), + (Join-Path (Get-Location).Path 'CIPP'), + (Get-Location).Path + ) + + foreach ($candidate in $candidatePaths) { + if (Test-Path (Join-Path $candidate 'src\data')) { + return (Resolve-Path -Path $candidate).Path + } + } + + throw 'Unable to determine FrontendRepoPath. Provide -FrontendRepoPath explicitly.' +} + +function Write-NewSkuSummary { + param( + [string]$FilePath, + [object[]]$NewRows + ) + + if ($NewRows.Count -eq 0) { + Write-Host "No new SKUs detected for $FilePath" -ForegroundColor DarkGray + return + } + + Write-Host "New SKUs detected for $FilePath ($($NewRows.Count))" -ForegroundColor Cyan + foreach ($row in $NewRows) { + Write-Host (" + {0} | {1} | {2}" -f $row.GUID, $row.String_Id, $row.Product_Display_Name) + } +} + +Write-Host 'Downloading latest Microsoft license SKU CSV...' -ForegroundColor Yellow +Invoke-WebRequest -Uri $licenseCsvURL -OutFile $TempLicenseDataFile +$LicenseData = Import-Csv -Path $TempLicenseDataFile -Encoding utf8BOM -Delimiter ',' +$LatestCanonical = @($LicenseData | ForEach-Object { Convert-ToCanonicalRow -Row $_ }) + +try { + if ($Target -in @('backend', 'both')) { + $ResolvedBackendPath = Resolve-DefaultBackendPath + $ConversionTableFiles = Get-ChildItem -Path $ResolvedBackendPath -Filter 'ConversionTable.csv' -Recurse -File + + Write-Host "Updating $($ConversionTableFiles.Count) backend ConversionTable.csv files..." -ForegroundColor Yellow + + foreach ($file in $ConversionTableFiles) { + $existingRows = @() + if (Test-Path $file.FullName) { + $existingRows = @(Import-Csv -Path $file.FullName -Encoding utf8 -Delimiter ',') + } + + $mergeResult = Merge-LicenseRows -ExistingRows $existingRows -LatestRows $LatestCanonical + $mergeResult.Rows | + Select-Object -Property $CanonicalColumns | + Export-Csv -Path $file.FullName -NoTypeInformation -Force -Encoding utf8 -UseQuotes AsNeeded + + Write-NewSkuSummary -FilePath $file.FullName -NewRows $mergeResult.NewRows + Write-Host "Updated $($file.FullName)" -ForegroundColor Green + } + + $ExcludeSkuListPath = Join-Path $ResolvedBackendPath 'Config\ExcludeSkuList.JSON' + if (Test-Path $ExcludeSkuListPath) { + Write-Host 'Syncing ExcludeSkuList.JSON product names...' -ForegroundColor Yellow + $GuidToName = @{} + foreach ($license in $LatestCanonical) { + if ($license.GUID -and -not $GuidToName.ContainsKey($license.GUID)) { + $GuidToName[$license.GUID] = $license.Product_Display_Name + } + } + + $ExcludeSkuList = Get-Content -Path $ExcludeSkuListPath -Encoding utf8 | ConvertFrom-Json + $updatedCount = 0 + foreach ($entry in $ExcludeSkuList) { + if ($GuidToName.ContainsKey($entry.GUID) -and $entry.Product_Display_Name -cne $GuidToName[$entry.GUID]) { + $entry.Product_Display_Name = $GuidToName[$entry.GUID] + $updatedCount++ + } + } + + $ExcludeSkuList | ConvertTo-Json -Depth 100 | Set-Content -Path $ExcludeSkuListPath -Encoding utf8 + Write-Host "Updated $updatedCount product names in ExcludeSkuList.JSON." -ForegroundColor Green + } + } + + if ($Target -in @('frontend', 'both')) { + $ResolvedFrontendPath = Resolve-DefaultFrontendPath + $FrontendDataPath = Join-Path $ResolvedFrontendPath 'src\data' + if (-not (Test-Path $FrontendDataPath)) { + throw "Frontend data path not found: $FrontendDataPath" + } + + $LicenseJSONFiles = Get-ChildItem -Path $FrontendDataPath -Filter '*M365Licenses.json' -File + Write-Host "Updating $($LicenseJSONFiles.Count) frontend M365 license JSON files..." -ForegroundColor Yellow + + foreach ($file in $LicenseJSONFiles) { + $existingRows = @() + if (Test-Path $file.FullName) { + $existingRows = @(Get-Content -Path $file.FullName -Encoding utf8 | ConvertFrom-Json) + } + + $mergeResult = Merge-LicenseRows -ExistingRows $existingRows -LatestRows $LatestCanonical + $mergeResult.Rows | + Select-Object -Property $CanonicalColumns | + ConvertTo-Json -Depth 100 | + Set-Content -Path $file.FullName -Encoding utf8 + + Write-NewSkuSummary -FilePath $file.FullName -NewRows $mergeResult.NewRows + Write-Host "Updated $($file.FullName)" -ForegroundColor Green + } + } +} +finally { + if (Test-Path $TempLicenseDataFile) { + Remove-Item -Path $TempLicenseDataFile -Force + } +} From 0e4d0158893d0ea7896d86653862c2d83427d3b2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:40:03 +0800 Subject: [PATCH 13/31] Revert "pr" This reverts commit 121a2cb031a159183aa161bb5e14a3dfa03f2038. --- .gitignore | 1 - Tools/Update-LicenseSKUFiles.ps1 | 289 ++++++------------------------- 2 files changed, 54 insertions(+), 236 deletions(-) diff --git a/.gitignore b/.gitignore index 7efae367512a..f9f512565e51 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,3 @@ yarn.lock /*.ps1 !/profile.ps1 .DS_Store -!/.github/workflows/update_license_skus_backend.yml diff --git a/Tools/Update-LicenseSKUFiles.ps1 b/Tools/Update-LicenseSKUFiles.ps1 index 9048c859ffd7..2bcc8d8c1910 100644 --- a/Tools/Update-LicenseSKUFiles.ps1 +++ b/Tools/Update-LicenseSKUFiles.ps1 @@ -1,262 +1,81 @@ <# .SYNOPSIS -Updates Microsoft license SKU data for CIPP backend and/or frontend files. +Updates license SKU files and JSON files in the CIPP project. .DESCRIPTION -Downloads the latest Microsoft license CSV and merges it into target files. -Existing file-only SKUs are preserved, matching SKUs are refreshed from the latest CSV, -and newly discovered SKUs are appended and reported. +This script downloads the latest license SKU CSV file from Microsoft and updates the ConversionTable.csv files with the latest license SKU data. It also updates the license SKU data in the CIPP repo JSON files. -.PARAMETER Target -Select where to apply updates: backend, frontend, or both. +.PARAMETER None -.PARAMETER BackendRepoPath -Root path of the CIPP-API repository. +.EXAMPLE +Update-LicenseSKUFiles.ps1 -.PARAMETER FrontendRepoPath -Root path of the CIPP repository. +This example runs the script to update the license SKU files and JSON files in the CIPP project. -.EXAMPLE -./Update-LicenseSKUFiles.ps1 -Target backend +.NOTES +Date: 2024-09-02 +Version: 1.0 - Initial script -.EXAMPLE -./Update-LicenseSKUFiles.ps1 -Target frontend -FrontendRepoPath C:\repo\CIPP +Needs to be run from the "Tools" folder in the CIPP-API project. #> -[CmdletBinding()] -param( - [ValidateSet('backend', 'frontend', 'both')] - [string]$Target = 'both', - [string]$BackendRepoPath, - [string]$FrontendRepoPath -) -$ErrorActionPreference = 'Stop' +# TODO: Convert this to a GitHub Action +# Download the latest license SKU CSV file from Microsoft. Saved to the TEMP folder to circumvent a bug where "???" is added to the first property name. $licenseCsvURL = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv' -$TempLicenseDataFile = Join-Path $env:TEMP 'LicenseSKUs.csv' -$CanonicalColumns = @( - 'Product_Display_Name', - 'String_Id', - 'GUID', - 'Service_Plan_Name', - 'Service_Plan_Id', - 'Service_Plans_Included_Friendly_Names' -) - -function Normalize-Value { - param([AllowNull()][object]$Value) - if ($null -eq $Value) { return '' } - return ([string]$Value).Trim() +$TempLicenseDataFile = "$env:TEMP\LicenseSKUs.csv" +Invoke-WebRequest -Uri $licenseCsvURL -OutFile $TempLicenseDataFile +$LicenseDataFile = Get-Item -Path $TempLicenseDataFile +$LicenseData = Import-Csv -Path $LicenseDataFile.FullName -Encoding utf8BOM -Delimiter ',' +# Update ConversionTable.csv with the latest license SKU data +Set-Location $PSScriptRoot +Set-Location .. +$ConversionTableFiles = Get-ChildItem -Path *ConversionTable.csv -Recurse -File +Write-Host "Updating $($ConversionTableFiles.Count) ConversionTable.csv files with the latest license SKU data..." -ForegroundColor Yellow + +foreach ($File in $ConversionTableFiles) { + $LicenseData | Export-Csv -Path $File.FullName -NoTypeInformation -Force -Encoding utf8 -UseQuotes AsNeeded + Write-Host "Updated $($File.FullName) with new license SKU data." -ForegroundColor Green } -function Convert-ToCanonicalRow { - param([object]$Row) - [pscustomobject]@{ - Product_Display_Name = Normalize-Value $Row.Product_Display_Name - String_Id = Normalize-Value $Row.String_Id - GUID = Normalize-Value $Row.GUID - Service_Plan_Name = Normalize-Value $Row.Service_Plan_Name - Service_Plan_Id = Normalize-Value $Row.Service_Plan_Id - Service_Plans_Included_Friendly_Names = Normalize-Value $Row.Service_Plans_Included_Friendly_Names - } -} +# Update the license SKU data in the CIPP repo JSON files +Set-Location $PSScriptRoot +Set-Location .. +Set-Location .. +Set-Location CIPP\src\data +$LicenseJSONFiles = Get-ChildItem -Path *M365Licenses.json -File -function Get-LicenseKey { - param([object]$Row) +Write-Host "Updating $($LicenseJSONFiles.Count) M365 license JSON files with the latest license SKU data..." -ForegroundColor Yellow - $guid = (Normalize-Value $Row.GUID).ToLowerInvariant() - $stringId = (Normalize-Value $Row.String_Id).ToLowerInvariant() - $servicePlanId = (Normalize-Value $Row.Service_Plan_Id).ToLowerInvariant() - - if ($guid -or $servicePlanId) { - return "$guid|$servicePlanId" - } - - return "$stringId|$($Row.Service_Plan_Name.ToString().Trim().ToLowerInvariant())" +foreach ($File in $LicenseJSONFiles) { + ConvertTo-Json -InputObject $LicenseData -Depth 100 | Set-Content -Path $File.FullName -Encoding utf8 + Write-Host "Updated $($File.FullName) with new license SKU data." -ForegroundColor Green } -function Merge-LicenseRows { - param( - [object[]]$ExistingRows, - [object[]]$LatestRows - ) - - $existingByKey = @{} - $existingOrder = New-Object System.Collections.Generic.List[string] - - foreach ($row in $ExistingRows) { - $canonical = Convert-ToCanonicalRow -Row $row - $key = Get-LicenseKey -Row $canonical - if (-not $existingByKey.ContainsKey($key)) { - $existingByKey[$key] = $canonical - $null = $existingOrder.Add($key) +# Sync ExcludeSkuList.JSON names with the authoritative license data +Set-Location $PSScriptRoot +$ExcludeSkuListPath = Join-Path $PSScriptRoot '..\Config\ExcludeSkuList.JSON' +if (Test-Path $ExcludeSkuListPath) { + Write-Host 'Syncing ExcludeSkuList.JSON product names...' -ForegroundColor Yellow + $GuidToName = @{} + foreach ($license in $LicenseData) { + if (-not $GuidToName.ContainsKey($license.GUID)) { + $GuidToName[$license.GUID] = $license.Product_Display_Name } } - - $latestByKey = @{} - $latestOrder = New-Object System.Collections.Generic.List[string] - - foreach ($row in $LatestRows) { - $canonical = Convert-ToCanonicalRow -Row $row - $key = Get-LicenseKey -Row $canonical - if (-not $latestByKey.ContainsKey($key)) { - $latestByKey[$key] = $canonical - $null = $latestOrder.Add($key) + $ExcludeSkuList = Get-Content -Path $ExcludeSkuListPath -Encoding utf8 | ConvertFrom-Json + $updatedCount = 0 + foreach ($entry in $ExcludeSkuList) { + if ($GuidToName.ContainsKey($entry.GUID) -and $entry.Product_Display_Name -cne $GuidToName[$entry.GUID]) { + $entry.Product_Display_Name = $GuidToName[$entry.GUID] + $updatedCount++ } } - - $mergedRows = New-Object System.Collections.Generic.List[object] - $newRows = New-Object System.Collections.Generic.List[object] - - foreach ($key in $existingOrder) { - if ($latestByKey.ContainsKey($key)) { - $null = $mergedRows.Add($latestByKey[$key]) - } - else { - $null = $mergedRows.Add($existingByKey[$key]) - } - } - - foreach ($key in $latestOrder) { - if (-not $existingByKey.ContainsKey($key)) { - $null = $mergedRows.Add($latestByKey[$key]) - $null = $newRows.Add($latestByKey[$key]) - } - } - - [pscustomobject]@{ - Rows = @($mergedRows) - NewRows = @($newRows) - } -} - -function Resolve-DefaultBackendPath { - if ($BackendRepoPath) { - return (Resolve-Path -Path $BackendRepoPath).Path - } - - return (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path + $ExcludeSkuList | ConvertTo-Json -Depth 100 | Set-Content -Path $ExcludeSkuListPath -Encoding utf8 + Write-Host "Updated $updatedCount product names in ExcludeSkuList.JSON." -ForegroundColor Green } -function Resolve-DefaultFrontendPath { - if ($FrontendRepoPath) { - return (Resolve-Path -Path $FrontendRepoPath).Path - } - - $candidatePaths = @( - (Join-Path (Resolve-DefaultBackendPath) '..\CIPP'), - (Join-Path (Get-Location).Path 'CIPP'), - (Get-Location).Path - ) - - foreach ($candidate in $candidatePaths) { - if (Test-Path (Join-Path $candidate 'src\data')) { - return (Resolve-Path -Path $candidate).Path - } - } - - throw 'Unable to determine FrontendRepoPath. Provide -FrontendRepoPath explicitly.' -} - -function Write-NewSkuSummary { - param( - [string]$FilePath, - [object[]]$NewRows - ) - - if ($NewRows.Count -eq 0) { - Write-Host "No new SKUs detected for $FilePath" -ForegroundColor DarkGray - return - } - - Write-Host "New SKUs detected for $FilePath ($($NewRows.Count))" -ForegroundColor Cyan - foreach ($row in $NewRows) { - Write-Host (" + {0} | {1} | {2}" -f $row.GUID, $row.String_Id, $row.Product_Display_Name) - } -} - -Write-Host 'Downloading latest Microsoft license SKU CSV...' -ForegroundColor Yellow -Invoke-WebRequest -Uri $licenseCsvURL -OutFile $TempLicenseDataFile -$LicenseData = Import-Csv -Path $TempLicenseDataFile -Encoding utf8BOM -Delimiter ',' -$LatestCanonical = @($LicenseData | ForEach-Object { Convert-ToCanonicalRow -Row $_ }) - -try { - if ($Target -in @('backend', 'both')) { - $ResolvedBackendPath = Resolve-DefaultBackendPath - $ConversionTableFiles = Get-ChildItem -Path $ResolvedBackendPath -Filter 'ConversionTable.csv' -Recurse -File - - Write-Host "Updating $($ConversionTableFiles.Count) backend ConversionTable.csv files..." -ForegroundColor Yellow - - foreach ($file in $ConversionTableFiles) { - $existingRows = @() - if (Test-Path $file.FullName) { - $existingRows = @(Import-Csv -Path $file.FullName -Encoding utf8 -Delimiter ',') - } - - $mergeResult = Merge-LicenseRows -ExistingRows $existingRows -LatestRows $LatestCanonical - $mergeResult.Rows | - Select-Object -Property $CanonicalColumns | - Export-Csv -Path $file.FullName -NoTypeInformation -Force -Encoding utf8 -UseQuotes AsNeeded - - Write-NewSkuSummary -FilePath $file.FullName -NewRows $mergeResult.NewRows - Write-Host "Updated $($file.FullName)" -ForegroundColor Green - } - - $ExcludeSkuListPath = Join-Path $ResolvedBackendPath 'Config\ExcludeSkuList.JSON' - if (Test-Path $ExcludeSkuListPath) { - Write-Host 'Syncing ExcludeSkuList.JSON product names...' -ForegroundColor Yellow - $GuidToName = @{} - foreach ($license in $LatestCanonical) { - if ($license.GUID -and -not $GuidToName.ContainsKey($license.GUID)) { - $GuidToName[$license.GUID] = $license.Product_Display_Name - } - } - - $ExcludeSkuList = Get-Content -Path $ExcludeSkuListPath -Encoding utf8 | ConvertFrom-Json - $updatedCount = 0 - foreach ($entry in $ExcludeSkuList) { - if ($GuidToName.ContainsKey($entry.GUID) -and $entry.Product_Display_Name -cne $GuidToName[$entry.GUID]) { - $entry.Product_Display_Name = $GuidToName[$entry.GUID] - $updatedCount++ - } - } - - $ExcludeSkuList | ConvertTo-Json -Depth 100 | Set-Content -Path $ExcludeSkuListPath -Encoding utf8 - Write-Host "Updated $updatedCount product names in ExcludeSkuList.JSON." -ForegroundColor Green - } - } - - if ($Target -in @('frontend', 'both')) { - $ResolvedFrontendPath = Resolve-DefaultFrontendPath - $FrontendDataPath = Join-Path $ResolvedFrontendPath 'src\data' - if (-not (Test-Path $FrontendDataPath)) { - throw "Frontend data path not found: $FrontendDataPath" - } - - $LicenseJSONFiles = Get-ChildItem -Path $FrontendDataPath -Filter '*M365Licenses.json' -File - Write-Host "Updating $($LicenseJSONFiles.Count) frontend M365 license JSON files..." -ForegroundColor Yellow - - foreach ($file in $LicenseJSONFiles) { - $existingRows = @() - if (Test-Path $file.FullName) { - $existingRows = @(Get-Content -Path $file.FullName -Encoding utf8 | ConvertFrom-Json) - } - - $mergeResult = Merge-LicenseRows -ExistingRows $existingRows -LatestRows $LatestCanonical - $mergeResult.Rows | - Select-Object -Property $CanonicalColumns | - ConvertTo-Json -Depth 100 | - Set-Content -Path $file.FullName -Encoding utf8 - - Write-NewSkuSummary -FilePath $file.FullName -NewRows $mergeResult.NewRows - Write-Host "Updated $($file.FullName)" -ForegroundColor Green - } - } -} -finally { - if (Test-Path $TempLicenseDataFile) { - Remove-Item -Path $TempLicenseDataFile -Force - } -} +# Clean up the temporary license SKU CSV file +Remove-Item -Path $TempLicenseDataFile -Force From d6975d2ac54daad2f5a38c9dd033a1f19bc00e96 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:35:29 +0800 Subject: [PATCH 14/31] Fix: Make API client creation more resilient for entra replication time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change hardens CIPP API client setup against transient Entra/Graph replication timing issues. During app registration creation, Graph can briefly fail service principal creation with “resource does not exist or queried reference-property objects are not present” even though the app was just created. --- .../Public/Authentication/New-CIPPAPIConfig.ps1 | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 index 0ddd57a18fd2..6bbaaa8515d9 100644 --- a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 +++ b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 @@ -81,7 +81,19 @@ function New-CIPPAPIConfig { $APIIdUrl = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($APIApp.id)" -AsApp $true -NoAuthCheck $true -type PATCH -body "{`"identifierUris`":[`"api://$($APIApp.appId)`"]}" -maxRetries 3 Write-Information 'Adding serviceprincipal' $Step = 'Creating Service Principal' - $ServicePrincipal = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/serviceprincipals' -AsApp $true -NoAuthCheck $true -type POST -body "{`"accountEnabled`":true,`"appId`":`"$($APIApp.appId)`",`"displayName`":`"$AppName`",`"tags`":[`"WindowsAzureActiveDirectoryIntegratedApp`",`"AppServiceIntegratedApp`"]}" -maxRetries 3 + $ServicePrincipalBody = "{`"accountEnabled`":true,`"appId`":`"$($APIApp.appId)`",`"displayName`":`"$AppName`",`"tags`":[`"WindowsAzureActiveDirectoryIntegratedApp`",`"AppServiceIntegratedApp`"]}" + for ($Attempt = 1; $Attempt -le 4; $Attempt++) { + try { + $ServicePrincipal = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/serviceprincipals' -AsApp $true -NoAuthCheck $true -type POST -body $ServicePrincipalBody -maxRetries 3 + break + } catch { + if ($Attempt -lt 4) { + Start-Sleep -Seconds 1 + continue + } + throw + } + } Write-LogMessage -headers $Headers -API $APINAME -tenant 'None '-message "Created CIPP-API App with name '$($APIApp.displayName)'." -Sev 'info' } } From 8484e3954d528baab9b410038a66f95e7d7ac5fd Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Mon, 23 Mar 2026 18:42:44 +0100 Subject: [PATCH 15/31] cleanup code --- .../Users/Invoke-AddUserDefaults.ps1 | 2 +- Modules/CIPPCore/Public/New-CIPPUserTask.ps1 | 23 ++++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 6936763f2f50..b9b99ff6553e 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -149,7 +149,7 @@ function Invoke-AddUserDefaults { $Action = if ($Request.Body.GUID) { 'Updated' } else { 'Created' } $Result = "$Action User Default Template '$($TemplateName)' with GUID $GUID" - Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index f8644199a762..d8dce48e5021 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -22,13 +22,12 @@ function New-CIPPUserTask { if ($UserObj.licenses.value) { # Filter out licenses with no available units try { - $SubscribedSkus = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/subscribedSkus' -tenantid $UserObj.tenantFilter + $LicenseOverview = Get-CIPPLicenseOverview -TenantFilter $UserObj.tenantFilter $FilteredLicenses = @(foreach ($LicenseId in $UserObj.licenses.value) { - $Sku = $SubscribedSkus | Where-Object { $_.skuId -eq $LicenseId } - $Available = [int]$Sku.prepaidUnits.enabled - [int]$Sku.consumedUnits - if ($Sku -and $Available -le 0) { - $null = $Results.Add("Skipped license $($Sku.skuPartNumber): no available units") - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Skipped license $($Sku.skuPartNumber) for $($CreationResults.Username): no available units" -Sev 'Warn' + $Sku = $LicenseOverview | Where-Object { $_.skuId -eq $LicenseId } + if ($Sku -and [int]$Sku.availableUnits -le 0) { + $null = $Results.Add("Skipped license $($Sku.License): no available units") + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Skipped license $($Sku.License) for $($CreationResults.Username): no available units" -Sev 'Warn' } else { $LicenseId } @@ -87,16 +86,12 @@ function New-CIPPUserTask { if ($UserObj.groupMemberships -and ($UserObj.groupMemberships | Measure-Object).Count -gt 0) { Write-Host "Adding user to $(@($UserObj.groupMemberships).Count) groups from template" - $ODataBind = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $CreationResults.User.id - $AddMemberBody = @{ '@odata.id' = $ODataBind } | ConvertTo-Json -Compress foreach ($Group in $UserObj.groupMemberships) { try { - if ($Group.mailEnabled -and $Group.groupTypes -notcontains 'Unified') { - $Params = @{ Identity = $Group.id; Member = $CreationResults.Username; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $Params -UseSystemMailbox $true - } else { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$($Group.id)/members/`$ref" -tenantid $UserObj.tenantFilter -body $AddMemberBody -Verbose - } + $GroupType = if ($Group.groupTypes -contains 'Unified') { 'Microsoft 365' } + elseif ($Group.mailEnabled) { 'Distribution list' } + else { 'Security' } + Add-CIPPGroupMember -Headers $Headers -GroupType $GroupType -GroupId $Group.id -Member $CreationResults.Username -TenantFilter $UserObj.tenantFilter -APIName $APIName $Results.Add("Added user to group from template: $($Group.displayName)") } catch { $Results.Add("Failed to add to group $($Group.displayName): $($_.Exception.Message)") From 60e57dd8f94b228e887376d91bae1fc6598b8fc4 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Mon, 23 Mar 2026 18:46:40 +0100 Subject: [PATCH 16/31] cleanup unnecessary checks --- .../Administration/Groups/Invoke-AddGroupTemplate.ps1 | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 index 30340b9bfae5..5f709fd9db24 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 @@ -13,12 +13,6 @@ function Invoke-AddGroupTemplate { if (!$Request.Body.displayName) { throw 'You must enter a displayname' } - if (!$Request.Body.username) { - throw 'You must enter a username' - } - if (!$Request.Body.groupType) { - throw 'You must select a group type' - } # Normalize group type to match New-CIPPGroup expectations # Handle values from ListGroups calculatedGroupType and frontend form values From 11c6bc0fe889465331903486c9ca2dfc53b3943f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 23 Mar 2026 14:55:51 -0400 Subject: [PATCH 17/31] fix: cleanup of standard template when removed --- .../Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 | 7 ++++--- .../Orchestrator Functions/Start-CIPPOrchestrator.ps1 | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 index 283913c6702c..6125cc632780 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 @@ -16,14 +16,15 @@ function Invoke-RemoveStandardTemplate { $ID = $Request.Body.ID ?? $Request.Query.ID try { $Table = Get-CippTable -tablename 'templates' - $Filter = "PartitionKey eq 'StandardsTemplateV2' and (GUID eq '$ID' or RowKey eq '$ID' or OriginalEntityId eq '$ID')" - $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, JSON + $Filter = "PartitionKey eq 'StandardsTemplateV2' and (RowKey eq '$ID' or OriginalEntityId eq '$ID' or OriginalEntityId eq guid'$ID')" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($ClearRow.JSON) { $TemplateName = (ConvertFrom-Json -InputObject $ClearRow.JSON -ErrorAction SilentlyContinue).templateName } else { $TemplateName = '' } - Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Entities = Get-AzDataTableEntity @Table -Filter $Filter + Remove-AzDataTableEntity -Force @Table -Entity $Entities $Result = "Removed Standards Template named: '$($TemplateName)' with id: $($ID)" Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index ef944134eef3..57f64ac7cde1 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -70,7 +70,7 @@ function Start-CIPPOrchestrator { # Clean up the stored input object after starting the orchestration try { - $Entities = Get-AzDataTableEntity @OrchestratorTable -Filter "PartitionKey eq 'Input' and (RowKey eq '$InputObjectGuid' or OriginalEntityId eq '$InputObjectGuid')" -Property PartitionKey, RowKey + $Entities = Get-AzDataTableEntity @OrchestratorTable -Filter "PartitionKey eq 'Input' and (RowKey eq '$InputObjectGuid' or OriginalEntityId eq '$InputObjectGuid' or OriginalEntityId eq guid'$InputObjectGuid')" -Property PartitionKey, RowKey Remove-AzDataTableEntity @OrchestratorTable -Entity $Entities -Force Write-Information "Cleaned up stored input object: $InputObjectGuid" } catch { From c396867a4e47e400f39774e869350d7fec9c84e7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 23 Mar 2026 14:56:36 -0400 Subject: [PATCH 18/31] fix: update inclusion/exclusion logic for tenant alignment --- .../Functions/Get-CIPPTenantAlignment.ps1 | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 5ffcaf908254..aedf3b274f98 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -113,11 +113,36 @@ function Get-CIPPTenantAlignment { $TemplateAssignedTenants = @() $AppliestoAllTenants = $false + # Build excluded tenants list (mirrors Get-CIPPStandards logic, including group expansion) + $ExcludedTenantValues = [System.Collections.Generic.List[string]]::new() + if ($Template.excludedTenants) { + $ExcludeList = if ($Template.excludedTenants -is [System.Collections.IEnumerable] -and -not ($Template.excludedTenants -is [string])) { + $Template.excludedTenants + } else { + @($Template.excludedTenants) + } + foreach ($excludeItem in $ExcludeList) { + $ExcludeValue = $excludeItem.value + if ($excludeItem.type -eq 'Group') { + $GroupMembers = $TenantGroups | Where-Object { $_.Id -eq $ExcludeValue } + if ($GroupMembers -and $GroupMembers.Members) { + foreach ($member in $GroupMembers.Members.defaultDomainName) { + $ExcludedTenantValues.Add($member) + } + } + } else { + if ($ExcludeValue) { $ExcludedTenantValues.Add($ExcludeValue) } + } + } + } + $ExcludedTenantsSet = [System.Collections.Generic.HashSet[string]]::new() + foreach ($item in $ExcludedTenantValues) { [void]$ExcludedTenantsSet.Add($item) } + if ($Template.tenantFilter -and $Template.tenantFilter.Count -gt 0) { # Extract tenant values from the tenantFilter array $TenantValues = [System.Collections.Generic.List[string]]::new() foreach ($filterItem in $Template.tenantFilter) { - if ($filterItem.type -eq 'group') { + if ($filterItem.type -eq 'Group') { # Look up group members by Id (GUID in the value field) $GroupMembers = $TenantGroups | Where-Object { $_.Id -eq $filterItem.value } if ($GroupMembers -and $GroupMembers.Members) { @@ -224,6 +249,10 @@ function Get-CIPPTenantAlignment { } else { $null } foreach ($TenantName in $TenantStandards.Keys) { + # Skip explicitly excluded tenants regardless of AllTenants or specific assignment + if ($ExcludedTenantsSet.Contains($TenantName)) { + continue + } # Check tenant scope with HashSet and cache tenant data if (-not $AppliestoAllTenants) { if ($TemplateAssignedTenantsSet -and -not $TemplateAssignedTenantsSet.Contains($TenantName)) { From cecaff66b8edba5eb92fa1eb9d43650411f3960a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 23 Mar 2026 15:59:33 -0400 Subject: [PATCH 19/31] fix: add initialDomainName support to logs and exo request --- Modules/CIPPCore/Public/Entrypoints/Invoke-ListLogs.ps1 | 3 +-- Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListLogs.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListLogs.ps1 index 907e06857fa7..0612ec3ef23b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListLogs.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListLogs.ps1 @@ -33,8 +33,7 @@ function Invoke-ListLogs { $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } } - if ($AllowedTenants -contains 'AllTenants' -or ($AllowedTenants -notcontains 'AllTenants' -and ($TenantList.defaultDomainName -contains $Row.Tenant -or $Row.Tenant -eq 'CIPP' -or $TenantList.customerId -contains $Row.TenantId)) ) { - + if ($AllowedTenants -contains 'AllTenants' -or ($AllowedTenants -notcontains 'AllTenants' -and ($TenantList.defaultDomainName -contains $Row.Tenant -or $Row.Tenant -eq 'CIPP' -or $TenantList.customerId -contains $Row.TenantId -or $TenantList.initialDomainName -contains $Row.Tenant)) ) { if ($Row.StandardTemplateId) { $Standard = ($Templates | Where-Object { $_.RowKey -eq $Row.StandardTemplateId }).JSON | ConvertFrom-Json diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index 93c373c1fa98..19e6aad21817 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -56,7 +56,7 @@ function New-ExoRequest { } $ExoBody = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $ExoBody -EscapeForJson - $Tenant = Get-Tenants -IncludeErrors | Where-Object { $_.defaultDomainName -eq $tenantid -or $_.customerId -eq $tenantid } + $Tenant = Get-Tenants -IncludeErrors | Where-Object { $_.defaultDomainName -eq $tenantid -or $_.customerId -eq $tenantid -or $_.initialDomainName -eq $tenantid } | Select-Object -First 1 if (-not $Tenant -and $NoAuthCheck -eq $true) { $Tenant = [PSCustomObject]@{ customerId = $tenantid From c85052abdb6ca6c67b55d12a903230a75cfb4aba Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:14:49 +0800 Subject: [PATCH 20/31] Add cmdlets to remove extension API keys Introduce three new PowerShell functions to support removing extension API keys and Key Vault secrets: - Invoke-ExecExtensionClearHIBPKey: HTTP entrypoint that clears the HIBP API key by calling Remove-ExtensionAPIKey and returns an HTTP response. - Remove-CippKeyVaultSecret: Lightweight REST-based Key Vault secret deletion (no Az.KeyVault dependency). Derives vault name from WEBSITE_DEPLOYMENT_ID when not provided, obtains an AAD token via Get-CIPPAzIdentityToken, calls the Vault REST API, and returns deletion metadata; handles 404 not found and reports errors. - Remove-ExtensionAPIKey: Removes extension API keys from a DevSecrets table when running with local Azurite/dev storage, or deletes the secret from Key Vault in production; clears the corresponding env variable and logs outcomes. Adds error handling, informative logging, and safe behavior for local development vs production Key Vault usage. --- .../Invoke-ExecExtensionClearHIBPKey.ps1 | 22 +++++++ .../Public/Remove-CippKeyVaultSecret.ps1 | 58 +++++++++++++++++++ .../Remove-ExtensionAPIKey.ps1 | 33 +++++++++++ 3 files changed, 113 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 create mode 100644 Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 new file mode 100644 index 000000000000..c7a6f656047d --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionClearHIBPKey.ps1 @@ -0,0 +1,22 @@ +function Invoke-ExecExtensionClearHIBPKey { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.Extension.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Results = try { + Remove-ExtensionAPIKey -Extension 'HIBP' | Out-Null + 'Successfully cleared the HIBP API key.' + } catch { + "Failed to clear the HIBP API key" + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{'Results' = $Results } + }) +} diff --git a/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 new file mode 100644 index 000000000000..64464ee7f72e --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 @@ -0,0 +1,58 @@ +function Remove-CippKeyVaultSecret { + <# + .SYNOPSIS + Deletes a secret from Azure Key Vault using REST API (no Az.KeyVault module required) + + .DESCRIPTION + Lightweight replacement for Remove-AzKeyVaultSecret that uses REST API directly. + + .PARAMETER VaultName + Name of the Key Vault. If not provided, derives from WEBSITE_DEPLOYMENT_ID environment variable. + + .PARAMETER Name + Name of the secret to delete. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$VaultName, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + try { + if (-not $VaultName) { + if ($env:WEBSITE_DEPLOYMENT_ID) { + $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + } else { + throw 'VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set' + } + } + + $token = Get-CIPPAzIdentityToken -ResourceUrl 'https://vault.azure.net' + $uri = "https://$VaultName.vault.azure.net/secrets/$Name`?api-version=7.4" + + $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $token" } -Method Delete -ErrorAction Stop + + return @{ + Name = $Name + VaultName = $VaultName + Id = $response.recoveryId + Deleted = $true + } + } catch { + if ($_.Exception.Message -match '404|NotFound') { + return @{ + Name = $Name + VaultName = $VaultName + Id = $null + Deleted = $false + Status = 'NotFound' + } + } + + Write-Error "Failed to delete secret '$Name' from vault '$VaultName': $($_.Exception.Message)" + throw + } +} diff --git a/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 new file mode 100644 index 000000000000..3e42100a218f --- /dev/null +++ b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 @@ -0,0 +1,33 @@ +function Remove-ExtensionAPIKey { + <# + .FUNCTIONALITY + Internal + #> + param( + [Parameter(Mandatory = $true)] + [string]$Extension + ) + + $Var = "Ext_$Extension" + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $DevSecretRows = Get-AzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$Extension'" + if ($DevSecretRows) { + Remove-AzDataTableEntity @DevSecretsTable -Entity @($DevSecretRows) -Force -ErrorAction Stop + Write-Information "Deleted $(@($DevSecretRows).Count) DevSecrets row(s) for '$Extension'." + } else { + Write-Information "No existing DevSecrets row found for '$Extension' to delete." + } + } else { + $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + try { + $null = Remove-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension + } catch { + Write-Warning "Unable to delete secret '$Extension' from '$keyvaultname'" + } + } + + Remove-Item -Path "env:$Var" -Force -ErrorAction SilentlyContinue + + return $true +} From 1eb4862daa818ee4a431eff2cf50dab831ebd243 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:06:38 +0800 Subject: [PATCH 21/31] Add standardized webhook schema support Introduce an opt-in, versioned webhook schema and plumbing to emit it. Adds New-CIPPStandardizedWebhookSchema to normalize legacy payloads. Send-CIPPAlert gains SchemaSource, InvokingCommand and UseStandardizedSchema handling, builds/serializes standardized payloads and adapts Teams/Discord/Slack sends accordingly; also hardens extension config parsing. Notification config endpoints (Invoke-ExecNotificationConfig, Set-CIPPNotificationConfig, Invoke-ListNotificationConfig) now expose/handle UseStandardizedSchema. Callers (scheduler notifications, scheduled task alerts, webhook processing, offboarding) are updated to send titles, schema source, invoking command and the standardized flag where appropriate. --- .../Push-CIPPOffboardingComplete.ps1 | 2 +- .../Push-SchedulerCIPPNotifications.ps1 | 6 +- .../Invoke-ExecNotificationConfig.ps1 | 13 +-- .../Invoke-ListNotificationConfig.ps1 | 2 +- .../New-CIPPStandardizedWebhookSchema.ps1 | 84 +++++++++++++++++ Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 90 ++++++++++++++----- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 39 ++++++-- .../Public/Set-CIPPNotificationConfig.ps1 | 28 +++--- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 3 + 9 files changed, 217 insertions(+), 50 deletions(-) create mode 100644 Modules/CIPPCore/Public/New-CIPPStandardizedWebhookSchema.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 index faca0853ea6f..c848c0b14723 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 @@ -107,7 +107,7 @@ function Push-CIPPOffboardingComplete { # Send post-execution alerts if configured if ($TaskInfo.PostExecution -and $ProcessedResults) { - Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter + Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter -TaskType 'User Offboarding' } } Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Offboarding completed for $Username" -sev Info -headers $Headers diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 index bc1d438f1a99..3bbcb1130688 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 @@ -109,7 +109,8 @@ function Push-SchedulerCIPPNotifications { if (![string]::IsNullOrEmpty($config.webhook)) { if ($Currentlog) { $JSONContent = $Currentlog | ConvertTo-Json -Compress - Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' + $Title = "Logbook Notification: Alerts found starting at $((Get-Date).AddMinutes(-15))" + Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' -SchemaSource 'Logbook Notification' -InvokingCommand 'Push-SchedulerCIPPNotifications' -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) $UpdateLogs = $CurrentLog | ForEach-Object { $_.sentAsAlert = $true; $_ } if ($UpdateLogs) { Add-CIPPAzDataTableEntity @Table -Entity $UpdateLogs -Force } } @@ -118,7 +119,8 @@ function Push-SchedulerCIPPNotifications { $Data = $CurrentStandardsLogs $JSONContent = New-CIPPAlertTemplate -Data $Data -Format 'json' -InputObject 'table' -CIPPURL $CIPPURL $CurrentStandardsLogs | ConvertTo-Json -Compress - Send-CIPPAlert -Type 'webhook' -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' + $Title = "Standards Notification: Out of sync standards detected" + Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $JSONContent -TenantFilter $Tenant -APIName 'Alerts' -SchemaSource 'Standards Notification' -InvokingCommand 'Push-SchedulerCIPPNotifications' -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) $updateStandards = $CurrentStandardsLogs | ForEach-Object { if ($_.PSObject.Properties.Name -contains 'sentAsAlert') { $_.sentAsAlert = $true diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 index feafd5eae3f0..7b23c445b724 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 @@ -9,12 +9,13 @@ Function Invoke-ExecNotificationConfig { param($Request, $TriggerMetadata) $sev = ([pscustomobject]$Request.body.Severity).value -join (',') $config = @{ - email = $Request.body.email - webhook = $Request.body.webhook - onepertenant = $Request.body.onePerTenant - logsToInclude = $Request.body.logsToInclude - sendtoIntegration = $Request.body.sendtoIntegration - sev = $sev + email = $Request.body.email + webhook = $Request.body.webhook + onepertenant = $Request.body.onePerTenant + logsToInclude = $Request.body.logsToInclude + sendtoIntegration = $Request.body.sendtoIntegration + UseStandardizedSchema = [boolean]$Request.body.UseStandardizedSchema + sev = $sev } $Results = Set-cippNotificationConfig @Config $body = [pscustomobject]@{'Results' = $Results } diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 index 4d9f4f11cfdb..f24a9d06740d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 @@ -16,7 +16,7 @@ Function Invoke-ListNotificationConfig { $Config = @{} } #$config | Add-Member -NotePropertyValue @() -NotePropertyName 'logsToInclude' -Force - $config.logsToInclude = @(([pscustomobject]$config | Select-Object * -ExcludeProperty schedule, type, tenantid, onepertenant, sendtoIntegration, partitionkey, rowkey, tenant, ETag, email, logsToInclude, Severity, Alert, Info, Error, timestamp, webhook, includeTenantId).psobject.properties.name) + $config.logsToInclude = @(([pscustomobject]$config | Select-Object * -ExcludeProperty schedule, type, tenantid, onepertenant, sendtoIntegration, partitionkey, rowkey, tenant, ETag, email, logsToInclude, Severity, Alert, Info, Error, timestamp, webhook, includeTenantId, UseStandardizedSchema).psobject.properties.name) if (!$config.logsToInclude) { $config.logsToInclude = @('None') } diff --git a/Modules/CIPPCore/Public/New-CIPPStandardizedWebhookSchema.ps1 b/Modules/CIPPCore/Public/New-CIPPStandardizedWebhookSchema.ps1 new file mode 100644 index 000000000000..77ae5aa73b6f --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPStandardizedWebhookSchema.ps1 @@ -0,0 +1,84 @@ +function New-CIPPStandardizedWebhookSchema { + <# + .SYNOPSIS + Builds a standardized webhook alert payload. + + .DESCRIPTION + Converts legacy alert payloads (string/object/array) into a stable, versioned JSON schema + for webhook consumers. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Title, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + $Payload, + + [Parameter(Mandatory = $false)] + [string]$Source = 'CIPP', + + [Parameter(Mandatory = $false)] + [string]$InvokingCommand + ) + + $NormalizedPayload = $null + + if ($null -eq $Payload) { + $NormalizedPayload = [pscustomobject]@{} + } elseif ($Payload -is [string]) { + if (Test-Json -Json $Payload -ErrorAction SilentlyContinue) { + $NormalizedPayload = $Payload | ConvertFrom-Json -Depth 50 + } else { + $NormalizedPayload = [pscustomobject]@{ + message = $Payload + } + } + } else { + $NormalizedPayload = $Payload + } + + $AlertCount = if ($NormalizedPayload -is [array]) { $NormalizedPayload.Count } else { 1 } + + $DetectedInvokingCommand = $null + + if ($NormalizedPayload -is [array] -and $NormalizedPayload.Count -gt 0) { + if ($NormalizedPayload[0].PSObject.Properties.Name -contains 'API') { + $ApiList = @($NormalizedPayload | Where-Object { $_.API } | Select-Object -ExpandProperty API -Unique) + if ($ApiList.Count -gt 0) { + $DetectedInvokingCommand = $ApiList -join ', ' + } + } + } elseif ($NormalizedPayload -isnot [string] -and $null -ne $NormalizedPayload) { + if ($NormalizedPayload.PSObject.Properties.Name -contains 'task' -and $NormalizedPayload.task) { + if ($NormalizedPayload.task.PSObject.Properties.Name -contains 'command' -and $NormalizedPayload.task.command) { + $DetectedInvokingCommand = [string]$NormalizedPayload.task.command + } + } + + if (-not $DetectedInvokingCommand -and $NormalizedPayload.PSObject.Properties.Name -contains 'TaskInfo' -and $NormalizedPayload.TaskInfo) { + if ($NormalizedPayload.TaskInfo.PSObject.Properties.Name -contains 'Command' -and $NormalizedPayload.TaskInfo.Command) { + $DetectedInvokingCommand = [string]$NormalizedPayload.TaskInfo.Command + } + } + + } + + $ResolvedInvokingCommand = if (![string]::IsNullOrWhiteSpace($InvokingCommand)) { $InvokingCommand } elseif ($DetectedInvokingCommand) { $DetectedInvokingCommand } else { $null } + + $SchemaObject = [ordered]@{ + schemaVersion = '1.0' + source = $Source + invoking = $ResolvedInvokingCommand + title = $Title + tenant = $TenantFilter + generatedAt = [datetime]::UtcNow.ToString('o') + alertCount = $AlertCount + payload = $NormalizedPayload + } + + return [pscustomobject]$SchemaObject +} diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index 5965003d9ec5..829793d58e74 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -10,9 +10,12 @@ function Send-CIPPAlert { $altEmail, $altWebhook, $APIName = 'Send Alert', + $SchemaSource, + $InvokingCommand, $Headers, $TableName, - $RowKey = [string][guid]::NewGuid() + $RowKey = [string][guid]::NewGuid(), + [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' $Table = Get-CIPPTable -TableName SchedulerConfig @@ -102,7 +105,13 @@ function Send-CIPPAlert { Write-Information 'Trying to send webhook' $ExtensionTable = Get-CIPPTable -TableName Extensionsconfig - $Configuration = ((Get-CIPPAzDataTableEntity @ExtensionTable).config | ConvertFrom-Json) + $ExtensionConfig = Get-CIPPAzDataTableEntity @ExtensionTable + # Check if config exists and is not null before parsing + if ($ExtensionConfig.config -and -not [string]::IsNullOrWhiteSpace($ExtensionConfig.config)) { + $Configuration = $ExtensionConfig.config | ConvertFrom-Json + } else { + $Configuration = $null + } if ($Configuration.CFZTNA.WebhookEnabled -eq $true -and $Configuration.CFZTNA.Enabled -eq $true) { $CFAPIKey = Get-ExtensionAPIKey -Extension 'CFZTNA' @@ -112,34 +121,75 @@ function Send-CIPPAlert { $Headers = $null } - $ReplacedContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $JSONContent -EscapeForJson + $UseStandardizedWebhookSchema = [boolean]$Config.UseStandardizedSchema + if ($PSBoundParameters.ContainsKey('UseStandardizedSchema')) { + $UseStandardizedWebhookSchema = [boolean]$UseStandardizedSchema + } + + $EffectiveTitle = if ([string]::IsNullOrWhiteSpace($Title)) { + '{0} - {1} - Webhook Alert' -f $APIName, $TenantFilter + } else { + $Title + } + + $EffectiveSchemaSource = if (![string]::IsNullOrWhiteSpace($SchemaSource)) { + $SchemaSource + } elseif (![string]::IsNullOrWhiteSpace($APIName)) { + $APIName + } else { + 'CIPP' + } + + $WebhookContent = if ($UseStandardizedWebhookSchema) { + New-CIPPStandardizedWebhookSchema -Title $EffectiveTitle -TenantFilter $TenantFilter -Payload $JSONContent -Source $EffectiveSchemaSource -InvokingCommand $InvokingCommand + } else { + $JSONContent + } + + if ($WebhookContent -isnot [string]) { + $WebhookContent = $WebhookContent | ConvertTo-Json -Compress -Depth 50 + } + + $ReplacedContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $WebhookContent -EscapeForJson try { if (![string]::IsNullOrWhiteSpace($Config.webhook) -or ![string]::IsNullOrWhiteSpace($AltWebhook)) { - if ($PSCmdlet.ShouldProcess($Config.webhook, 'Sending webhook')) { - $webhook = if ($AltWebhook) { $AltWebhook } else { $Config.webhook } + $webhook = if ($AltWebhook) { $AltWebhook } else { $Config.webhook } + if ($PSCmdlet.ShouldProcess($webhook, 'Sending webhook')) { switch -wildcard ($webhook) { '*webhook.office.com*' { - $TeamsBody = [PSCustomObject]@{ - text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$ReplacedContent" - } | ConvertTo-Json -Compress - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $TeamsBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + if ($UseStandardizedWebhookSchema) { + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + } else { + $TeamsBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$ReplacedContent" + } | ConvertTo-Json -Compress + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $TeamsBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + } } '*discord.com*' { - $DiscordBody = [PSCustomObject]@{ - content = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" - } | ConvertTo-Json -Compress - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $DiscordBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + if ($UseStandardizedWebhookSchema) { + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + } else { + $DiscordBody = [PSCustomObject]@{ + content = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $DiscordBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + } } '*slack.com*' { - $SlackBlocks = Get-SlackAlertBlocks -JSONBody $JSONContent - if ($SlackBlocks.blocks) { - $SlackBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress + if ($UseStandardizedWebhookSchema) { + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck } else { - $SlackBody = [PSCustomObject]@{ - text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" - } | ConvertTo-Json -Compress + $SlackBlocks = Get-SlackAlertBlocks -JSONBody $JSONContent + if ($SlackBlocks.blocks) { + $SlackBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress + } else { + $SlackBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress + } + $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $SlackBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck } - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $SlackBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck } default { $RestMethod = @{ diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 1f8b163fe714..df1f5c4cbc35 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -71,6 +71,11 @@ function Send-CIPPScheduledTaskAlert { Write-Information 'Scheduler: Sending the results to configured targets.' + $NotificationTable = Get-CIPPTable -TableName SchedulerConfig + $NotificationFilter = "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'" + $NotificationConfig = [pscustomobject](Get-CIPPAzDataTableEntity @NotificationTable -Filter $NotificationFilter) + $UseStandardizedSchema = [boolean]$NotificationConfig.UseStandardizedSchema + # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { '*psa*' { @@ -80,14 +85,34 @@ function Send-CIPPScheduledTaskAlert { Send-CIPPAlert -Type 'email' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter } '*webhook*' { - $Webhook = [PSCustomObject]@{ - 'tenantId' = $TenantInfo.customerId - 'Tenant' = $TenantFilter - 'TaskInfo' = $TaskInfo - 'Results' = $Results - 'AlertComment' = $TaskInfo.AlertComment + $Webhook = if ($UseStandardizedSchema) { + [PSCustomObject]@{ + tenantId = $TenantInfo.customerId + tenant = $TenantFilter + taskType = $TaskType + task = [PSCustomObject]@{ + id = $TaskInfo.RowKey + name = $TaskInfo.Name + command = $TaskInfo.Command + state = $TaskInfo.TaskState + reference = $TaskInfo.Reference + scheduled = $TaskInfo.ScheduledTime + executed = $TaskInfo.ExecutedTime + partition = $TaskInfo.PartitionKey + } + results = $Results + alertComment = $TaskInfo.AlertComment + } + } else { + [PSCustomObject]@{ + tenantId = $TenantInfo.customerId + Tenant = $TenantFilter + TaskInfo = $TaskInfo + Results = $Results + AlertComment = $TaskInfo.AlertComment + } } - Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) + Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) -APIName 'Scheduled Task Alerts' -SchemaSource $TaskType -InvokingCommand $TaskInfo.Command -UseStandardizedSchema:$UseStandardizedSchema } } diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 7c6acb63d582..74e17dd81b19 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -7,24 +7,26 @@ function Set-CIPPNotificationConfig { $logsToInclude, $sendtoIntegration, $sev, + [boolean]$UseStandardizedSchema, $APIName = 'Set Notification Config' ) - $results = try { + try { $Table = Get-CIPPTable -TableName SchedulerConfig $SchedulerConfig = @{ - 'tenant' = 'Any' - 'tenantid' = 'TenantId' - 'type' = 'CIPPNotifications' - 'schedule' = 'Every 15 minutes' - 'Severity' = [string]$sev - 'email' = "$($email)" - 'webhook' = "$($webhook)" - 'onePerTenant' = [boolean]$onePerTenant - 'sendtoIntegration' = [boolean]$sendtoIntegration - 'includeTenantId' = $true - 'PartitionKey' = 'CippNotifications' - 'RowKey' = 'CippNotifications' + 'tenant' = 'Any' + 'tenantid' = 'TenantId' + 'type' = 'CIPPNotifications' + 'schedule' = 'Every 15 minutes' + 'Severity' = [string]$sev + 'email' = "$($email)" + 'webhook' = "$($webhook)" + 'onePerTenant' = [boolean]$onePerTenant + 'sendtoIntegration' = [boolean]$sendtoIntegration + 'UseStandardizedSchema' = [boolean]$UseStandardizedSchema + 'includeTenantId' = $true + 'PartitionKey' = 'CippNotifications' + 'RowKey' = 'CippNotifications' } foreach ($logvalue in [pscustomobject]$logsToInclude) { $SchedulerConfig[([pscustomobject]$logvalue.value)] = $true diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 4ece756421d6..d1f1b139413d 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -144,6 +144,9 @@ function Invoke-CippWebhookProcessing { Title = $GenerateJSON.Title JSONContent = $JsonContent TenantFilter = $TenantFilter + APIName = 'Audit Log Alerts' + SchemaSource = 'Audit Log Alert' + InvokingCommand = 'Start-AuditLogProcessingOrchestrator' } Write-Host 'Sending Webhook Content' Send-CIPPAlert @CippAlert From 1d546e13b2203c1101067c090bf75a259ca24bed Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 10:46:41 -0400 Subject: [PATCH 22/31] fix: Check extension standard Use OMA-URI decryption to compare existing policy Fix issue with detecting if policy is deployed --- ...CIPPStandardDeployCheckChromeExtension.ps1 | 119 ++++++++++++++---- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 index 921fe6d5db0f..3e2c62816a4e 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 @@ -169,11 +169,71 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { ) } | ConvertTo-Json -Depth 20 + # Compares OMA-URI settings between expected and existing policy. + # The 'value' field of omaSettingString is itself a JSON string, so we parse it + # before calling Compare-CIPPIntuneObject to avoid false positives from whitespace + # or key-ordering differences introduced by Intune's API response serialization. + function Compare-OMAURISettings { + param( + [Parameter(Mandatory = $true)]$ExpectedSettings, + [Parameter(Mandatory = $true)]$ExistingConfig + ) + $diffs = [System.Collections.Generic.List[PSCustomObject]]::new() + if ($null -eq $ExistingConfig -or $null -eq $ExistingConfig.omaSettings) { return $diffs } + + foreach ($expectedSetting in $ExpectedSettings) { + $existingSetting = $ExistingConfig.omaSettings | Where-Object { $_.omaUri -eq $expectedSetting.omaUri } + if (-not $existingSetting) { + $diffs.Add([PSCustomObject]@{ Property = $expectedSetting.omaUri; ExpectedValue = 'present'; ReceivedValue = 'missing' }) + continue + } + try { + $expectedValue = $expectedSetting.value | ConvertFrom-Json -Depth 20 + $existingValue = $existingSetting.value | ConvertFrom-Json -Depth 20 + $valueDiffs = Compare-CIPPIntuneObject -ReferenceObject $expectedValue -DifferenceObject $existingValue -CompareType 'Device' + foreach ($diff in $valueDiffs) { + $diffs.Add([PSCustomObject]@{ Property = "$($expectedSetting.omaUri).$($diff.Property)"; ExpectedValue = $diff.ExpectedValue; ReceivedValue = $diff.ReceivedValue }) + } + } catch { + # Fall back to string comparison if either value is not valid JSON + if ($expectedSetting.value -ne $existingSetting.value) { + $diffs.Add([PSCustomObject]@{ Property = $expectedSetting.omaUri; ExpectedValue = '[expected value]'; ReceivedValue = '[current value differs]' }) + } + } + } + return $diffs + } + try { - # Check if the policies already exist - $ExistingPolicies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -tenantid $Tenant - $ChromePolicyExists = $ExistingPolicies.value | Where-Object { $_.displayName -eq $ChromePolicyName } - $EdgePolicyExists = $ExistingPolicies.value | Where-Object { $_.displayName -eq $EdgePolicyName } + # Fetch existing policies with full configuration details for OMA-URI drift detection + $ExistingChromePolicy = Get-CIPPIntunePolicy -TemplateType 'Device' -DisplayName $ChromePolicyName -tenantFilter $Tenant + $ExistingEdgePolicy = Get-CIPPIntunePolicy -TemplateType 'Device' -DisplayName $EdgePolicyName -tenantFilter $Tenant + + $ChromePolicyExists = $null -ne $ExistingChromePolicy + $EdgePolicyExists = $null -ne $ExistingEdgePolicy + + # Build expected OMA-URI settings from the generated policy JSON for comparison + $ExpectedChromeSettings = ($ChromePolicyJSON | ConvertFrom-Json).omaSettings + $ExpectedEdgeSettings = ($EdgePolicyJSON | ConvertFrom-Json).omaSettings + + # Detect configuration drift in existing policies + $ChromeDifferences = [System.Collections.Generic.List[PSCustomObject]]::new() + $EdgeDifferences = [System.Collections.Generic.List[PSCustomObject]]::new() + + if ($ExistingChromePolicy) { + # omaSettingString values are encrypted by Intune; decrypt before comparing + $DecryptedChromePolicy = Get-CIPPOmaSettingDecryptedValue -DeviceConfiguration ($ExistingChromePolicy.cippconfiguration | ConvertFrom-Json) -DeviceConfigurationId $ExistingChromePolicy.id -TenantFilter $Tenant + $ChromeDifferences = Compare-OMAURISettings -ExpectedSettings $ExpectedChromeSettings -ExistingConfig $DecryptedChromePolicy + } + + if ($ExistingEdgePolicy) { + # omaSettingString values are encrypted by Intune; decrypt before comparing + $DecryptedEdgePolicy = Get-CIPPOmaSettingDecryptedValue -DeviceConfiguration ($ExistingEdgePolicy.cippconfiguration | ConvertFrom-Json) -DeviceConfigurationId $ExistingEdgePolicy.id -TenantFilter $Tenant + $EdgeDifferences = Compare-OMAURISettings -ExpectedSettings $ExpectedEdgeSettings -ExistingConfig $DecryptedEdgePolicy + } + + $ChromePolicyCompliant = $ChromePolicyExists -and ($ChromeDifferences.Count -eq 0) + $EdgePolicyCompliant = $EdgePolicyExists -and ($EdgeDifferences.Count -eq 0) if ($Settings.remediate -eq $true) { # Handle assignment configuration @@ -185,46 +245,63 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { $AssignTo = $Settings.customGroup } - # Deploy Chrome policy + # Deploy or remediate Chrome policy (create if missing, update if drifted) if (-not $ChromePolicyExists) { $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Google Chrome browsers' -DisplayName $ChromePolicyName -RawJSON $ChromePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully created Check Chrome Extension policy for Chrome: $ChromePolicyName" -sev Info + } elseif ($ChromeDifferences.Count -gt 0) { + $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Google Chrome browsers' -DisplayName $ChromePolicyName -RawJSON $ChromePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully corrected $($ChromeDifferences.Count) drifted OMA-URI setting(s) in Check Chrome Extension policy for Chrome" -sev Info } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Chrome already exists, skipping creation' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Chrome is compliant, no changes needed' -sev Info } - # Deploy Edge policy + # Deploy or remediate Edge policy (create if missing, update if drifted) if (-not $EdgePolicyExists) { $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Microsoft Edge browsers' -DisplayName $EdgePolicyName -RawJSON $EdgePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully created Check Chrome Extension policy for Edge: $EdgePolicyName" -sev Info + } elseif ($EdgeDifferences.Count -gt 0) { + $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Microsoft Edge browsers' -DisplayName $EdgePolicyName -RawJSON $EdgePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully corrected $($EdgeDifferences.Count) drifted OMA-URI setting(s) in Check Chrome Extension policy for Edge" -sev Info } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Edge already exists, skipping creation' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Edge is compliant, no changes needed' -sev Info } } if ($Settings.alert -eq $true) { - $BothPoliciesExist = $ChromePolicyExists -and $EdgePolicyExists - if ($BothPoliciesExist) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policies are deployed for both Chrome and Edge' -sev Info + if ($ChromePolicyCompliant -and $EdgePolicyCompliant) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policies are deployed and correctly configured for both Chrome and Edge' -sev Info } else { - $MissingPolicies = @() - if (-not $ChromePolicyExists) { $MissingPolicies += 'Chrome' } - if (-not $EdgePolicyExists) { $MissingPolicies += 'Edge' } - Write-StandardsAlert -message "Check Chrome Extension policies are missing for: $($MissingPolicies -join ', ')" -object @{ 'Missing Policies' = $MissingPolicies -join ',' } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Check Chrome Extension policies are missing for: $($MissingPolicies -join ', ')" -sev Alert + $Issues = [System.Collections.Generic.List[string]]::new() + if (-not $ChromePolicyExists) { + $Issues.Add('Chrome policy is missing') + } elseif ($ChromeDifferences.Count -gt 0) { + $Issues.Add("Chrome policy OMA-URI settings differ ($($ChromeDifferences.Count) difference(s))") + } + if (-not $EdgePolicyExists) { + $Issues.Add('Edge policy is missing') + } elseif ($EdgeDifferences.Count -gt 0) { + $Issues.Add("Edge policy OMA-URI settings differ ($($EdgeDifferences.Count) difference(s))") + } + Write-StandardsAlert -message "Check Chrome Extension issues: $($Issues -join '; ')" -object @{ Issues = ($Issues -join '; '); ChromeDifferences = $ChromeDifferences; EdgeDifferences = $EdgeDifferences } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Check Chrome Extension issues: $($Issues -join '; ')" -sev Alert } } if ($Settings.report -eq $true) { - $StateIsCorrect = $ChromePolicyExists -and $EdgePolicyExists + $StateIsCorrect = $ChromePolicyCompliant -and $EdgePolicyCompliant $ExpectedValue = [PSCustomObject]@{ - ChromePolicyDeployed = $true - EdgePolicyDeployed = $true + ChromePolicyDeployed = $true + ChromePolicyCompliant = $true + EdgePolicyDeployed = $true + EdgePolicyCompliant = $true } $CurrentValue = [PSCustomObject]@{ - ChromePolicyDeployed = $ChromePolicyExists - EdgePolicyDeployed = $EdgePolicyExists + ChromePolicyDeployed = [bool]$ChromePolicyExists + ChromePolicyCompliant = [bool]$ChromePolicyCompliant + EdgePolicyDeployed = [bool]$EdgePolicyExists + EdgePolicyCompliant = [bool]$EdgePolicyCompliant } Set-CIPPStandardsCompareField -FieldName 'standards.DeployCheckChromeExtension' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'DeployCheckChromeExtension' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant From 152153ecb2b34b5ec5e062eebbacca49794692c5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 14:12:53 -0400 Subject: [PATCH 23/31] feat: Check browser extension improvements - Added `detectionScript` parameter to `Add-CIPPW32ScriptApplication` function, allowing for PowerShell detection scripts that take priority over file detection. - Updated detection rules to prioritize detection scripts, followed by file detection and marker file fallback. - Modified `Push-UploadApplication` to include `detectionScript` in application properties. - Updated `Invoke-AddWin32ScriptApp` to handle `detectionScript` when creating Win32 applications. - Refactored `Invoke-CIPPStandardDeployCheckChromeExtension` to implement Win32 script app deployment for Chrome and Edge extensions, replacing legacy OMA-URI policies. - Introduced install, uninstall, and detection scripts for managing Chrome and Edge extension settings via registry keys. - Enhanced logging and alerting mechanisms for deployment status and errors. --- .../Public/Add-CIPPW32ScriptApplication.ps1 | 22 +- .../Applications/Push-UploadApplication.ps1 | 1 + .../Applications/Invoke-AddWin32ScriptApp.ps1 | 1 + ...CIPPStandardDeployCheckChromeExtension.ps1 | 539 +++++++++++------- 4 files changed, 340 insertions(+), 223 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 640cb120265a..dc9298da4356 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -17,7 +17,9 @@ function Add-CIPPW32ScriptApplication { - publisher: Publisher name - installScript (required): PowerShell install script content (plaintext) - uninstallScript: PowerShell uninstall script content (plaintext) - - detectionPath (required): Full path to the file or folder to detect (e.g., 'C:\\Program Files\\MyApp') + - detectionScript: PowerShell detection script content (plaintext). Takes priority over file detection. + Script should write output to STDOUT and exit 0 when app is detected (installed). + - detectionPath: Full path to the file or folder to detect (e.g., 'C:\\Program Files\\MyApp') - detectionFile: File name to detect (optional, for folder path detection) - detectionType: 'exists', 'modifiedDate', 'createdDate', 'version', 'sizeInMB' (default: 'exists') - check32BitOn64System: Boolean, check 32-bit registry/paths on 64-bit systems (default: false) @@ -69,9 +71,20 @@ function Add-CIPPW32ScriptApplication { $FileName = $ChocoXml.ApplicationInfo.FileName $UnencryptedSize = [int64]$ChocoXml.ApplicationInfo.UnencryptedContentSize - # Build detection rules - if ($Properties.detectionPath) { - # Determine if this is a file or folder detection + # Build detection rules — detection script takes priority, then file detection, then marker file fallback + if ($Properties.detectionScript) { + # PowerShell script detection: script should write to STDOUT and exit 0 when detected + $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) + $DetectionRules = @( + @{ + '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' + scriptContent = $DetectionScriptContent + enforceSignatureCheck = if ($null -ne $Properties.enforceSignatureCheck) { [bool]$Properties.enforceSignatureCheck } else { $false } + runAs32Bit = if ($null -ne $Properties.runAs32Bit) { [bool]$Properties.runAs32Bit } else { $false } + } + ) + } elseif ($Properties.detectionPath) { + # File system detection $DetectionRule = @{ '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' check32BitOn64System = if ($null -ne $Properties.check32BitOn64System) { [bool]$Properties.check32BitOn64System } else { $false } @@ -84,7 +97,6 @@ function Add-CIPPW32ScriptApplication { $DetectionRule['fileOrFolderName'] = $Properties.detectionFile } else { # Folder/File detection (full path) - # Split the path into directory and file/folder name $PathItem = Split-Path $Properties.detectionPath -Leaf $ParentPath = Split-Path $Properties.detectionPath -Parent diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 index b643716861a1..5db9617a82d8 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 @@ -139,6 +139,7 @@ function Push-UploadApplication { if ($AppConfig.description) { $Properties['description'] = $AppConfig.description } if ($AppConfig.publisher) { $Properties['publisher'] = $AppConfig.publisher } if ($AppConfig.uninstallScript) { $Properties['uninstallScript'] = $AppConfig.uninstallScript } + if ($AppConfig.detectionScript) { $Properties['detectionScript'] = $AppConfig.detectionScript } if ($AppConfig.detectionPath) { $Properties['detectionPath'] = $AppConfig.detectionPath } if ($AppConfig.detectionFile) { $Properties['detectionFile'] = $AppConfig.detectionFile } if ($AppConfig.runAsAccount) { $Properties['runAsAccount'] = $AppConfig.runAsAccount } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 index bdb797cbdd88..3ee3383f71df 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 @@ -49,6 +49,7 @@ function Invoke-AddWin32ScriptApp { uninstallScript = $Win32ScriptApp.uninstallScript detectionPath = $Win32ScriptApp.detectionPath detectionFile = $Win32ScriptApp.detectionFile + detectionScript = $Win32ScriptApp.detectionScript runAsAccount = if ($Win32ScriptApp.InstallAsSystem) { 'system' } else { 'user' } deviceRestartBehavior = if ($Win32ScriptApp.DisableRestart) { 'suppress' } else { 'allow' } runAs32Bit = [bool]$Win32ScriptApp.runAs32Bit diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 index 3e2c62816a4e..bb7a89a6b3e8 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { .SYNOPSIS (Label) Deploy Check Chrome Extension .DESCRIPTION - (Helptext) Deploys the Check Chrome extension via Intune OMA-URI custom policies for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg - (DocsDescription) Creates Intune OMA-URI custom policies that automatically install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. This ensures the extension is deployed consistently across all corporate devices with customizable settings. + (Helptext) Deploys the Check Chrome extension via a Win32 script app in Intune for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg + (DocsDescription) Creates an Intune Win32 script application that writes registry keys to install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. Uses a PowerShell detection script to enforce configuration drift — when settings change in CIPP the app is automatically redeployed. .NOTES CAT Intune Standards @@ -16,26 +16,32 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { EXECUTIVETEXT Automatically deploys the Check browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity. ADDEDCOMPONENT - {"type":"switch","name":"standards.DeployCheckChromeExtension.enableValidPageBadge","label":"Enable valid page badge","defaultValue":true} + {"type":"switch","name":"standards.DeployCheckChromeExtension.showNotifications","label":"Show notifications","defaultValue":true} + {"type":"switch","name":"standards.DeployCheckChromeExtension.enableValidPageBadge","label":"Enable valid page badge","defaultValue":false} {"type":"switch","name":"standards.DeployCheckChromeExtension.enablePageBlocking","label":"Enable page blocking","defaultValue":true} - {"type":"switch","name":"standards.DeployCheckChromeExtension.enableCippReporting","label":"Enable CIPP reporting","defaultValue":true} - {"type":"textField","name":"standards.DeployCheckChromeExtension.cippServerUrl","label":"CIPP Server URL","placeholder":"https://YOUR-CIPP-SERVER-URL","required":false} + {"type":"switch","name":"standards.DeployCheckChromeExtension.forceToolbarPin","label":"Force pin extension to toolbar","defaultValue":true} + {"type":"switch","name":"standards.DeployCheckChromeExtension.enableCippReporting","label":"Enable CIPP reporting","defaultValue":false} {"type":"textField","name":"standards.DeployCheckChromeExtension.customRulesUrl","label":"Custom Rules URL","placeholder":"https://YOUR-CIPP-SERVER-URL/rules.json","required":false} - {"type":"number","name":"standards.DeployCheckChromeExtension.updateInterval","label":"Update interval (hours)","defaultValue":12} + {"type":"number","name":"standards.DeployCheckChromeExtension.updateInterval","label":"Update interval (hours)","defaultValue":24} {"type":"switch","name":"standards.DeployCheckChromeExtension.enableDebugLogging","label":"Enable debug logging","defaultValue":false} + {"type":"switch","name":"standards.DeployCheckChromeExtension.enableGenericWebhook","label":"Enable generic webhook","defaultValue":false} + {"type":"textField","name":"standards.DeployCheckChromeExtension.webhookUrl","label":"Webhook URL","placeholder":"https://webhook.example.com/endpoint","required":false} + {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"label":"Webhook Events","name":"standards.DeployCheckChromeExtension.webhookEvents","placeholder":"e.g. pageBlocked, pageAllowed"} + {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"label":"URL Allowlist","name":"standards.DeployCheckChromeExtension.urlAllowlist","placeholder":"e.g. https://example.com/*"} {"type":"textField","name":"standards.DeployCheckChromeExtension.companyName","label":"Company Name","placeholder":"YOUR-COMPANY","required":false} + {"type":"textField","name":"standards.DeployCheckChromeExtension.companyURL","label":"Company URL","placeholder":"https://yourcompany.com","required":false} {"type":"textField","name":"standards.DeployCheckChromeExtension.productName","label":"Product Name","placeholder":"YOUR-PRODUCT-NAME","required":false} {"type":"textField","name":"standards.DeployCheckChromeExtension.supportEmail","label":"Support Email","placeholder":"support@yourcompany.com","required":false} - {"type":"textField","name":"standards.DeployCheckChromeExtension.primaryColor","label":"Primary Color","placeholder":"#0044CC","required":false} + {"type":"textField","name":"standards.DeployCheckChromeExtension.primaryColor","label":"Primary Color","placeholder":"#F77F00","required":false} {"type":"textField","name":"standards.DeployCheckChromeExtension.logoUrl","label":"Logo URL","placeholder":"https://yourcompany.com/logo.png","required":false} - {"name":"AssignTo","label":"Who should this policy be assigned to?","type":"radio","options":[{"label":"Do not assign","value":"On"},{"label":"Assign to all users","value":"allLicensedUsers"},{"label":"Assign to all devices","value":"AllDevices"},{"label":"Assign to all users and devices","value":"AllDevicesAndUsers"},{"label":"Assign to Custom Group","value":"customGroup"}]} + {"name":"AssignTo","label":"Who should this app be assigned to?","type":"radio","options":[{"label":"Do not assign","value":"On"},{"label":"Assign to all users","value":"allLicensedUsers"},{"label":"Assign to all devices","value":"AllDevices"},{"label":"Assign to all users and devices","value":"AllDevicesAndUsers"},{"label":"Assign to Custom Group","value":"customGroup"}]} {"type":"textField","required":false,"name":"customGroup","label":"Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed."} IMPACT Low Impact ADDEDDATE 2025-09-18 POWERSHELLEQUIVALENT - New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' + Add-CIPPW32ScriptApplication RECOMMENDEDBY "CIPP" UPDATECOMMENTBLOCK @@ -55,253 +61,350 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { return $true } - Write-Information "Running Deploy Check Chrome Extension standard for tenant $($Tenant)." + Write-Information "Running CyberDrain Check Browser Extension standard for tenant $($Tenant)." - # Chrome and Edge extension IDs for the Check extension + ########################################################################## + # Configuration values + ########################################################################## $ChromeExtensionId = 'benimdeioplgkhanklclahllklceahbe' $EdgeExtensionId = 'knepjpocdagponkonnbggpcnhnaikajg' - - # Policy names - $ChromePolicyName = 'Deploy Check Chrome Extension (Chrome)' - $EdgePolicyName = 'Deploy Check Chrome Extension (Edge)' + $AppDisplayName = 'CyberDrain - Check Browser Extension' # CIPP Url $CippConfigTable = Get-CippTable -tablename Config $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" $CIPPURL = 'https://{0}' -f $CippConfig.Value - # Get configuration values with defaults - $ShowNotifications = $Settings.showNotifications ?? $true - $EnableValidPageBadge = $Settings.enableValidPageBadge ?? $true - $EnablePageBlocking = $Settings.enablePageBlocking ?? $true - $EnableCippReporting = $Settings.enableCippReporting ?? $true + # Settings with defaults + $ShowNotifications = [int][bool]($Settings.showNotifications ?? $true) + $EnableValidPageBadge = [int][bool]($Settings.enableValidPageBadge ?? $false) + $EnablePageBlocking = [int][bool]($Settings.enablePageBlocking ?? $true) + $ForceToolbarPin = [int][bool]($Settings.forceToolbarPin ?? $true) + $EnableCippReporting = [int][bool]($Settings.enableCippReporting ?? $false) $CippServerUrl = $CIPPURL $CippTenantId = $Tenant - $CustomRulesUrl = $Settings.customRulesUrl - $UpdateInterval = $Settings.updateInterval ?? 24 - $EnableDebugLogging = $Settings.enableDebugLogging ?? $false - $CompanyName = $Settings.companyName - $ProductName = $Settings.productName - $SupportEmail = $Settings.supportEmail - $PrimaryColor = $Settings.primaryColor ?? '#F77F00' - $LogoUrl = $Settings.logoUrl - - # Create extension settings JSON - $ChromeExtensionSettings = @{ - $ChromeExtensionId = @{ - installation_mode = 'force_installed' - update_url = 'https://clients2.google.com/service/update2/crx' - settings = @{ - showNotifications = $ShowNotifications - enableValidPageBadge = $EnableValidPageBadge - enablePageBlocking = $EnablePageBlocking - enableCippReporting = $EnableCippReporting - cippServerUrl = $CippServerUrl - cippTenantId = $CippTenantId - customRulesUrl = $CustomRulesUrl - updateInterval = $UpdateInterval - enableDebugLogging = $EnableDebugLogging - customBranding = @{ - companyName = $CompanyName - productName = $ProductName - supportEmail = $SupportEmail - primaryColor = $PrimaryColor - logoUrl = $LogoUrl - } - } - } - } | ConvertTo-Json -Depth 10 - - $EdgeExtensionSettings = @{ - $EdgeExtensionId = @{ - installation_mode = 'force_installed' - update_url = 'https://edge.microsoft.com/extensionwebstorebase/v1/crx' - settings = @{ - showNotifications = $ShowNotifications - enableValidPageBadge = $EnableValidPageBadge - enablePageBlocking = $EnablePageBlocking - enableCippReporting = $EnableCippReporting - cippServerUrl = $CippServerUrl - cippTenantId = $CippTenantId - customRulesUrl = $CustomRulesUrl - updateInterval = $UpdateInterval - enableDebugLogging = $EnableDebugLogging - customBranding = @{ - companyName = $CompanyName - productName = $ProductName - supportEmail = $SupportEmail - primaryColor = $PrimaryColor - logoUrl = $LogoUrl - } - } - } - } | ConvertTo-Json -Depth 10 - - # Create Chrome OMA-URI policy JSON - $ChromePolicyJSON = @{ - '@odata.type' = '#microsoft.graph.windows10CustomConfiguration' - displayName = $ChromePolicyName - description = 'Deploys and configures the Check Chrome extension for Google Chrome browsers' - omaSettings = @( - @{ - '@odata.type' = '#microsoft.graph.omaSettingString' - displayName = 'Chrome Extension Settings' - description = 'Configure Check Chrome extension settings' - omaUri = './Device/Vendor/MSFT/Policy/Config/Chrome~Policy~googlechrome/ExtensionSettings' - value = $ChromeExtensionSettings - } - ) - } | ConvertTo-Json -Depth 20 - - # Create Edge OMA-URI policy JSON - $EdgePolicyJSON = @{ - '@odata.type' = '#microsoft.graph.windows10CustomConfiguration' - displayName = $EdgePolicyName - description = 'Deploys and configures the Check Chrome extension for Microsoft Edge browsers' - omaSettings = @( - @{ - '@odata.type' = '#microsoft.graph.omaSettingString' - displayName = 'Edge Extension Settings' - description = 'Configure Check Chrome extension settings' - omaUri = './Device/Vendor/MSFT/Policy/Config/Edge/ExtensionSettings' - value = $EdgeExtensionSettings - } - ) - } | ConvertTo-Json -Depth 20 - - # Compares OMA-URI settings between expected and existing policy. - # The 'value' field of omaSettingString is itself a JSON string, so we parse it - # before calling Compare-CIPPIntuneObject to avoid false positives from whitespace - # or key-ordering differences introduced by Intune's API response serialization. - function Compare-OMAURISettings { - param( - [Parameter(Mandatory = $true)]$ExpectedSettings, - [Parameter(Mandatory = $true)]$ExistingConfig - ) - $diffs = [System.Collections.Generic.List[PSCustomObject]]::new() - if ($null -eq $ExistingConfig -or $null -eq $ExistingConfig.omaSettings) { return $diffs } - - foreach ($expectedSetting in $ExpectedSettings) { - $existingSetting = $ExistingConfig.omaSettings | Where-Object { $_.omaUri -eq $expectedSetting.omaUri } - if (-not $existingSetting) { - $diffs.Add([PSCustomObject]@{ Property = $expectedSetting.omaUri; ExpectedValue = 'present'; ReceivedValue = 'missing' }) - continue - } - try { - $expectedValue = $expectedSetting.value | ConvertFrom-Json -Depth 20 - $existingValue = $existingSetting.value | ConvertFrom-Json -Depth 20 - $valueDiffs = Compare-CIPPIntuneObject -ReferenceObject $expectedValue -DifferenceObject $existingValue -CompareType 'Device' - foreach ($diff in $valueDiffs) { - $diffs.Add([PSCustomObject]@{ Property = "$($expectedSetting.omaUri).$($diff.Property)"; ExpectedValue = $diff.ExpectedValue; ReceivedValue = $diff.ReceivedValue }) - } - } catch { - # Fall back to string comparison if either value is not valid JSON - if ($expectedSetting.value -ne $existingSetting.value) { - $diffs.Add([PSCustomObject]@{ Property = $expectedSetting.omaUri; ExpectedValue = '[expected value]'; ReceivedValue = '[current value differs]' }) - } - } - } - return $diffs + $CustomRulesUrl = $Settings.customRulesUrl ?? '' + $UpdateInterval = [int]($Settings.updateInterval ?? 24) + $EnableDebugLogging = [int][bool]($Settings.enableDebugLogging ?? $false) + $EnableGenericWebhook = [int][bool]($Settings.enableGenericWebhook ?? $false) + $WebhookUrl = $Settings.webhookUrl ?? '' + $WebhookEvents = @($Settings.webhookEvents | ForEach-Object { $_.value ?? $_ } | Where-Object { $_ }) + $UrlAllowlist = @($Settings.urlAllowlist | ForEach-Object { $_.value ?? $_ } | Where-Object { $_ }) + $CompanyName = $Settings.companyName ?? '' + $CompanyURL = $Settings.companyURL ?? '' + $ProductName = $Settings.productName ?? '' + $SupportEmail = $Settings.supportEmail ?? '' + $PrimaryColor = if ($Settings.primaryColor) { $Settings.primaryColor } else { '#F77F00' } + $LogoUrl = $Settings.logoUrl ?? '' + + ########################################################################## + # Build the install script (writes registry keys - matches upstream Deploy-Windows-Chrome-and-Edge.ps1) + ########################################################################## + $InstallScript = @" +# Check Chrome Extension - Install Script (generated by CIPP) +`$chromeExtensionId = '$ChromeExtensionId' +`$edgeExtensionId = '$EdgeExtensionId' + +# Extension settings per browser +`$browsers = @( + @{ + ExtensionId = `$chromeExtensionId + UpdateUrl = 'https://clients2.google.com/service/update2/crx' + ManagedStorageKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\`$chromeExtensionId\policy" + ExtSettingsKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\`$chromeExtensionId" + ToolbarProp = 'toolbar_pin' + ToolbarPinned = 'force_pinned' + ToolbarUnpinned = 'default_unpinned' + }, + @{ + ExtensionId = `$edgeExtensionId + UpdateUrl = 'https://edge.microsoft.com/extensionwebstorebase/v1/crx' + ManagedStorageKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\`$edgeExtensionId\policy" + ExtSettingsKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\`$edgeExtensionId" + ToolbarProp = 'toolbar_state' + ToolbarPinned = 'force_shown' + ToolbarUnpinned = 'hidden' } +) - try { - # Fetch existing policies with full configuration details for OMA-URI drift detection - $ExistingChromePolicy = Get-CIPPIntunePolicy -TemplateType 'Device' -DisplayName $ChromePolicyName -tenantFilter $Tenant - $ExistingEdgePolicy = Get-CIPPIntunePolicy -TemplateType 'Device' -DisplayName $EdgePolicyName -tenantFilter $Tenant +foreach (`$b in `$browsers) { + # Managed storage - core settings + if (!(Test-Path `$b.ManagedStorageKey)) { New-Item -Path `$b.ManagedStorageKey -Force | Out-Null } + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'showNotifications' -PropertyType DWord -Value $ShowNotifications -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableValidPageBadge' -PropertyType DWord -Value $EnableValidPageBadge -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enablePageBlocking' -PropertyType DWord -Value $EnablePageBlocking -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableCippReporting' -PropertyType DWord -Value $EnableCippReporting -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'cippServerUrl' -PropertyType String -Value '$CippServerUrl' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'cippTenantId' -PropertyType String -Value '$CippTenantId' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'customRulesUrl' -PropertyType String -Value '$CustomRulesUrl' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'updateInterval' -PropertyType DWord -Value $UpdateInterval -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableDebugLogging' -PropertyType DWord -Value $EnableDebugLogging -Force | Out-Null + + # Managed storage - customBranding subkey + `$brandingKey = "`$(`$b.ManagedStorageKey)\customBranding" + if (!(Test-Path `$brandingKey)) { New-Item -Path `$brandingKey -Force | Out-Null } + New-ItemProperty -Path `$brandingKey -Name 'companyName' -PropertyType String -Value '$($CompanyName -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'companyURL' -PropertyType String -Value '$($CompanyURL -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'productName' -PropertyType String -Value '$($ProductName -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'supportEmail' -PropertyType String -Value '$($SupportEmail -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'primaryColor' -PropertyType String -Value '$PrimaryColor' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'logoUrl' -PropertyType String -Value '$($LogoUrl -replace "'", "''")' -Force | Out-Null + + # Managed storage - genericWebhook subkey + `$webhookKey = "`$(`$b.ManagedStorageKey)\genericWebhook" + if (!(Test-Path `$webhookKey)) { New-Item -Path `$webhookKey -Force | Out-Null } + New-ItemProperty -Path `$webhookKey -Name 'enabled' -PropertyType DWord -Value $EnableGenericWebhook -Force | Out-Null + New-ItemProperty -Path `$webhookKey -Name 'url' -PropertyType String -Value '$($WebhookUrl -replace "'", "''")' -Force | Out-Null + + # Managed storage - genericWebhook\events subkey + `$webhookEventsKey = "`$(`$b.ManagedStorageKey)\genericWebhook\events" + if (Test-Path `$webhookEventsKey) { Remove-Item -Path `$webhookEventsKey -Recurse -Force | Out-Null } +$(if ($WebhookEvents.Count -gt 0) { + " if (!(Test-Path `$webhookEventsKey)) { New-Item -Path `$webhookEventsKey -Force | Out-Null }`n" + $i = 1 + foreach ($evt in $WebhookEvents) { + " New-ItemProperty -Path `$webhookEventsKey -Name '$i' -PropertyType String -Value '$($evt -replace "'", "''")' -Force | Out-Null`n" + $i++ + } +}) + # Managed storage - urlAllowlist subkey + `$allowlistKey = "`$(`$b.ManagedStorageKey)\urlAllowlist" + if (Test-Path `$allowlistKey) { Remove-Item -Path `$allowlistKey -Recurse -Force | Out-Null } +$(if ($UrlAllowlist.Count -gt 0) { + " if (!(Test-Path `$allowlistKey)) { New-Item -Path `$allowlistKey -Force | Out-Null }`n" + $i = 1 + foreach ($url in $UrlAllowlist) { + " New-ItemProperty -Path `$allowlistKey -Name '$i' -PropertyType String -Value '$($url -replace "'", "''")' -Force | Out-Null`n" + $i++ + } +}) + # Extension settings (installation + toolbar) + if (!(Test-Path `$b.ExtSettingsKey)) { New-Item -Path `$b.ExtSettingsKey -Force | Out-Null } + New-ItemProperty -Path `$b.ExtSettingsKey -Name 'installation_mode' -PropertyType String -Value 'force_installed' -Force | Out-Null + New-ItemProperty -Path `$b.ExtSettingsKey -Name 'update_url' -PropertyType String -Value `$b.UpdateUrl -Force | Out-Null + if ($ForceToolbarPin -eq 1) { + New-ItemProperty -Path `$b.ExtSettingsKey -Name `$b.ToolbarProp -PropertyType String -Value `$b.ToolbarPinned -Force | Out-Null + } else { + New-ItemProperty -Path `$b.ExtSettingsKey -Name `$b.ToolbarProp -PropertyType String -Value `$b.ToolbarUnpinned -Force | Out-Null + } +} - $ChromePolicyExists = $null -ne $ExistingChromePolicy - $EdgePolicyExists = $null -ne $ExistingEdgePolicy +Write-Output 'Check Chrome Extension registry keys configured successfully.' +"@ - # Build expected OMA-URI settings from the generated policy JSON for comparison - $ExpectedChromeSettings = ($ChromePolicyJSON | ConvertFrom-Json).omaSettings - $ExpectedEdgeSettings = ($EdgePolicyJSON | ConvertFrom-Json).omaSettings + ########################################################################## + # Build the uninstall script (removes registry keys - matches upstream Remove-Windows-Chrome-and-Edge.ps1) + ########################################################################## + $UninstallScript = @" +# Check Chrome Extension - Uninstall Script (generated by CIPP) +`$chromeExtensionId = '$ChromeExtensionId' +`$edgeExtensionId = '$EdgeExtensionId' - # Detect configuration drift in existing policies - $ChromeDifferences = [System.Collections.Generic.List[PSCustomObject]]::new() - $EdgeDifferences = [System.Collections.Generic.List[PSCustomObject]]::new() +`$keysToRemove = @( + "HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\`$chromeExtensionId", + "HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\`$chromeExtensionId", + "HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\`$edgeExtensionId", + "HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\`$edgeExtensionId" +) - if ($ExistingChromePolicy) { - # omaSettingString values are encrypted by Intune; decrypt before comparing - $DecryptedChromePolicy = Get-CIPPOmaSettingDecryptedValue -DeviceConfiguration ($ExistingChromePolicy.cippconfiguration | ConvertFrom-Json) -DeviceConfigurationId $ExistingChromePolicy.id -TenantFilter $Tenant - $ChromeDifferences = Compare-OMAURISettings -ExpectedSettings $ExpectedChromeSettings -ExistingConfig $DecryptedChromePolicy - } +foreach (`$key in `$keysToRemove) { + if (Test-Path `$key) { + Remove-Item -Path `$key -Recurse -Force -ErrorAction SilentlyContinue + Write-Output "Removed: `$key" + } +} - if ($ExistingEdgePolicy) { - # omaSettingString values are encrypted by Intune; decrypt before comparing - $DecryptedEdgePolicy = Get-CIPPOmaSettingDecryptedValue -DeviceConfiguration ($ExistingEdgePolicy.cippconfiguration | ConvertFrom-Json) -DeviceConfigurationId $ExistingEdgePolicy.id -TenantFilter $Tenant - $EdgeDifferences = Compare-OMAURISettings -ExpectedSettings $ExpectedEdgeSettings -ExistingConfig $DecryptedEdgePolicy - } +Write-Output 'Check Chrome Extension registry keys removed.' +"@ - $ChromePolicyCompliant = $ChromePolicyExists -and ($ChromeDifferences.Count -eq 0) - $EdgePolicyCompliant = $EdgePolicyExists -and ($EdgeDifferences.Count -eq 0) + ########################################################################## + # Build the detection script + # Checks that critical DWORD values match expected config. When settings + # change in CIPP the detection script body changes, so Intune sees a new + # app version and redeploys automatically. + ########################################################################## + $DetectionScript = @" +# Check Chrome Extension - Detection Script (generated by CIPP) +`$chromeKey = 'HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\$ChromeExtensionId\policy' +`$edgeKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\$EdgeExtensionId\policy' + +# Verify both managed storage keys exist +if (!(Test-Path `$chromeKey) -or !(Test-Path `$edgeKey)) { exit 1 } + +# Helper to check a registry value matches expected +function Test-RegValue(`$Path, `$Name, `$Expected) { + `$val = (Get-ItemProperty -Path `$Path -Name `$Name -ErrorAction SilentlyContinue).`$Name + return (`$null -ne `$val -and `$val -eq `$Expected) +} + +foreach (`$key in @(`$chromeKey, `$edgeKey)) { + # Core DWORD settings + if (!(Test-RegValue `$key 'showNotifications' $ShowNotifications)) { exit 1 } + if (!(Test-RegValue `$key 'enableValidPageBadge' $EnableValidPageBadge)) { exit 1 } + if (!(Test-RegValue `$key 'enablePageBlocking' $EnablePageBlocking)) { exit 1 } + if (!(Test-RegValue `$key 'enableCippReporting' $EnableCippReporting)) { exit 1 } + if (!(Test-RegValue `$key 'updateInterval' $UpdateInterval)) { exit 1 } + if (!(Test-RegValue `$key 'enableDebugLogging' $EnableDebugLogging)) { exit 1 } + + # Core string settings + if (!(Test-RegValue `$key 'cippServerUrl' '$CippServerUrl')) { exit 1 } + if (!(Test-RegValue `$key 'cippTenantId' '$CippTenantId')) { exit 1 } + if (!(Test-RegValue `$key 'customRulesUrl' '$CustomRulesUrl')) { exit 1 } + + # customBranding subkey + `$brandingKey = "`$key\customBranding" + if (!(Test-Path `$brandingKey)) { exit 1 } + if (!(Test-RegValue `$brandingKey 'companyName' '$($CompanyName -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'companyURL' '$($CompanyURL -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'productName' '$($ProductName -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'supportEmail' '$($SupportEmail -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'primaryColor' '$PrimaryColor')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'logoUrl' '$($LogoUrl -replace "'", "''")')) { exit 1 } + + # genericWebhook subkey + `$webhookKey = "`$key\genericWebhook" + if (!(Test-Path `$webhookKey)) { exit 1 } + if (!(Test-RegValue `$webhookKey 'enabled' $EnableGenericWebhook)) { exit 1 } + if (!(Test-RegValue `$webhookKey 'url' '$($WebhookUrl -replace "'", "''")')) { exit 1 } + + # genericWebhook\events subkey — verify exact count and values + `$eventsKey = "`$key\genericWebhook\events" +$(if ($WebhookEvents.Count -gt 0) { + " if (!(Test-Path `$eventsKey)) { exit 1 }`n" + $i = 1 + foreach ($evt in $WebhookEvents) { + " if (!(Test-RegValue `$eventsKey '$i' '$($evt -replace "'", "''")')) { exit 1 }`n" + $i++ + } + " `$eventsCount = (Get-Item `$eventsKey).Property.Count`n" + " if (`$eventsCount -ne $($WebhookEvents.Count)) { exit 1 }`n" +} else { + " if (Test-Path `$eventsKey) {`n" + " `$eventsCount = (Get-Item `$eventsKey).Property.Count`n" + " if (`$eventsCount -gt 0) { exit 1 }`n" + " }`n" +}) + # urlAllowlist subkey — verify exact count and values + `$allowlistKey = "`$key\urlAllowlist" +$(if ($UrlAllowlist.Count -gt 0) { + " if (!(Test-Path `$allowlistKey)) { exit 1 }`n" + $i = 1 + foreach ($url in $UrlAllowlist) { + " if (!(Test-RegValue `$allowlistKey '$i' '$($url -replace "'", "''")')) { exit 1 }`n" + $i++ + } + " `$allowlistCount = (Get-Item `$allowlistKey).Property.Count`n" + " if (`$allowlistCount -ne $($UrlAllowlist.Count)) { exit 1 }`n" +} else { + " if (Test-Path `$allowlistKey) {`n" + " `$allowlistCount = (Get-Item `$allowlistKey).Property.Count`n" + " if (`$allowlistCount -gt 0) { exit 1 }`n" + " }`n" +}) +} + +# Verify extension settings keys exist +`$chromeExtSettings = 'HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\$ChromeExtensionId' +`$edgeExtSettings = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\$EdgeExtensionId' +if (!(Test-Path `$chromeExtSettings) -or !(Test-Path `$edgeExtSettings)) { exit 1 } + +Write-Output 'Check Chrome Extension is correctly configured.' +exit 0 +"@ + + ########################################################################## + # Legacy OMA-URI policy cleanup + ########################################################################## + $LegacyPolicyNames = @( + 'Deploy Check Chrome Extension (Chrome)', + 'Deploy Check Chrome Extension (Edge)' + ) + + try { + ########################################################################## + # Check for existing Win32 app + ########################################################################## + $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $ExistingApps = New-GraphGetRequest -Uri "$Baseuri`?`$filter=displayName eq '$AppDisplayName'&`$select=id,displayName" -tenantid $Tenant | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.win32LobApp' + } + $AppExists = ($null -ne $ExistingApps -and @($ExistingApps).Count -gt 0) if ($Settings.remediate -eq $true) { - # Handle assignment configuration $AssignTo = $Settings.AssignTo ?? 'AllDevices' - $ExcludeGroup = $Settings.ExcludeGroup + if ($Settings.customGroup) { $AssignTo = $Settings.customGroup } - # Handle custom group assignment - if ($Settings.customGroup) { - $AssignTo = $Settings.customGroup + # Clean up legacy OMA-URI configuration policies from the old approach + $LegacyPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$select=id,displayName' -tenantid $Tenant | Where-Object { + $_.displayName -in $LegacyPolicyNames + } + if ($LegacyPolicies) { + $DeleteRequests = @($LegacyPolicies | ForEach-Object { + @{ + id = "delete-$($_.id)" + method = 'DELETE' + url = "deviceManagement/deviceConfigurations/$($_.id)" + } + }) + $BulkResults = New-GraphBulkRequest -tenantid $Tenant -Requests $DeleteRequests + foreach ($Policy in $LegacyPolicies) { + $Result = $BulkResults | Where-Object { $_.id -eq "delete-$($Policy.id)" } + if ($Result.status -match '^2') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Removed legacy OMA-URI policy: $($Policy.displayName)" -sev Info + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to remove legacy OMA-URI policy: $($Policy.displayName) - $($Result.body.error.message)" -sev Warn + } + } } - # Deploy or remediate Chrome policy (create if missing, update if drifted) - if (-not $ChromePolicyExists) { - $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Google Chrome browsers' -DisplayName $ChromePolicyName -RawJSON $ChromePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully created Check Chrome Extension policy for Chrome: $ChromePolicyName" -sev Info - } elseif ($ChromeDifferences.Count -gt 0) { - $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Google Chrome browsers' -DisplayName $ChromePolicyName -RawJSON $ChromePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully corrected $($ChromeDifferences.Count) drifted OMA-URI setting(s) in Check Chrome Extension policy for Chrome" -sev Info - } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Chrome is compliant, no changes needed' -sev Info + if ($AppExists) { + # App exists — delete and recreate to pick up any script changes + foreach ($ExistingApp in @($ExistingApps)) { + $null = New-GraphPostRequest -Uri "$Baseuri/$($ExistingApp.id)" -Type DELETE -tenantid $Tenant + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Removed existing $AppDisplayName app to redeploy with updated settings" -sev Info + } + Start-Sleep -Seconds 2 } - # Deploy or remediate Edge policy (create if missing, update if drifted) - if (-not $EdgePolicyExists) { - $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Microsoft Edge browsers' -DisplayName $EdgePolicyName -RawJSON $EdgePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully created Check Chrome Extension policy for Edge: $EdgePolicyName" -sev Info - } elseif ($EdgeDifferences.Count -gt 0) { - $Result = Set-CIPPIntunePolicy -TemplateType 'Device' -Description 'Deploys and configures the Check Chrome extension for Microsoft Edge browsers' -DisplayName $EdgePolicyName -RawJSON $EdgePolicyJSON -AssignTo $AssignTo -ExcludeGroup $ExcludeGroup -tenantFilter $Tenant - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully corrected $($EdgeDifferences.Count) drifted OMA-URI setting(s) in Check Chrome Extension policy for Edge" -sev Info - } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policy for Edge is compliant, no changes needed' -sev Info + # Deploy the Win32 script app + $AppProperties = [PSCustomObject]@{ + displayName = $AppDisplayName + description = 'Deploys and configures the Check phishing protection extension for Chrome and Edge browsers. Managed by CIPP.' + publisher = 'CIPP' + installScript = $InstallScript + uninstallScript = $UninstallScript + detectionScript = $DetectionScript + runAsAccount = 'system' + deviceRestartBehavior = 'suppress' + } + + $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $Tenant -Properties $AppProperties + + if ($NewApp -and $AssignTo -ne 'On') { + Start-Sleep -Milliseconds 500 + Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -TenantFilter $Tenant -GroupName $AssignTo -Intent 'Required' -AppType 'Win32Lob' -APIName 'Standards' } + + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully deployed $AppDisplayName" -sev Info } if ($Settings.alert -eq $true) { - if ($ChromePolicyCompliant -and $EdgePolicyCompliant) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Check Chrome Extension policies are deployed and correctly configured for both Chrome and Edge' -sev Info + if ($AppExists) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "$AppDisplayName is deployed" -sev Info } else { - $Issues = [System.Collections.Generic.List[string]]::new() - if (-not $ChromePolicyExists) { - $Issues.Add('Chrome policy is missing') - } elseif ($ChromeDifferences.Count -gt 0) { - $Issues.Add("Chrome policy OMA-URI settings differ ($($ChromeDifferences.Count) difference(s))") - } - if (-not $EdgePolicyExists) { - $Issues.Add('Edge policy is missing') - } elseif ($EdgeDifferences.Count -gt 0) { - $Issues.Add("Edge policy OMA-URI settings differ ($($EdgeDifferences.Count) difference(s))") - } - Write-StandardsAlert -message "Check Chrome Extension issues: $($Issues -join '; ')" -object @{ Issues = ($Issues -join '; '); ChromeDifferences = $ChromeDifferences; EdgeDifferences = $EdgeDifferences } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Check Chrome Extension issues: $($Issues -join '; ')" -sev Alert + Write-StandardsAlert -message "$AppDisplayName is not deployed" -object @{ AppName = $AppDisplayName } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "$AppDisplayName is not deployed" -sev Alert } } if ($Settings.report -eq $true) { - $StateIsCorrect = $ChromePolicyCompliant -and $EdgePolicyCompliant + $StateIsCorrect = $AppExists $ExpectedValue = [PSCustomObject]@{ - ChromePolicyDeployed = $true - ChromePolicyCompliant = $true - EdgePolicyDeployed = $true - EdgePolicyCompliant = $true + AppDeployed = $true } $CurrentValue = [PSCustomObject]@{ - ChromePolicyDeployed = [bool]$ChromePolicyExists - ChromePolicyCompliant = [bool]$ChromePolicyCompliant - EdgePolicyDeployed = [bool]$EdgePolicyExists - EdgePolicyCompliant = [bool]$EdgePolicyCompliant + AppDeployed = [bool]$AppExists } Set-CIPPStandardsCompareField -FieldName 'standards.DeployCheckChromeExtension' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'DeployCheckChromeExtension' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant @@ -309,10 +412,10 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy Check Chrome Extension policies. Error: $ErrorMessage" -sev Error + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy $AppDisplayName. Error: $ErrorMessage" -sev Error if ($Settings.alert -eq $true) { - Write-StandardsAlert -message "Failed to deploy Check Chrome Extension policies: $ErrorMessage" -object @{ 'Error' = $ErrorMessage } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId + Write-StandardsAlert -message "Failed to deploy ${AppDisplayName}: $ErrorMessage" -object @{ 'Error' = $ErrorMessage } -tenant $Tenant -standardName 'DeployCheckChromeExtension' -standardId $Settings.standardId } if ($Settings.report -eq $true) { From a39e57fdb847654839f0b587248f7307823b1fb4 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 14:32:40 -0400 Subject: [PATCH 24/31] fix: update extension name --- ...Invoke-CIPPStandardDeployCheckChromeExtension.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 index bb7a89a6b3e8..b26cededae21 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployCheckChromeExtension.ps1 @@ -7,14 +7,14 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { .SYNOPSIS (Label) Deploy Check Chrome Extension .DESCRIPTION - (Helptext) Deploys the Check Chrome extension via a Win32 script app in Intune for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg - (DocsDescription) Creates an Intune Win32 script application that writes registry keys to install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. Uses a PowerShell detection script to enforce configuration drift — when settings change in CIPP the app is automatically redeployed. + (Helptext) Deploys the Check by CyberDrain extension via a Win32 script app in Intune for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg + (DocsDescription) Creates an Intune Win32 script application that writes registry keys to install and configure the Check by CyberDrain extension on managed devices for both Google Chrome and Microsoft Edge browsers. Uses a PowerShell detection script to enforce configuration drift — when settings change in CIPP the app is automatically redeployed. .NOTES CAT Intune Standards TAG EXECUTIVETEXT - Automatically deploys the Check browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity. + Automatically deploys the Check by CyberDrain extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity. ADDEDCOMPONENT {"type":"switch","name":"standards.DeployCheckChromeExtension.showNotifications","label":"Show notifications","defaultValue":true} {"type":"switch","name":"standards.DeployCheckChromeExtension.enableValidPageBadge","label":"Enable valid page badge","defaultValue":false} @@ -61,14 +61,14 @@ function Invoke-CIPPStandardDeployCheckChromeExtension { return $true } - Write-Information "Running CyberDrain Check Browser Extension standard for tenant $($Tenant)." + Write-Information "Running Check by CyberDrain standard for tenant $($Tenant)." ########################################################################## # Configuration values ########################################################################## $ChromeExtensionId = 'benimdeioplgkhanklclahllklceahbe' $EdgeExtensionId = 'knepjpocdagponkonnbggpcnhnaikajg' - $AppDisplayName = 'CyberDrain - Check Browser Extension' + $AppDisplayName = 'Check by CyberDrain - Browser Extension' # CIPP Url $CippConfigTable = Get-CippTable -tablename Config @@ -369,7 +369,7 @@ exit 0 # Deploy the Win32 script app $AppProperties = [PSCustomObject]@{ displayName = $AppDisplayName - description = 'Deploys and configures the Check phishing protection extension for Chrome and Edge browsers. Managed by CIPP.' + description = 'Deploys and configures the Check by CyberDrain phishing protection extension for Chrome and Edge browsers. Managed by CIPP.' publisher = 'CIPP' installScript = $InstallScript uninstallScript = $UninstallScript From 71468caf9cf057c8c9e983b342d1c28f3fd57607 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 17:37:36 -0400 Subject: [PATCH 25/31] fix: optimize role member retrieval in Invoke-ListRoles function --- .../Public/Entrypoints/Invoke-ListRoles.ps1 | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListRoles.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListRoles.ps1 index 5d4eff68a7bd..b56819cc9b16 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListRoles.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListRoles.ps1 @@ -11,10 +11,25 @@ function Invoke-ListRoles { $TenantFilter = $Request.Query.tenantFilter try { - [System.Collections.Generic.List[PSCustomObject]]$Roles = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/directoryRoles?`$expand=members" -tenantid $TenantFilter + [System.Collections.Generic.List[PSCustomObject]]$Roles = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/directoryRoles' -tenantid $TenantFilter + + $MemberRequests = $Roles | ForEach-Object { + @{ + id = $_.id + method = 'GET' + url = "/directoryRoles/$($_.id)/members" + } + } + $MemberResponses = New-GraphBulkRequest -Requests $MemberRequests -tenantid $TenantFilter -Version 'v1.0' + + $MemberMap = @{} + foreach ($Response in $MemberResponses) { + $MemberMap[$Response.id] = $Response.body.value + } + $GraphRequest = foreach ($Role in $Roles) { - $Members = if ($Role.members) { - $Role.members | ForEach-Object { [PSCustomObject]@{ + $Members = if ($MemberMap[$Role.id]) { + $MemberMap[$Role.id] | ForEach-Object { [PSCustomObject]@{ displayName = $_.displayName userPrincipalName = $_.userPrincipalName id = $_.id From ab9cb1dd4b25737a9a8a6d0f964e428e3a278735 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:21:37 +0800 Subject: [PATCH 26/31] Add litigation/retention mailbox fields Expand mailbox properties cached by Set-CIPPDBCacheMailboxes to include litigation hold, retention, and compliance-related attributes. The Get-Mailbox select list and Select-Object output were updated to include PersistedCapabilities, LitigationHoldEnabled/Date/Duration, a computed LicensedForLitigationHold (from PersistedCapabilities), ComplianceTagHoldApplied, RetentionHoldEnabled, InPlaceHolds, and RetentionPolicy Fixes issues where you were no longer able to set these actions due to missing values: Enable Auto-Expanding Archive Set Litigation Hold Enable Online Archive --- Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 index c6b5579b6390..f27f08e710ee 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 @@ -26,7 +26,7 @@ function Set-CIPPDBCacheMailboxes { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching mailboxes' -sev Debug # Get mailboxes with select properties - $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo' + $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy' $ExoRequest = @{ tenantid = $TenantFilter cmdlet = 'Get-Mailbox' @@ -52,6 +52,14 @@ function Set-CIPPDBCacheMailboxes { ExternalDirectoryObjectId, MessageCopyForSendOnBehalfEnabled, MessageCopyForSentAsEnabled, + LitigationHoldEnabled, + LitigationHoldDate, + LitigationHoldDuration, + @{ Name = 'LicensedForLitigationHold'; Expression = { ($_.PersistedCapabilities -contains 'EXCHANGE_S_ARCHIVE_ADDON' -or $_.PersistedCapabilities -contains 'BPOS_S_ArchiveAddOn' -or $_.PersistedCapabilities -contains 'EXCHANGE_S_ENTERPRISE' -or $_.PersistedCapabilities -contains 'BPOS_S_DlpAddOn' -or $_.PersistedCapabilities -contains 'BPOS_S_Enterprise') } }, + ComplianceTagHoldApplied, + RetentionHoldEnabled, + InPlaceHolds, + RetentionPolicy, GrantSendOnBehalfTo)) } From 452b987e20d67415bd4082f9401fff5a1655ff4d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:52:38 +0800 Subject: [PATCH 27/31] Add webhook auth and tenant filter support Propagate TenantFilter from HTTP entrypoint into test alerts and include it when sending alerts. Add webhook authentication support (bearer, basic, apikey, custom headers) in Send-CIPPAlert, including secret retrieval from Key Vault or a DevSecrets table and parsing custom header JSON. Refactor webhook request building to use a unified RestMethod/RequestHeaders hashtable and include CFZTNA headers into RequestHeaders. Extend Set-CIPPNotificationConfig to accept webhook auth fields, persist secrets to Key Vault/DevSecrets and mark stored secrets as 'SentToKeyVault'. Update Invoke-ListNotificationConfig to exclude the new webhook auth fields from logsToInclude. --- .../CIPP/Core/Invoke-ExecAddAlert.ps1 | 20 +-- .../Invoke-ExecNotificationConfig.ps1 | 7 ++ .../Invoke-ListNotificationConfig.ps1 | 2 +- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 118 +++++++++++++++--- .../Public/Set-CIPPNotificationConfig.ps1 | 58 +++++++++ 5 files changed, 168 insertions(+), 37 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAddAlert.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAddAlert.ps1 index 10d24e146044..8b5fb5fb8535 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAddAlert.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAddAlert.ps1 @@ -8,32 +8,18 @@ function Invoke-ExecAddAlert { [CmdletBinding()] param($Request, $TriggerMetadata) $Headers = $Request.Headers - + $TenantFilter = $Request.Body.tenantFilter ?? $env:TenantID $Severity = 'Alert' $Result = if ($Request.Body.sendEmailNow -or $Request.Body.sendWebhookNow -eq $true -or $Request.Body.writeLog -eq $true -or $Request.Body.sendPsaNow -eq $true) { - $sev = ([pscustomobject]$Request.body.Severity).value -join (',') - if ($Request.body.email -or $Request.body.webhook) { - Write-Host 'found config, setting' - $config = @{ - email = $Request.body.email - webhook = $Request.body.webhook - onepertenant = $Request.body.onePerTenant - logsToInclude = $Request.body.logsToInclude - sendtoIntegration = $true - sev = $sev - } - Write-Host "setting notification config to $($config | ConvertTo-Json)" - $Results = Set-cippNotificationConfig @Config - Write-Host $Results - } $Title = 'CIPP Notification Test' if ($Request.Body.sendEmailNow -eq $true) { $CIPPAlert = @{ Type = 'email' Title = $Title HTMLContent = $Request.Body.text + TenantFilter = $TenantFilter } Send-CIPPAlert @CIPPAlert } @@ -46,6 +32,7 @@ function Invoke-ExecAddAlert { Type = 'webhook' Title = $Title JSONContent = $JSONContent + TenantFilter = $TenantFilter } Send-CIPPAlert @CIPPAlert } @@ -54,6 +41,7 @@ function Invoke-ExecAddAlert { Type = 'psa' Title = $Title HTMLContent = $Request.Body.text + TenantFilter = $TenantFilter } Send-CIPPAlert @CIPPAlert } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 index 7b23c445b724..46e3341eb2c6 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecNotificationConfig.ps1 @@ -11,6 +11,13 @@ Function Invoke-ExecNotificationConfig { $config = @{ email = $Request.body.email webhook = $Request.body.webhook + webhookAuthType = $Request.body.webhookAuthType.value + webhookAuthToken = $Request.body.webhookAuthToken + webhookAuthUsername = $Request.body.webhookAuthUsername + webhookAuthPassword = $Request.body.webhookAuthPassword + webhookAuthHeaderName = $Request.body.webhookAuthHeaderName + webhookAuthHeaderValue = $Request.body.webhookAuthHeaderValue + webhookAuthHeaders = $Request.body.webhookAuthHeaders onepertenant = $Request.body.onePerTenant logsToInclude = $Request.body.logsToInclude sendtoIntegration = $Request.body.sendtoIntegration diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 index f24a9d06740d..a1312b27ad66 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListNotificationConfig.ps1 @@ -16,7 +16,7 @@ Function Invoke-ListNotificationConfig { $Config = @{} } #$config | Add-Member -NotePropertyValue @() -NotePropertyName 'logsToInclude' -Force - $config.logsToInclude = @(([pscustomobject]$config | Select-Object * -ExcludeProperty schedule, type, tenantid, onepertenant, sendtoIntegration, partitionkey, rowkey, tenant, ETag, email, logsToInclude, Severity, Alert, Info, Error, timestamp, webhook, includeTenantId, UseStandardizedSchema).psobject.properties.name) + $config.logsToInclude = @(([pscustomobject]$config | Select-Object * -ExcludeProperty schedule, type, tenantid, onepertenant, sendtoIntegration, partitionkey, rowkey, tenant, ETag, email, logsToInclude, Severity, Alert, Info, Error, timestamp, webhook, includeTenantId, UseStandardizedSchema, webhookAuthType, webhookAuthToken, webhookAuthUsername, webhookAuthPassword, webhookAuthHeaderName, webhookAuthHeaderValue, webhookAuthHeaders).psobject.properties.name) if (!$config.logsToInclude) { $config.logsToInclude = @('None') } diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index 829793d58e74..57b498617372 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -104,6 +104,79 @@ function Send-CIPPAlert { if ($Type -eq 'webhook') { Write-Information 'Trying to send webhook' + $GetWebhookSecret = { + param( + [string]$SecretName + ) + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + return (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$SecretName' and RowKey eq '$SecretName'").APIKey + } + + $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + return (Get-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -AsPlainText) + } + + $RequestHeaders = @{} + if ($Headers -is [hashtable]) { + foreach ($HeaderName in $Headers.Keys) { + $RequestHeaders[$HeaderName] = $Headers[$HeaderName] + } + } + + $WebhookAuthType = [string]$Config.webhookAuthType + switch ($WebhookAuthType.ToLowerInvariant()) { + 'bearer' { + $WebhookAuthToken = [string]$Config.webhookAuthToken + if ($WebhookAuthToken -eq 'SentToKeyVault') { + $WebhookAuthToken = & $GetWebhookSecret -SecretName 'CIPPNotificationsWebhookAuthToken' + } + if (![string]::IsNullOrWhiteSpace($WebhookAuthToken)) { + $RequestHeaders['Authorization'] = "Bearer $WebhookAuthToken" + } + } + 'basic' { + $WebhookAuthPassword = [string]$Config.webhookAuthPassword + if ($WebhookAuthPassword -eq 'SentToKeyVault') { + $WebhookAuthPassword = & $GetWebhookSecret -SecretName 'CIPPNotificationsWebhookAuthPassword' + } + if (![string]::IsNullOrWhiteSpace($Config.webhookAuthUsername) -and ![string]::IsNullOrWhiteSpace($WebhookAuthPassword)) { + $BasicAuthBytes = [System.Text.Encoding]::UTF8.GetBytes("$($Config.webhookAuthUsername):$WebhookAuthPassword") + $RequestHeaders['Authorization'] = 'Basic {0}' -f [System.Convert]::ToBase64String($BasicAuthBytes) + } + } + 'apikey' { + $WebhookAuthHeaderValue = [string]$Config.webhookAuthHeaderValue + if ($WebhookAuthHeaderValue -eq 'SentToKeyVault') { + $WebhookAuthHeaderValue = & $GetWebhookSecret -SecretName 'CIPPNotificationsWebhookAuthHeaderValue' + } + if (![string]::IsNullOrWhiteSpace($Config.webhookAuthHeaderName) -and ![string]::IsNullOrWhiteSpace($WebhookAuthHeaderValue)) { + $RequestHeaders[$Config.webhookAuthHeaderName] = $WebhookAuthHeaderValue + } + } + 'customheaders' { + $WebhookAuthHeaders = [string]$Config.webhookAuthHeaders + if ($WebhookAuthHeaders -eq 'SentToKeyVault') { + $WebhookAuthHeaders = & $GetWebhookSecret -SecretName 'CIPPNotificationsWebhookAuthHeaders' + } + if (![string]::IsNullOrWhiteSpace($WebhookAuthHeaders)) { + try { + $CustomHeaders = $WebhookAuthHeaders | ConvertFrom-Json -AsHashtable + if ($CustomHeaders -is [hashtable]) { + foreach ($HeaderName in $CustomHeaders.Keys) { + if (![string]::IsNullOrWhiteSpace([string]$HeaderName)) { + $RequestHeaders[[string]$HeaderName] = [string]$CustomHeaders[$HeaderName] + } + } + } + } catch { + Write-LogMessage -API 'Webhook Alerts' -message 'Webhook custom headers JSON is invalid. Continuing without custom auth headers.' -tenant $TenantFilter -sev warning + } + } + } + } + $ExtensionTable = Get-CIPPTable -TableName Extensionsconfig $ExtensionConfig = Get-CIPPAzDataTableEntity @ExtensionTable # Check if config exists and is not null before parsing @@ -115,10 +188,9 @@ function Send-CIPPAlert { if ($Configuration.CFZTNA.WebhookEnabled -eq $true -and $Configuration.CFZTNA.Enabled -eq $true) { $CFAPIKey = Get-ExtensionAPIKey -Extension 'CFZTNA' - $Headers = @{'CF-Access-Client-Id' = $Configuration.CFZTNA.ClientId; 'CF-Access-Client-Secret' = "$CFAPIKey" } + $RequestHeaders['CF-Access-Client-Id'] = $Configuration.CFZTNA.ClientId + $RequestHeaders['CF-Access-Client-Secret'] = "$CFAPIKey" Write-Information 'CF-Access-Client-Id and CF-Access-Client-Secret headers added to webhook API request' - } else { - $Headers = $null } $UseStandardizedWebhookSchema = [boolean]$Config.UseStandardizedSchema @@ -155,30 +227,45 @@ function Send-CIPPAlert { if (![string]::IsNullOrWhiteSpace($Config.webhook) -or ![string]::IsNullOrWhiteSpace($AltWebhook)) { $webhook = if ($AltWebhook) { $AltWebhook } else { $Config.webhook } if ($PSCmdlet.ShouldProcess($webhook, 'Sending webhook')) { + $RestMethod = @{ + Uri = $webhook + Method = 'POST' + ContentType = 'application/json' + StatusCodeVariable = 'WebhookStatusCode' + SkipHttpErrorCheck = $true + } + if ($RequestHeaders.Count -gt 0) { + $RestMethod['Headers'] = $RequestHeaders + } switch -wildcard ($webhook) { '*webhook.office.com*' { if ($UseStandardizedWebhookSchema) { - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $ReplacedContent + $WebhookResponse = Invoke-RestMethod @RestMethod } else { $TeamsBody = [PSCustomObject]@{ text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$ReplacedContent" } | ConvertTo-Json -Compress - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $TeamsBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $TeamsBody + $WebhookResponse = Invoke-RestMethod @RestMethod } } '*discord.com*' { if ($UseStandardizedWebhookSchema) { - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $ReplacedContent + $WebhookResponse = Invoke-RestMethod @RestMethod } else { $DiscordBody = [PSCustomObject]@{ content = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" } | ConvertTo-Json -Compress - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $DiscordBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $DiscordBody + $WebhookResponse = Invoke-RestMethod @RestMethod } } '*slack.com*' { if ($UseStandardizedWebhookSchema) { - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $ReplacedContent -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $ReplacedContent + $WebhookResponse = Invoke-RestMethod @RestMethod } else { $SlackBlocks = Get-SlackAlertBlocks -JSONBody $JSONContent if ($SlackBlocks.blocks) { @@ -188,21 +275,12 @@ function Send-CIPPAlert { text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" } | ConvertTo-Json -Compress } - $WebhookResponse = Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $SlackBody -StatusCodeVariable WebhookStatusCode -SkipHttpErrorCheck + $RestMethod['Body'] = $SlackBody + $WebhookResponse = Invoke-RestMethod @RestMethod } } default { - $RestMethod = @{ - Uri = $webhook - Method = 'POST' - ContentType = 'application/json' - Body = $ReplacedContent - StatusCodeVariable = 'WebhookStatusCode' - SkipHttpErrorCheck = $true - } - if ($Headers) { - $RestMethod['Headers'] = $Headers - } + $RestMethod['Body'] = $ReplacedContent $WebhookResponse = Invoke-RestMethod @RestMethod } } diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 74e17dd81b19..3679e4a89c06 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -3,6 +3,13 @@ function Set-CIPPNotificationConfig { param ( $email, $webhook, + $webhookAuthType, + $webhookAuthToken, + $webhookAuthUsername, + $webhookAuthPassword, + $webhookAuthHeaderName, + $webhookAuthHeaderValue, + $webhookAuthHeaders, $onepertenant, $logsToInclude, $sendtoIntegration, @@ -13,6 +20,50 @@ function Set-CIPPNotificationConfig { try { $Table = Get-CIPPTable -TableName SchedulerConfig + $ExistingConfig = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CippNotifications' and RowKey eq 'CippNotifications'" + + $StoreWebhookSecret = { + param( + [string]$SecretName, + [string]$SecretValue + ) + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = [PSCustomObject]@{ + 'PartitionKey' = $SecretName + 'RowKey' = $SecretName + 'APIKey' = $SecretValue + } + Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null + } else { + $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + Set-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -SecretValue (ConvertTo-SecureString -AsPlainText -Force -String $SecretValue) | Out-Null + } + } + + $WebhookSecretMap = @( + @{ Field = 'webhookAuthToken'; SecretName = 'CIPPNotificationsWebhookAuthToken'; Value = [string]$webhookAuthToken } + @{ Field = 'webhookAuthPassword'; SecretName = 'CIPPNotificationsWebhookAuthPassword'; Value = [string]$webhookAuthPassword } + @{ Field = 'webhookAuthHeaderValue'; SecretName = 'CIPPNotificationsWebhookAuthHeaderValue'; Value = [string]$webhookAuthHeaderValue } + @{ Field = 'webhookAuthHeaders'; SecretName = 'CIPPNotificationsWebhookAuthHeaders'; Value = [string]$webhookAuthHeaders } + ) + + $WebhookSecretMarkers = @{} + foreach ($SecretInfo in $WebhookSecretMap) { + $IncomingValue = [string]$SecretInfo.Value + $ExistingValue = [string]$ExistingConfig.($SecretInfo.Field) + + if (![string]::IsNullOrWhiteSpace($IncomingValue) -and $IncomingValue -ne 'SentToKeyVault') { + & $StoreWebhookSecret -SecretName $SecretInfo.SecretName -SecretValue $IncomingValue + $WebhookSecretMarkers[$SecretInfo.Field] = 'SentToKeyVault' + } elseif ($IncomingValue -eq 'SentToKeyVault' -or $ExistingValue -eq 'SentToKeyVault') { + $WebhookSecretMarkers[$SecretInfo.Field] = 'SentToKeyVault' + } else { + $WebhookSecretMarkers[$SecretInfo.Field] = '' + } + } + $SchedulerConfig = @{ 'tenant' = 'Any' 'tenantid' = 'TenantId' @@ -21,6 +72,13 @@ function Set-CIPPNotificationConfig { 'Severity' = [string]$sev 'email' = "$($email)" 'webhook' = "$($webhook)" + 'webhookAuthType' = "$($webhookAuthType)" + 'webhookAuthToken' = "$($WebhookSecretMarkers.webhookAuthToken)" + 'webhookAuthUsername' = "$($webhookAuthUsername)" + 'webhookAuthPassword' = "$($WebhookSecretMarkers.webhookAuthPassword)" + 'webhookAuthHeaderName' = "$($webhookAuthHeaderName)" + 'webhookAuthHeaderValue' = "$($WebhookSecretMarkers.webhookAuthHeaderValue)" + 'webhookAuthHeaders' = "$($WebhookSecretMarkers.webhookAuthHeaders)" 'onePerTenant' = [boolean]$onePerTenant 'sendtoIntegration' = [boolean]$sendtoIntegration 'UseStandardizedSchema' = [boolean]$UseStandardizedSchema From 083040d1295bf67c3b9eb9c991949a7e425fdebd Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:20:46 +0800 Subject: [PATCH 28/31] Add username space handling to user defaults --- .../Users/Invoke-AddUserDefaults.ps1 | 68 +++++++++++-------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 63e6e2b50cfc..9d40efb19374 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -31,6 +31,14 @@ function Invoke-AddUserDefaults { $Request.Body.usernameFormat.value } + $UsernameSpaceHandling = if ($Request.Body.usernameSpaceHandling -is [string]) { + $Request.Body.usernameSpaceHandling + } else { + $Request.Body.usernameSpaceHandling.value + } + + $UsernameSpaceReplacement = $Request.Body.usernameSpaceReplacement + $PrimDomain = if ($Request.Body.primDomain -is [string]) { $Request.Body.primDomain } else { @@ -75,35 +83,37 @@ function Invoke-AddUserDefaults { # Create template object with all fields from CippAddEditUser $TemplateObject = @{ - tenantFilter = $TenantFilter - templateName = $TemplateName - defaultForTenant = [bool]$DefaultForTenant - givenName = $GivenName - surname = $Surname - displayName = $DisplayName - usernameFormat = $UsernameFormat - primDomain = $PrimDomain - addedAliases = $AddedAliases - Autopassword = $Autopassword - password = $Password - MustChangePass = $MustChangePass - usageLocation = $UsageLocation - licenses = $Licenses - removeLicenses = $RemoveLicenses - jobTitle = $JobTitle - streetAddress = $StreetAddress - city = $City - state = $State - postalCode = $PostalCode - country = $Country - companyName = $CompanyName - department = $Department - mobilePhone = $MobilePhone - businessPhones = $BusinessPhones - otherMails = $OtherMails - setManager = $SetManager - setSponsor = $SetSponsor - copyFrom = $CopyFrom + tenantFilter = $TenantFilter + templateName = $TemplateName + defaultForTenant = [bool]$DefaultForTenant + givenName = $GivenName + surname = $Surname + displayName = $DisplayName + usernameFormat = $UsernameFormat + usernameSpaceHandling = $UsernameSpaceHandling + usernameSpaceReplacement = $UsernameSpaceReplacement + primDomain = $PrimDomain + addedAliases = $AddedAliases + Autopassword = $Autopassword + password = $Password + MustChangePass = $MustChangePass + usageLocation = $UsageLocation + licenses = $Licenses + removeLicenses = $RemoveLicenses + jobTitle = $JobTitle + streetAddress = $StreetAddress + city = $City + state = $State + postalCode = $PostalCode + country = $Country + companyName = $CompanyName + department = $Department + mobilePhone = $MobilePhone + businessPhones = $BusinessPhones + otherMails = $OtherMails + setManager = $SetManager + setSponsor = $SetSponsor + copyFrom = $CopyFrom } # Use existing GUID if editing, otherwise generate new one From 15fdda24fbc38691ef035c6692dbf9f7ce909f2f Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 13:50:31 +0100 Subject: [PATCH 29/31] String lists and arrays can be dumb --- .../CIPPCore/Private/ConvertTo-StringList.ps1 | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 Modules/CIPPCore/Private/ConvertTo-StringList.ps1 diff --git a/Modules/CIPPCore/Private/ConvertTo-StringList.ps1 b/Modules/CIPPCore/Private/ConvertTo-StringList.ps1 new file mode 100644 index 000000000000..2e680b4d604c --- /dev/null +++ b/Modules/CIPPCore/Private/ConvertTo-StringList.ps1 @@ -0,0 +1,109 @@ +function ConvertTo-StringList { + <# + .SYNOPSIS + Turns encoded list data into something you can foreach over. + + .DESCRIPTION + String input: if it is JSON (object or array), it is converted first; otherwise comma/semicolon/newline + splitting applies. Other shapes: wrapper objects, or an existing array/list. After conversion, the return + value is always foreach-able (empty collection is @()). Arrays and IList instances are returned + as-is — they are already foreach-able without conversion. + This exists because front end multi-value input is annoying to deal with. + + .PARAMETER InputObject + Encoded list (string/JSON), wrapper object, or an existing array/list. + + .PARAMETER PropertyNames + On hashtables/PSCustomObjects, property names to read in order. + + .OUTPUTS + Always an enumerable suitable for: foreach ($item in (ConvertTo-StringList ...)) { } + #> + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [Alias('Input', 'Value')] + [AllowNull()] + $InputObject, + + [string[]]$PropertyNames = @('Items', 'Value') + ) + + # Output must be foreach-able; $null input yields empty collection. + if ($null -eq $InputObject) { + return @() + } + + if ($InputObject -is [string]) { + $s = $InputObject.Trim() + if (-not $s) { + return @() + } + if ($s.StartsWith('[') -or $s.StartsWith('{')) { + try { + $parsed = $s | ConvertFrom-Json -ErrorAction Stop + return ConvertTo-StringList -InputObject $parsed -PropertyNames $PropertyNames + } catch { + } + } + return @( + $s -split '[,;\r\n]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + ) + } + + if ($InputObject -is [Array]) { + return $InputObject + } + if ($InputObject -is [System.Collections.IList] -and $InputObject -isnot [string]) { + return $InputObject + } + + if ($InputObject -is [hashtable]) { + if ($InputObject.Count -eq 0) { + return @() + } + foreach ($name in $PropertyNames) { + if ($InputObject.ContainsKey($name)) { + return ConvertTo-StringList -InputObject $InputObject[$name] -PropertyNames $PropertyNames + } + } + foreach ($p in $InputObject.GetEnumerator() | Sort-Object { $_.Key }) { + $v = $p.Value + if ($null -eq $v) { continue } + if ($v -is [string] -or ($v -is [System.Collections.IEnumerable] -and $v -isnot [hashtable] -and $v -isnot [pscustomobject])) { + return ConvertTo-StringList -InputObject $v -PropertyNames @() + } + } + $single = "$InputObject".Trim() + if ($single) { + return , @($single) + } + return @() + } + + if ($InputObject -is [pscustomobject]) { + foreach ($name in $PropertyNames) { + if ($InputObject.PSObject.Properties.Name -contains $name) { + return ConvertTo-StringList -InputObject $InputObject.$name -PropertyNames $PropertyNames + } + } + foreach ($p in $InputObject.PSObject.Properties) { + $v = $p.Value + if ($null -eq $v) { continue } + if ($v -is [string] -or ($v -is [System.Collections.IEnumerable] -and $v -isnot [hashtable] -and $v -isnot [pscustomobject])) { + return ConvertTo-StringList -InputObject $v -PropertyNames @() + } + } + $single = "$InputObject".Trim() + if ($single) { + return , @($single) + } + return @() + } + + $t = "$InputObject".Trim() + if ($t) { + return , @($t) + } + return @() +} From 4a08c8b934c61e4a2196a11d47c861ef9ab2d2a6 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 14:13:27 +0100 Subject: [PATCH 30/31] Get-CIPPAlertLongLivedAppCredentials backend --- .../Get-CIPPAlertLongLivedAppCredentials.ps1 | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 new file mode 100644 index 000000000000..ab3a5bfb9302 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 @@ -0,0 +1,58 @@ +function Get-CIPPAlertLongLivedAppCredentials { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $MaxMonths = $InputValue + + try { + $Apps = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications?`$select=id,appId,displayName,passwordCredentials,keyCredentials&`$top=999" -tenantid $TenantFilter -AsApp $true) + + $NowUtc = [datetime]::UtcNow + $DatePartition = (Get-Date -UFormat '%Y%m%d').ToString() + $CredTypeMap = @( + @{ Property = 'passwordCredentials'; TypeLabel = 'Secret' } + @{ Property = 'keyCredentials'; TypeLabel = 'Certificate' } + ) + + foreach ($App in @($Apps)) { + foreach ($ct in $CredTypeMap) { + $credList = $App.($ct.Property) + if (-not $credList) { continue } + foreach ($Cred in @($credList)) { + $startUtc = ([datetime]$Cred.startDateTime).ToUniversalTime() + $endUtc = ([datetime]$Cred.endDateTime).ToUniversalTime() + if ($endUtc -le $startUtc -or $endUtc -le $NowUtc) { continue } + $months = (New-TimeSpan -Start $startUtc -End $endUtc).TotalDays / 30.4375 + if ($months -gt $MaxMonths) { + $keyId = if ($Cred.keyId) { "$($Cred.keyId)" } else { 'unknown' } + $tracePartition = "$DatePartition-$($App.id)-$keyId" -replace '[/\\#?]', '_' + $oneFinding = [PSCustomObject]@{ + AppDisplayName = $App.displayName + AppId = $App.appId + CredentialType = $ct.TypeLabel + CredentialName = $Cred.displayName + KeyId = $Cred.keyId + StartDateTime = $Cred.startDateTime + EndDateTime = $Cred.endDateTime + ValidityMonths = [math]::Round([double]$months, 2) + MaxMonthsAllowed = $MaxMonths + } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $oneFinding -PartitionKey $tracePartition + } + } + } + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Excessive secret validity alert failed: $ErrorMessage" -sev 'Error' + } +} From 056f9157b9c4378d375f1ae899c45ae8f41fd20d Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 14:41:10 +0100 Subject: [PATCH 31/31] Get-CIPPAlertRoleEscalableGroups --- .../Get-CIPPAlertRoleEscalableGroups.ps1 | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 new file mode 100644 index 000000000000..d9afcce955eb --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 @@ -0,0 +1,129 @@ +function Get-CIPPAlertRoleEscalableGroups { + <# + .SYNOPSIS + Flags non-role-assignable groups in Entra directory role paths. + + .DESCRIPTION + Scans Entra directory role assignments where the role principal is a group. Flags: + 1) direct role-assigned groups that are not marked isAssignableToRole, and + 2) non-role-assignable nested groups found via transitive group membership under a role-assigned group. + Findings include path type, role-assigned group details, impacted group details, and human-readable role name. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + $groups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName,isAssignableToRole&`$top=999" -tenantid $TenantFilter) + if (-not $groups -or $groups.Count -eq 0) { + Write-Information "Get-CIPPAlertRoleEscalableGroups: no groups returned for $TenantFilter" + return + } + $groupById = @{} + foreach ($g in $groups) { + if (-not $g.id) { continue } + $groupById["$($g.id)"] = $g + } + + $roleDefinitions = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?`$select=id,displayName&`$top=999" -tenantid $TenantFilter) + $roleDefById = @{} + foreach ($rd in $roleDefinitions) { + if (-not $rd.id) { continue } + $roleDefById["$($rd.id)"] = $rd + } + + $roleAssignments = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$select=id,principalId,roleDefinitionId,directoryScopeId,appScopeId&`$top=999" -tenantid $TenantFilter) + if (-not $roleAssignments -or $roleAssignments.Count -eq 0) { + Write-Information "Get-CIPPAlertRoleEscalableGroups: no role assignments returned for $TenantFilter" + return + } + + $findings = [System.Collections.Generic.List[object]]::new() + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $transitiveNestedGroupIdsByRoot = @{} + + foreach ($assignment in $roleAssignments) { + $principalId = "$($assignment.principalId)" + if (-not $principalId) { continue } + + if (-not $groupById.ContainsKey($principalId)) { continue } + $rootGroup = $groupById[$principalId] + if (-not $rootGroup) { continue } + + $roleDefId = "$($assignment.roleDefinitionId)" + $roleDef = if ($roleDefId) { $roleDefById[$roleDefId] } else { $null } + $roleName = if ($roleDef -and $roleDef.displayName) { $roleDef.displayName } else { 'Unknown role' } + $scope = if ($assignment.directoryScopeId) { "$($assignment.directoryScopeId)" } elseif ($assignment.appScopeId) { "$($assignment.appScopeId)" } else { '/' } + + if ($rootGroup.isAssignableToRole -ne $true) { + $dedupeKey = "D|$principalId|$roleDefId|$scope" + if (-not $seen.Add($dedupeKey)) { continue } + + $findings.Add([PSCustomObject]@{ + PathType = 'Direct' + RoleAssignedGroupId = $rootGroup.id + RoleAssignedGroupDisplayName = $rootGroup.displayName + GroupDisplayName = $rootGroup.displayName + GroupId = $rootGroup.id + IsAssignableToRole = $rootGroup.isAssignableToRole + RoleName = $roleName + Risk = 'High' + Reason = 'Group has directory role assignment while not marked role-assignable; group owners/admins have an indirect role-escalation path.' + }) + } + + if (-not $transitiveNestedGroupIdsByRoot.ContainsKey($principalId)) { + $nestedIds = [System.Collections.Generic.List[string]]::new() + try { + $transitive = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups/$principalId/transitiveMembers/microsoft.graph.group?`$select=id" -tenantid $TenantFilter) + foreach ($m in $transitive) { + $mid = "$($m.id)" + if (-not $mid -or $mid -eq $principalId) { continue } + $nestedIds.Add($mid) + } + } catch { + Write-Information "Get-CIPPAlertRoleEscalableGroups: transitiveMembers failed for group $principalId — $($_.Exception.Message)" + } + $transitiveNestedGroupIdsByRoot[$principalId] = $nestedIds + } + + foreach ($nestedId in @($transitiveNestedGroupIdsByRoot[$principalId])) { + if (-not $groupById.ContainsKey($nestedId)) { continue } + $nestedGroup = $groupById[$nestedId] + if (-not $nestedGroup) { continue } + if ($nestedGroup.isAssignableToRole -eq $true) { continue } + + $dedupeKey = "N|$nestedId|$roleDefId|$scope|$principalId" + if (-not $seen.Add($dedupeKey)) { continue } + + $findings.Add([PSCustomObject]@{ + PathType = 'Nested' + RoleAssignedGroupId = $rootGroup.id + RoleAssignedGroupDisplayName = $rootGroup.displayName + GroupDisplayName = $nestedGroup.displayName + GroupId = $nestedGroup.id + IsAssignableToRole = $nestedGroup.isAssignableToRole + RoleName = $roleName + Risk = 'High' + Reason = 'Non-role-assignable group is nested (transitive member) under a group that has a directory role; group owners/admins can add members who inherit high privileged membership.' + }) + } + } + + if ($findings.Count -gt 0) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data @($findings) + } else { + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert: no role-escalation group paths found" -sev 'Information' + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert failed: $ErrorMessage" -sev 'Error' + } +}