diff --git a/Config/SchedulerRateLimits.json b/Config/SchedulerRateLimits.json deleted file mode 100644 index 3d2c65716af0..000000000000 --- a/Config/SchedulerRateLimits.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "Command": "Sync-CIPPExtensionData", - "MaxRequests": 50 - }, - { - "Command": "Push-CIPPExtensionData", - "MaxRequests": 30 - } -] \ No newline at end of file diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index 7b96b16bc4cb..be862eabe375 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -41,6 +41,7 @@ function Add-CIPPDbItem { [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Alias('Data')] + [AllowNull()] [AllowEmptyCollection()] $InputObject, diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index 56182ece6f32..f0ca6d528e40 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -11,6 +11,11 @@ $TenantFilter ) + #Add rerun protection: This Monitor can only run once every hour. + $Rerun = Test-CIPPRerun -TenantFilter $TenantFilter -Type 'ExchangeMonitor' -API 'Get-CIPPAlertQuarantineReleaseRequests' + if ($Rerun) { + return $true + } $HasLicense = Test-CIPPStandardLicense -StandardName 'QuarantineReleaseRequests' -TenantFilter $TenantFilter -RequiredCapabilities @( 'EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', @@ -20,11 +25,11 @@ ) if (-not $HasLicense) { - return + return $true } try { - $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested' } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* + $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested'; StartReceivedDate = (Get-Date).AddHours(-6) } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* if ($RequestedReleases) { # Get the CIPP URL for the Quarantine link diff --git a/Modules/CIPPCore/Public/Assert-CippVersion.ps1 b/Modules/CIPPCore/Public/Assert-CippVersion.ps1 index ac61237dcb60..c96873ede537 100644 --- a/Modules/CIPPCore/Public/Assert-CippVersion.ps1 +++ b/Modules/CIPPCore/Public/Assert-CippVersion.ps1 @@ -10,8 +10,10 @@ function Assert-CippVersion { Local version of CIPP frontend #> - Param($CIPPVersion) - $APIVersion = (Get-Content 'version_latest.txt' -Raw).trim() + param($CIPPVersion) + $CIPPCoreModuleRoot = Get-Module -Name CIPPCore | Select-Object -ExpandProperty ModuleBase + $CIPPRoot = (Get-Item $CIPPCoreModuleRoot).Parent.Parent + $APIVersion = (Get-Content -Path $CIPPRoot\version_latest.txt).trim() $RemoteAPIVersion = (Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/KelvinTegelaar/CIPP-API/master/version_latest.txt').trim() $RemoteCIPPVersion = (Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/KelvinTegelaar/CIPP/main/public/version.json').version diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantDownload.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantDownload.ps1 index e80608d97d6a..54f1dc0f3c16 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantDownload.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantDownload.ps1 @@ -39,15 +39,19 @@ function Push-AuditLogTenantDownload { $LogSearchesTable = Get-CippTable -TableName 'AuditLogSearches' try { - $LogSearches = Get-CippAuditLogSearches -TenantFilter $TenantFilter -ReadyToProcess | Select-Object -First 10 - Write-Information ('Audit Logs: Found {0} searches, begin downloading' -f $LogSearches.Count) + $LogSearches = Get-CippAuditLogSearches -TenantFilter $TenantFilter -ReadyToProcess | Sort-Object -Property filterStartDateTime | Select-Object -First 10 + if ($LogSearches.Count -eq 0) { + Write-Information "Audit Logs: No searches ready to process for $TenantFilter" + return $true + } + Write-Information ('Audit Logs: Found {0} searches for {1}, begin downloading' -f $LogSearches.Count, $TenantFilter) foreach ($Search in $LogSearches) { $SearchEntity = Get-CIPPAzDataTableEntity @LogSearchesTable -Filter "Tenant eq '$($TenantFilter)' and RowKey eq '$($Search.id)'" $SearchEntity.CippStatus = 'Processing' Add-CIPPAzDataTableEntity @LogSearchesTable -Entity $SearchEntity -Force try { Write-Information "Audit Log search: Processing search ID: $($Search.id) for tenant: $TenantFilter" - $Downloads = New-CIPPAuditLogSearchResultsCache -TenantFilter $TenantFilter -searchId $Search.id + $null = New-CIPPAuditLogSearchResultsCache -TenantFilter $TenantFilter -searchId $Search.id $SearchEntity.CippStatus = 'Downloaded' } catch { if ($_.Exception.Message -match 'Request rate is large. More Request Units may be needed, so no changes were made. Please retry this request later.') { @@ -68,6 +72,7 @@ function Push-AuditLogTenantDownload { } Add-CIPPAzDataTableEntity @LogSearchesTable -Entity $SearchEntity -Force } + return $true } catch { Write-Information ('Audit Log search: Error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) return $false diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListExtensionsConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListExtensionsConfig.ps1 index eb94ced2c322..494b04b7b782 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListExtensionsConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListExtensionsConfig.ps1 @@ -10,7 +10,7 @@ function Invoke-ListExtensionsConfig { $Table = Get-CIPPTable -TableName Extensionsconfig try { $Config = (Get-CIPPAzDataTableEntity @Table).config - if (Test-Json -Json $Config -ErrorAction SilentlyContinue) { + if ($Config -and (Test-Json -Json $Config -ErrorAction SilentlyContinue)) { $Body = $Config | ConvertFrom-Json -Depth 10 -ErrorAction Stop if ($Body.HaloPSA.TicketType -and !$Body.HaloPSA.TicketType.value) { # translate ticket type to autocomplete format diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 index c1ae93dd22a1..a8c64da33fec 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 @@ -45,7 +45,6 @@ function Start-AuditLogSearchCreation { } if (!$TenantInConfig) { - Write-Information "Tenant $($Tenant.defaultDomainName) has no configured audit log rules, skipping search creation." continue } @@ -67,7 +66,7 @@ function Start-AuditLogSearchCreation { SkipLog = $true } Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 5 -Compress) - Write-Information "Started Audit Log search creation orchestratorwith $($Batch.Count) tenants" + Write-Information "Started Audit Log search creation orchestrator with $($Batch.Count) tenants" } else { Write-Information 'No tenants found for Audit Log search creation' } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 272c78ee2627..b677afdd2071 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -17,33 +17,6 @@ function Start-UserTasksOrchestrator { $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$30MinutesAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo'))" $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter - $RateLimitTable = Get-CIPPTable -tablename 'SchedulerRateLimits' - $RateLimits = Get-CIPPAzDataTableEntity @RateLimitTable -Filter "PartitionKey eq 'SchedulerRateLimits'" - - $CIPPCoreModuleRoot = Get-Module -Name CIPPCore | Select-Object -ExpandProperty ModuleBase - $CIPPRoot = (Get-Item $CIPPCoreModuleRoot).Parent.Parent - $DefaultRateLimits = Get-Content -Path "$CIPPRoot/Config/SchedulerRateLimits.json" | ConvertFrom-Json - $NewRateLimits = foreach ($Limit in $DefaultRateLimits) { - if ($Limit.Command -notin $RateLimits.RowKey) { - @{ - PartitionKey = 'SchedulerRateLimits' - RowKey = $Limit.Command - MaxRequests = $Limit.MaxRequests - } - } - } - - if ($NewRateLimits) { - $null = Add-CIPPAzDataTableEntity @RateLimitTable -Entity $NewRateLimits -Force - $RateLimits = Get-CIPPAzDataTableEntity @RateLimitTable -Filter "PartitionKey eq 'SchedulerRateLimits'" - } - - # Create a hashtable for quick rate limit lookups - $RateLimitLookup = @{} - foreach ($limit in $RateLimits) { - $RateLimitLookup[$limit.RowKey] = $limit.MaxRequests - } - $Batch = [System.Collections.Generic.List[object]]::new() $TenantList = Get-Tenants -IncludeErrors foreach ($task in $tasks) { @@ -62,9 +35,12 @@ function Start-UserTasksOrchestrator { TaskState = 'Pending' } $task.Parameters = $task.Parameters | ConvertFrom-Json -AsHashtable - $task.AdditionalProperties = $task.AdditionalProperties | ConvertFrom-Json - if (!$task.Parameters) { $task.Parameters = @{} } + + # Cache Get-Command result to avoid repeated expensive reflection calls + $CommandInfo = Get-Command $task.Command + $HasTenantFilter = $CommandInfo.Parameters.ContainsKey('TenantFilter') + $ScheduledCommand = [pscustomobject]@{ Command = $task.Command Parameters = $task.Parameters @@ -77,13 +53,15 @@ function Start-UserTasksOrchestrator { Write-Host "Excluded Tenants from this task: $ExcludedTenants" $AllTenantCommands = foreach ($Tenant in $TenantList | Where-Object { $_.defaultDomainName -notin $ExcludedTenants }) { $NewParams = $task.Parameters.Clone() - if ((Get-Command $task.Command).Parameters.TenantFilter) { + if ($HasTenantFilter) { $NewParams.TenantFilter = $Tenant.defaultDomainName } + # Clone TaskInfo to prevent shared object references + $TaskInfoClone = $task.PSObject.Copy() [pscustomobject]@{ Command = $task.Command Parameters = $NewParams - TaskInfo = $task + TaskInfo = $TaskInfoClone FunctionName = 'ExecScheduledCommand' } } @@ -109,13 +87,15 @@ function Start-UserTasksOrchestrator { $GroupTenantCommands = foreach ($ExpandedTenant in $ExpandedTenants | Where-Object { $_.value -notin $ExcludedTenants }) { $NewParams = $task.Parameters.Clone() - if ((Get-Command $task.Command).Parameters.TenantFilter) { + if ($HasTenantFilter) { $NewParams.TenantFilter = $ExpandedTenant.value } + # Clone TaskInfo to prevent shared object references + $TaskInfoClone = $task.PSObject.Copy() [pscustomobject]@{ Command = $task.Command Parameters = $NewParams - TaskInfo = $task + TaskInfo = $TaskInfoClone FunctionName = 'ExecScheduledCommand' } } @@ -125,14 +105,14 @@ function Start-UserTasksOrchestrator { Write-LogMessage -API 'Scheduler_UserTasks' -tenant $tenant -message "Failed to expand tenant group for task $($task.Name): $($_.Exception.Message)" -sev Error # Fall back to treating as single tenant - if ((Get-Command $task.Command).Parameters.TenantFilter) { + if ($HasTenantFilter) { $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant } $Batch.Add($ScheduledCommand) } } else { # Handle single tenant - if ((Get-Command $task.Command).Parameters.TenantFilter) { + if ($HasTenantFilter) { $ScheduledCommand.Parameters['TenantFilter'] = $task.Tenant } $Batch.Add($ScheduledCommand) @@ -155,51 +135,35 @@ function Start-UserTasksOrchestrator { Write-Information 'Batching tasks for execution...' Write-Information "Total tasks to process: $($Batch.Count)" - if (($Batch | Measure-Object).Count -gt 0) { - # Group commands by type and apply rate limits - $CommandGroups = $Batch | Group-Object -Property Command + if ($Batch.Count -gt 0) { + # Group tasks by tenant instead of command type + $TenantGroups = $Batch | Group-Object -Property { $_.Parameters.TenantFilter } $ProcessedBatches = [System.Collections.Generic.List[object]]::new() - foreach ($CommandGroup in $CommandGroups) { - $CommandName = $CommandGroup.Name - $Commands = [System.Collections.Generic.List[object]]::new($CommandGroup.Group) - - # Get rate limit for this command (default to 100 if not found) - $MaxItemsPerBatch = if ($RateLimitLookup.ContainsKey($CommandName)) { - $RateLimitLookup[$CommandName] - } else { - 100 - } - - # Split into batches based on rate limit - while ($Commands.Count -gt 0) { - $BatchSize = [Math]::Min($Commands.Count, $MaxItemsPerBatch) - $CommandBatch = [System.Collections.Generic.List[object]]::new() - - for ($i = 0; $i -lt $BatchSize; $i++) { - $CommandBatch.Add($Commands[0]) - $Commands.RemoveAt(0) - } + foreach ($TenantGroup in $TenantGroups) { + $TenantName = $TenantGroup.Name + $TenantCommands = [System.Collections.Generic.List[object]]::new($TenantGroup.Group) - $ProcessedBatches.Add($CommandBatch) - } + Write-Information "Creating batch for tenant: $TenantName with $($TenantCommands.Count) tasks" + $ProcessedBatches.Add($TenantCommands) } - # Process each batch separately + # Process each tenant batch separately foreach ($ProcessedBatch in $ProcessedBatches) { - Write-Information "Processing batch with $($ProcessedBatch.Count) tasks..." + $TenantName = $ProcessedBatch[0].Parameters.TenantFilter + Write-Information "Processing batch for tenant: $TenantName with $($ProcessedBatch.Count) tasks..." Write-Information 'Tasks by command:' $ProcessedBatch | Group-Object -Property Command | ForEach-Object { Write-Information " - $($_.Name): $($_.Count)" } - # Create queue entry for each batch - $Queue = New-CippQueueEntry -Name "Scheduled Tasks - Batch #$($ProcessedBatches.IndexOf($ProcessedBatch) + 1) of $($ProcessedBatches.Count)" + # Create queue entry for each tenant batch + $Queue = New-CippQueueEntry -Name "Scheduled Tasks - $TenantName" $QueueId = $Queue.RowKey - $BatchWithQueue = $ProcessedBatch | Select-Object *, @{Name = 'QueueId'; Expression = { $QueueId } }, @{Name = 'QueueName'; Expression = { '{0} - {1}' -f $_.TaskInfo.Name, ($_.TaskInfo.Tenant -ne 'AllTenants' ? $_.TaskInfo.Tenant : $_.Parameters.TenantFilter) } } + $BatchWithQueue = $ProcessedBatch | Select-Object *, @{Name = 'QueueId'; Expression = { $QueueId } }, @{Name = 'QueueName'; Expression = { '{0} - {1}' -f $_.TaskInfo.Name, $TenantName } } $InputObject = [PSCustomObject]@{ - OrchestratorName = 'UserTaskOrchestrator' + OrchestratorName = "UserTaskOrchestrator_$TenantName" Batch = @($BatchWithQueue) SkipLog = $true } diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index 67340eaf3ab5..886a8f9201a8 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -13,7 +13,11 @@ function New-CIPPAlertTemplate { $AlertComment ) $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 - $HTMLTemplate = Get-Content 'TemplateEmail.html' -Raw | Out-String + # Get the function app root directory by navigating from the module location + $ModuleBase = Get-Module CIPPCore | Select-Object -ExpandProperty ModuleBase + $FunctionAppRoot = (Get-Item $ModuleBase).Parent.Parent.FullName + $TemplatePath = Join-Path $FunctionAppRoot 'TemplateEmail.html' + $HTMLTemplate = Get-Content $TemplatePath -Raw | Out-String $Title = '' $IntroText = '' $ButtonUrl = '' diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 index 27fb33c5cbc6..3716fa9a5c06 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 @@ -24,11 +24,11 @@ function Set-CIPPDBCacheMailboxes { Select = $Select } # Use Generic List for better memory efficiency with large datasets - $MailboxList = [System.Collections.Generic.List[PSObject]]::new() + $Mailboxes = [System.Collections.Generic.List[PSObject]]::new() $RawMailboxes = New-ExoRequest @ExoRequest foreach ($Mailbox in $RawMailboxes) { - $MailboxList.Add(($Mailbox | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, + $Mailboxes.Add(($Mailbox | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } }, diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 index b7b0f4810c15..311a02400c6f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 @@ -22,7 +22,7 @@ } try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "$State-InboxRule" -Anchor $Username -cmdParams @{Identity = $RuleId; mailbox = $UserId } -Headers $Headers + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "$State-InboxRule" -Anchor $Username -cmdParams @{Identity = $RuleId; Mailbox = $UserId } Write-LogMessage -headers $Headers -API $APIName -message "Successfully set mailbox rule $($RuleName) for $($Username) to $($State)d" -Sev 'Info' -tenant $TenantFilter return "Successfully set mailbox rule $($RuleName) for $($Username) to $($State)d" } catch { diff --git a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 index ac19ef9039e2..198cf87143f9 100644 --- a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 @@ -21,6 +21,7 @@ function Test-CIPPRerun { 'Standard' { 9800 } # 2 hours 45 minutes ish. 'BPA' { 85000 } # 24 hours ish. 'CippTests' { 85000 } # 24 hours ish. + 'Get-CIPPAlertQuarantineReleaseRequests' { 3500 } #about an hour default { throw "Unknown type: $Type" } } } diff --git a/Modules/CIPPCore/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21801.ps1 b/Modules/CIPPCore/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21801.ps1 index 1b3783d09304..d169ac04dc97 100644 --- a/Modules/CIPPCore/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21801.ps1 +++ b/Modules/CIPPCore/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21801.ps1 @@ -16,13 +16,21 @@ function Invoke-CippTestZTNA21801 { $PhishResistantMethods = @('passKeyDeviceBound', 'passKeyDeviceBoundAuthenticator', 'windowsHelloForBusiness') - $results = $UserRegistrationDetails | Where-Object { - $userId = $_.id - $matchingUser = $Users | Where-Object { $_.id -eq $userId -and $_.accountEnabled } - $matchingUser - } | ForEach-Object { - $regDetail = $_ - $matchingUser = $Users | Where-Object { $_.id -eq $regDetail.id } + # Create hashtable for O(1) user lookup instead of O(n) Where-Object searches + $UserLookup = @{} + foreach ($user in $Users) { + if ($user.accountEnabled) { + $UserLookup[$user.id] = $user + } + } + + $results = foreach ($regDetail in $UserRegistrationDetails) { + # Fast O(1) lookup instead of Where-Object + if (-not $UserLookup.ContainsKey($regDetail.id)) { + continue + } + + $matchingUser = $UserLookup[$regDetail.id] $hasPhishResistant = $false if ($regDetail.methodsRegistered) { @@ -42,11 +50,11 @@ function Invoke-CippTestZTNA21801 { } } - $totalUserCount = $results.Length + $totalUserCount = $results.Count $phishResistantUsers = $results | Where-Object { $_.phishResistantAuthMethod } $phishableUsers = $results | Where-Object { !$_.phishResistantAuthMethod } - $phishResistantUserCount = $phishResistantUsers.Length + $phishResistantUserCount = $phishResistantUsers.Count $passed = $totalUserCount -eq $phishResistantUserCount @@ -64,24 +72,42 @@ function Invoke-CippTestZTNA21801 { $mdInfo = "Found users that have not registered phishing resistant authentication methods.`n`n" } + # Limit output to prevent performance issues with large user sets + $maxUsersToDisplay = 500 + $mdInfo = $mdInfo + "| User | Last sign in | Phishing resistant method registered |`n" $mdInfo = $mdInfo + "| :--- | :--- | :---: |`n" $userLinkFormat = 'https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/UserAuthMethods/userId/{0}/hidePreviewBanner~/true' - $mdLines = @($phishableUsers | Sort-Object displayName | ForEach-Object { + # Show phishable users first (up to limit) + $phishableUsersToShow = $phishableUsers | Sort-Object displayName | Select-Object -First $maxUsersToDisplay + $mdLines = @($phishableUsersToShow | ForEach-Object { $userLink = $userLinkFormat -f $_.id $lastSignInDate = if ($_.lastSuccessfulSignInDateTime) { (Get-Date $_.lastSuccessfulSignInDateTime -Format 'yyyy-MM-dd') } else { 'Never' } "|[$($_.displayName)]($userLink)| $lastSignInDate | ❌ |`n" }) $mdInfo = $mdInfo + ($mdLines -join '') - $mdLines = @($phishResistantUsers | Sort-Object displayName | ForEach-Object { - $userLink = $userLinkFormat -f $_.id - $lastSignInDate = if ($_.lastSuccessfulSignInDateTime) { (Get-Date $_.lastSuccessfulSignInDateTime -Format 'yyyy-MM-dd') } else { 'Never' } - "|[$($_.displayName)]($userLink)| $lastSignInDate | ✅ |`n" - }) - $mdInfo = $mdInfo + ($mdLines -join '') + if ($phishableUsers.Count -gt $maxUsersToDisplay) { + $mdInfo = $mdInfo + "|... and $($phishableUsers.Count - $maxUsersToDisplay) more users without phish-resistant methods|||`n" + } + + # Show phish-resistant users (up to remaining limit) + $remainingSlots = $maxUsersToDisplay - [Math]::Min($phishableUsers.Count, $maxUsersToDisplay) + if ($remainingSlots -gt 0) { + $phishResistantUsersToShow = $phishResistantUsers | Sort-Object displayName | Select-Object -First $remainingSlots + $mdLines = @($phishResistantUsersToShow | ForEach-Object { + $userLink = $userLinkFormat -f $_.id + $lastSignInDate = if ($_.lastSuccessfulSignInDateTime) { (Get-Date $_.lastSuccessfulSignInDateTime -Format 'yyyy-MM-dd') } else { 'Never' } + "|[$($_.displayName)]($userLink)| $lastSignInDate | ✅ |`n" + }) + $mdInfo = $mdInfo + ($mdLines -join '') + + if ($phishResistantUsers.Count -gt $remainingSlots) { + $mdInfo = $mdInfo + "|... and $($phishResistantUsers.Count - $remainingSlots) more users with phish-resistant methods|||`n" + } + } $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index cbd8229cf7fe..c33d4d006a53 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -8,97 +8,107 @@ function Test-CIPPAuditLogRules { ) try { - # Helper function to map GUIDs and partner UPNs to user objects + # Pre-compiled regex patterns for GUID matching (performance optimization) + $script:StandardGuidRegex = [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}$' + $script:PartnerUpnRegex = [regex]'user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)' + $script:PartnerExchangeRegex = [regex]'([^\\]+\.onmicrosoft\.com)\\tenant:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}),\s*object:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})' + + # Helper function to map GUIDs and partner UPNs to user objects (Optimized with hashtable lookups) function Add-CIPPGuidMappings { param( [Parameter(Mandatory = $true)] $DataObject, [Parameter(Mandatory = $true)] - $Users, + $UserLookup, [Parameter(Mandatory = $true)] - $Groups, + $GroupLookup, [Parameter(Mandatory = $true)] - $Devices, + $DeviceLookup, [Parameter(Mandatory = $true)] - $ServicePrincipals, + $ServicePrincipalLookup, [Parameter(Mandatory = $true)] - $PartnerUsers, + $PartnerUserLookup, [Parameter(Mandatory = $false)] [string]$PropertyPrefix = '' ) $DataObject.PSObject.Properties | ForEach-Object { - # Check for standard GUID format OR partner UPN formats - if ($_.Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' -or - $_.Value -match 'user_[0-9a-f]{32}@[^@]+\.onmicrosoft\.com' -or - $_.Value -match '[^\\]+\.onmicrosoft\.com\\tenant:\s*[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12},\s*object:\s*[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}') { - - # Use regex from guid-resolver hook to match various partner user formats - # Format 1: user_@.onmicrosoft.com - if ($_.Value -match 'user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)') { - $hexId = $matches[1] - $tenantDomain = $matches[2] - if ($hexId.Length -eq 32) { - # Convert the 32-character hex string to GUID format - $guid = "$($hexId.Substring(0,8))-$($hexId.Substring(8,4))-$($hexId.Substring(12,4))-$($hexId.Substring(16,4))-$($hexId.Substring(20,12))" - Write-Information "Found partner UPN format: $($_.Value) with GUID: $guid and tenant: $tenantDomain" - - # Check partner users for this GUID - foreach ($PartnerUser in $PartnerUsers) { - if ($PartnerUser.id -eq $guid) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" - return - } - } + $propValue = $_.Value + + # Quick type check - skip if not string or empty + if ([string]::IsNullOrEmpty($propValue) -or $propValue -isnot [string]) { + return + } + + # Check for partner UPN format 1: user_@.onmicrosoft.com + $match = $script:PartnerUpnRegex.Match($propValue) + if ($match.Success) { + $hexId = $match.Groups[1].Value + $tenantDomain = $match.Groups[2].Value + if ($hexId.Length -eq 32) { + # Convert hex string to GUID format + $guid = "$($hexId.Substring(0,8))-$($hexId.Substring(8,4))-$($hexId.Substring(12,4))-$($hexId.Substring(16,4))-$($hexId.Substring(20,12))" + Write-Information "Found partner UPN format: $propValue with GUID: $guid and tenant: $tenantDomain" + + # O(1) hashtable lookup instead of O(n) loop + if ($PartnerUserLookup.ContainsKey($guid)) { + $PartnerUser = $PartnerUserLookup[$guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" + return } } + } - # Format 2: TenantName.onmicrosoft.com\tenant: , object: - if ($_.Value -match '([^\\]+\.onmicrosoft\.com)\\tenant:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}),\s*object:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})') { - $customerTenantDomain = $matches[1] - $partnerTenantGuid = $matches[2] - $objectGuid = $matches[3] - Write-Information "Found partner exchange format: customer tenant $customerTenantDomain, partner tenant $partnerTenantGuid, object $objectGuid" - - # Check partner users for this object GUID - foreach ($PartnerUser in $PartnerUsers) { - if ($PartnerUser.id -eq $objectGuid) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" - return - } - } + # Check for partner exchange format: TenantName.onmicrosoft.com\tenant: , object: + $match = $script:PartnerExchangeRegex.Match($propValue) + if ($match.Success) { + $customerTenantDomain = $match.Groups[1].Value + $partnerTenantGuid = $match.Groups[2].Value + $objectGuid = $match.Groups[3].Value + Write-Information "Found partner exchange format: customer tenant $customerTenantDomain, partner tenant $partnerTenantGuid, object $objectGuid" + + # O(1) hashtable lookup + if ($PartnerUserLookup.ContainsKey($objectGuid)) { + $PartnerUser = $PartnerUserLookup[$objectGuid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" + return } + } - # Check standard directory objects (users, groups, devices, service principals) - foreach ($User in $Users) { - if ($User.id -eq $_.Value) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $User.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped User: $($User.userPrincipalName) to $PropertyPrefix$($_.Name)" - return - } + # Check for standard GUID format + if ($script:StandardGuidRegex.IsMatch($propValue)) { + $guid = $propValue + + # O(1) hashtable lookups in priority order + if ($UserLookup.ContainsKey($guid)) { + $User = $UserLookup[$guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $User.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped User: $($User.userPrincipalName) to $PropertyPrefix$($_.Name)" + return } - foreach ($Group in $Groups) { - if ($Group.id -eq $_.Value) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Group -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Group: $($Group.displayName) to $PropertyPrefix$($_.Name)" - return - } + + if ($GroupLookup.ContainsKey($guid)) { + $Group = $GroupLookup[$guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Group -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Group: $($Group.displayName) to $PropertyPrefix$($_.Name)" + return } - foreach ($Device in $Devices) { - if ($Device.id -eq $_.Value) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Device -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Device: $($Device.displayName) to $PropertyPrefix$($_.Name)" - return - } + + if ($DeviceLookup.ContainsKey($guid)) { + $Device = $DeviceLookup[$guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Device -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Device: $($Device.displayName) to $PropertyPrefix$($_.Name)" + return } - foreach ($ServicePrincipal in $ServicePrincipals) { - if ($ServicePrincipal.id -eq $_.Value -or $ServicePrincipal.appId -eq $_.Value) { - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $ServicePrincipal -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Service Principal: $($ServicePrincipal.displayName) to $PropertyPrefix$($_.Name)" - return - } + + # ServicePrincipal indexed by both id and appId + if ($ServicePrincipalLookup.ContainsKey($guid)) { + $ServicePrincipal = $ServicePrincipalLookup[$guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $ServicePrincipal -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Service Principal: $($ServicePrincipal.displayName) to $PropertyPrefix$($_.Name)" + return } } } @@ -151,7 +161,31 @@ function Test-CIPPAuditLogRules { $Table = Get-CIPPTable -tablename 'cacheauditloglookups' $1dayago = (Get-Date).AddDays(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $Lookups = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$TenantFilter' and Timestamp gt datetime'$1dayago'" - if (!$Lookups) { + + # Check if cached data needs refresh (wrong format or corrupted) + $NeedsRefresh = $false + if ($Lookups) { + try { + # Test if we can parse the cached data + $TestUser = ($Lookups | Where-Object { $_.RowKey -eq 'users' }).Data + if (![string]::IsNullOrEmpty($TestUser)) { + $ParsedTest = $TestUser | ConvertFrom-Json -ErrorAction Stop + # Check if data is valid (either array for legacy or PSCustomObject for hashtable) + if ($null -eq $ParsedTest) { + Write-Warning 'Cached data is null after parsing, triggering refresh' + $NeedsRefresh = $true + } + } else { + Write-Warning 'Cached data is empty, triggering refresh' + $NeedsRefresh = $true + } + } catch { + Write-Warning "Error parsing cached data: $($_.Exception.Message), triggering refresh" + $NeedsRefresh = $true + } + } + + if (!$Lookups -or $NeedsRefresh) { # Collect bulk data for users/groups/devices/applications $Requests = @( @{ @@ -180,44 +214,145 @@ function Test-CIPPAuditLogRules { $Groups = ($Response | Where-Object { $_.id -eq 'groups' }).body.value ?? @() $Devices = ($Response | Where-Object { $_.id -eq 'devices' }).body.value ?? @() $ServicePrincipals = ($Response | Where-Object { $_.id -eq 'servicePrincipals' }).body.value ?? @() - # Cache the lookups for 1 day + + # Build hashtables for O(1) GUID lookups + Write-Information "Building hashtable lookups for tenant $TenantFilter" + $UserLookup = @{} + foreach ($User in $Users) { + if (![string]::IsNullOrEmpty($User.id)) { + $UserLookup[$User.id] = $User + } + } + + $GroupLookup = @{} + foreach ($Group in $Groups) { + if (![string]::IsNullOrEmpty($Group.id)) { + $GroupLookup[$Group.id] = $Group + } + } + + $DeviceLookup = @{} + foreach ($Device in $Devices) { + if (![string]::IsNullOrEmpty($Device.id)) { + $DeviceLookup[$Device.id] = $Device + } + } + + $ServicePrincipalLookup = @{} + foreach ($SP in $ServicePrincipals) { + if (![string]::IsNullOrEmpty($SP.id)) { + $ServicePrincipalLookup[$SP.id] = $SP + } + # Also index by appId for dual lookup capability + if (![string]::IsNullOrEmpty($SP.appId)) { + $ServicePrincipalLookup[$SP.appId] = $SP + } + } + Write-Information "Built hashtables: $($UserLookup.Count) users, $($GroupLookup.Count) groups, $($DeviceLookup.Count) devices, $($ServicePrincipalLookup.Count) service principals" + + # Cache the hashtable lookups for 1 day (storing as JSON) $Entities = @( @{ PartitionKey = $TenantFilter RowKey = 'users' - Data = [string]($Users | ConvertTo-Json -Compress) + Data = [string]($UserLookup | ConvertTo-Json -Compress) + Format = 'hashtable' } @{ PartitionKey = $TenantFilter RowKey = 'groups' - Data = [string]($Groups | ConvertTo-Json -Compress) + Data = [string]($GroupLookup | ConvertTo-Json -Compress) + Format = 'hashtable' } @{ PartitionKey = $TenantFilter RowKey = 'devices' - Data = [string]($Devices | ConvertTo-Json -Compress) + Data = [string]($DeviceLookup | ConvertTo-Json -Compress) + Format = 'hashtable' } @{ PartitionKey = $TenantFilter RowKey = 'servicePrincipals' - Data = [string]($ServicePrincipals | ConvertTo-Json -Compress) + Data = [string]($ServicePrincipalLookup | ConvertTo-Json -Compress) + Format = 'hashtable' } ) # Save the cached lookups Add-CIPPAzDataTableEntity @Table -Entity $Entities -Force - Write-Information "Cached directory lookups for tenant $TenantFilter" + Write-Information "Cached directory hashtable lookups for tenant $TenantFilter" } else { - # Use cached lookups - $Users = (($Lookups | Where-Object { $_.RowKey -eq 'users' }).Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() - $Groups = (($Lookups | Where-Object { $_.RowKey -eq 'groups' }).Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() - $Devices = (($Lookups | Where-Object { $_.RowKey -eq 'devices' }).Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() - $ServicePrincipals = (($Lookups | Where-Object { $_.RowKey -eq 'servicePrincipals' }).Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() - Write-Information "Using cached directory lookups for tenant $TenantFilter" + # Use cached lookups - check if they're already hashtables or need conversion + $UsersLookup = $Lookups | Where-Object { $_.RowKey -eq 'users' } + $GroupsLookup = $Lookups | Where-Object { $_.RowKey -eq 'groups' } + $DevicesLookup = $Lookups | Where-Object { $_.RowKey -eq 'devices' } + $ServicePrincipalsLookup = $Lookups | Where-Object { $_.RowKey -eq 'servicePrincipals' } + + # Check if cached data is already in hashtable format + $IsHashtableFormat = $UsersLookup.Format -eq 'hashtable' + + if ($IsHashtableFormat) { + # Load pre-built hashtables directly from cache + Write-Information "Loading pre-built hashtable lookups from cache for tenant $TenantFilter" + $UserLookup = ($UsersLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue -AsHashtable) ?? @{} + $GroupLookup = ($GroupsLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue -AsHashtable) ?? @{} + $DeviceLookup = ($DevicesLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue -AsHashtable) ?? @{} + $ServicePrincipalLookup = ($ServicePrincipalsLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue -AsHashtable) ?? @{} + Write-Information "Loaded hashtables: $($UserLookup.Count) users, $($GroupLookup.Count) groups, $($DeviceLookup.Count) devices, $($ServicePrincipalLookup.Count) service principals" + } else { + # Old format (array) - convert to hashtables + Write-Information "Converting legacy array cache to hashtables for tenant $TenantFilter" + $Users = ($UsersLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() + $Groups = ($GroupsLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() + $Devices = ($DevicesLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() + $ServicePrincipals = ($ServicePrincipalsLookup.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) ?? @() + + # Build hashtables + $UserLookup = @{} + foreach ($User in $Users) { + if (![string]::IsNullOrEmpty($User.id)) { + $UserLookup[$User.id] = $User + } + } + + $GroupLookup = @{} + foreach ($Group in $Groups) { + if (![string]::IsNullOrEmpty($Group.id)) { + $GroupLookup[$Group.id] = $Group + } + } + + $DeviceLookup = @{} + foreach ($Device in $Devices) { + if (![string]::IsNullOrEmpty($Device.id)) { + $DeviceLookup[$Device.id] = $Device + } + } + + $ServicePrincipalLookup = @{} + foreach ($SP in $ServicePrincipals) { + if (![string]::IsNullOrEmpty($SP.id)) { + $ServicePrincipalLookup[$SP.id] = $SP + } + if (![string]::IsNullOrEmpty($SP.appId)) { + $ServicePrincipalLookup[$SP.appId] = $SP + } + } + Write-Information "Built hashtables from legacy cache: $($UserLookup.Count) users, $($GroupLookup.Count) groups, $($DeviceLookup.Count) devices, $($ServicePrincipalLookup.Count) service principals" + } } # partner users $PartnerUsers = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$select=id,displayName,userPrincipalName,accountEnabled&`$top=999" -AsApp $true -NoAuthCheck $true + # Build partner user hashtable + $PartnerUserLookup = @{} + foreach ($PartnerUser in $PartnerUsers) { + if (![string]::IsNullOrEmpty($PartnerUser.id)) { + $PartnerUserLookup[$PartnerUser.id] = $PartnerUser + } + } + Write-Information "Built partner user hashtable: $($PartnerUserLookup.Count) partner users" + Write-Warning '## Audit Log Configuration ##' Write-Information ($Configuration | ConvertTo-Json -Depth 10) @@ -245,13 +380,13 @@ function Test-CIPPAuditLogRules { $RootProperties = $AuditRecord $Data = $AuditRecord.auditData | Select-Object *, CIPPAction, CIPPClause, CIPPGeoLocation, CIPPBadRepIP, CIPPHostedIP, CIPPIPDetected, CIPPLocationInfo, CIPPExtendedProperties, CIPPDeviceProperties, CIPPParameters, CIPPModifiedProperties, AuditRecord -ErrorAction SilentlyContinue try { - # Attempt to locate GUIDs in $Data and match them with their corresponding user, group, device, or service principal recursively by checking each key/value once located lets store these mapped values in a CIPP$KeyName property + # Attempt to locate GUIDs in $Data and match them with their corresponding user, group, device, or service principal using O(1) hashtable lookups Write-Information 'Checking Data for GUIDs to map to users, groups, devices, or service principals' - Add-CIPPGuidMappings -DataObject $Data -Users $Users -Groups $Groups -Devices $Devices -ServicePrincipals $ServicePrincipals -PartnerUsers $PartnerUsers -PropertyPrefix 'CIPP' + Add-CIPPGuidMappings -DataObject $Data -UserLookup $UserLookup -GroupLookup $GroupLookup -DeviceLookup $DeviceLookup -ServicePrincipalLookup $ServicePrincipalLookup -PartnerUserLookup $PartnerUserLookup -PropertyPrefix 'CIPP' # Also check root properties for GUIDs and partner UPNs Write-Information 'Checking RootProperties for GUIDs to map to users, groups, devices, or service principals' - Add-CIPPGuidMappings -DataObject $RootProperties -Users $Users -Groups $Groups -Devices $Devices -ServicePrincipals $ServicePrincipals -PartnerUsers $PartnerUsers + Add-CIPPGuidMappings -DataObject $RootProperties -UserLookup $UserLookup -GroupLookup $GroupLookup -DeviceLookup $DeviceLookup -ServicePrincipalLookup $ServicePrincipalLookup -PartnerUserLookup $PartnerUserLookup if ($Data.ExtendedProperties) { $Data.CIPPExtendedProperties = ($Data.ExtendedProperties | ConvertTo-Json -Compress) @@ -432,6 +567,7 @@ function Test-CIPPAuditLogRules { try { $ClauseStartTime = Get-Date Write-Warning "Webhook: Processing clause: $($clause.clause)" + Write-Information "Webhook: Available operations in data: $(($ProcessedData.Operation | Select-Object -Unique) -join ', ')" $ReturnedData = $ProcessedData | Where-Object { Invoke-Expression $clause.clause } if ($ReturnedData) { Write-Warning "Webhook: There is matching data: $(($ReturnedData.operation | Select-Object -Unique) -join ', ')" diff --git a/host.json b/host.json index a581a9789068..97957cbf1ed7 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.0.7", + "defaultVersion": "10.0.8", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 9380cfccb8c7..5219a0df74ba 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.0.7 +10.0.8