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 01/13] 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 5ecdc29a503b9dc01006be6353efc86e9f539129 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Mar 2026 22:39:16 -0400 Subject: [PATCH 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 b25e385fa162f1157a08a8355ff3c11db18d7e1b Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sun, 22 Mar 2026 01:41:55 +0100 Subject: [PATCH 07/13] 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 08/13] 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 09/13] 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 11c6bc0fe889465331903486c9ca2dfc53b3943f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 23 Mar 2026 14:55:51 -0400 Subject: [PATCH 10/13] 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 11/13] 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 12/13] 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 1d546e13b2203c1101067c090bf75a259ca24bed Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 10:46:41 -0400 Subject: [PATCH 13/13] 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