From b563b25dc2c1d99db12a4851512b43d48fb0484f Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:49:00 +0100 Subject: [PATCH 001/150] feat(halo): add Get-HaloUser lookup function Adds a HaloPSA user lookup helper that resolves a Microsoft 365 end-user to their HaloPSA contact id, scoped to a specific client. Matches by Azure Object ID first (azureoid field on the contact), then falls back to email address. Returns $null when no match is found so callers can decide how to handle unmatched users. Foundation for linking CIPP-generated alert tickets to the affected end-user instead of the client's General User contact. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 new file mode 100644 index 0000000000000..7ac85349ff1a0 --- /dev/null +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -0,0 +1,63 @@ +function Get-HaloUser { + <# + .SYNOPSIS + Look up a HaloPSA user/contact for a Microsoft 365 end-user. + .DESCRIPTION + Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure + Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns the + matched HaloPSA user object's id, or $null when no match is found. + .PARAMETER AzureOID + The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. + .PARAMETER Email + The user's email address (typically the UPN). Used as a fallback when AzureOID is missing + or returns no match. + .PARAMETER ClientId + The HaloPSA client id to scope the search to. + .PARAMETER Configuration + The HaloPSA extension configuration object (already extracted from Extensionsconfig). + .PARAMETER Token + A valid Halo OAuth token object as returned by Get-HaloToken. + #> + [CmdletBinding()] + param ( + [string]$AzureOID, + [string]$Email, + [Parameter(Mandatory = $true)] + $ClientId, + [Parameter(Mandatory = $true)] + $Configuration, + [Parameter(Mandatory = $true)] + $Token + ) + + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" + + $TrySearch = { + param($Term) + try { + $EncodedTerm = [System.Uri]::EscapeDataString($Term) + $Response = Invoke-RestMethod -Uri "$BaseUri&search=$EncodedTerm" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client $ClientId: $Message" -sev Warning + return @() + } + } + + if ($AzureOID) { + $Results = & $TrySearch $AzureOID + $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 + if ($Match) { return $Match.id } + } + + if ($Email) { + $Results = & $TrySearch $Email + $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 + if ($Match) { return $Match.id } + } + + return $null +} From e3b2a61d7c82e7be580cfe9455919f04011a3fa6 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:50:10 +0100 Subject: [PATCH 002/150] feat(halo): link tickets to affected user contacts when configured Adds optional UserUPN/AzureOID/DisplayName parameters to New-HaloPSATicket. When the HaloPSA.LinkTicketsToUsers setting is enabled and a user identifier is supplied, looks up the matching HaloPSA contact (via Get-HaloUser) and populates userlookup.id and user_name on the ticket payload so the ticket lands directly on the end-user's contact record rather than the client's General User. Unmatched users fall back to the existing userlookup.id = -1 (General User) behaviour, with the affected UPN appended to the ticket description and a warning logged to the CIPP logbook for follow-up. Consolidation hash now includes the UPN when user-linking is active so that per-user tickets for the same alert title don't collapse onto each other. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/New-HaloPSATicket.ps1 | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 0249248c88ec6..35991feef2a31 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -3,15 +3,34 @@ function New-HaloPSATicket { param ( $title, $description, - $client + $client, + [string]$UserUPN, + [string]$AzureOID, + [string]$DisplayName ) #Get HaloPSA Token based on the config we have. $Table = Get-CIPPTable -TableName Extensionsconfig $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).HaloPSA $TicketTable = Get-CIPPTable -TableName 'PSATickets' $token = Get-HaloToken -configuration $Configuration - # sha hash title - $TitleHash = Get-StringHash -String $title + + # Resolve affected user to a HaloPSA contact when the integration is configured for it. + # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). + $MatchedUserId = $null + $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) + if ($UserLinkActive) { + $MatchedUserId = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token + if (-not $MatchedUserId) { + $UnmatchedLabel = if ($DisplayName) { "$DisplayName ($UserUPN)" } else { $UserUPN } + Write-LogMessage -API 'HaloPSATicket' -message "No HaloPSA contact match for $UserUPN in client $client - falling back to General User" -sev Warning + $description = "$description

Affected user: $UnmatchedLabel - no matching HaloPSA contact found, ticket assigned to General User.

" + } + } + + # When linking is active, include UPN in the consolidation key so per-user tickets don't + # collapse onto each other when the same alert title fires for multiple users. + $HashInput = if ($UserLinkActive -and $UserUPN) { "$title|$UserUPN" } else { $title } + $TitleHash = Get-StringHash -String $HashInput if ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" @@ -62,17 +81,29 @@ function New-HaloPSATicket { } } + $UserLookupId = if ($MatchedUserId) { $MatchedUserId } else { -1 } + $UserLookupDisplay = if ($MatchedUserId) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + 'Enter Details Manually' + } + $UserNameValue = if ($MatchedUserId) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + $null + } + $Object = [PSCustomObject]@{ files = $null usertype = 1 userlookup = @{ - id = -1 - lookupdisplay = 'Enter Details Manually' + id = $UserLookupId + lookupdisplay = $UserLookupDisplay } client_id = ($client | Select-Object -Last 1) _forcereassign = $true site_id = $null - user_name = $null + user_name = $UserNameValue reportedby = $null summary = $title details_html = $description From 5f470b1ed4a507054d4e95a0a53cc610d1763adc Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:50:58 +0100 Subject: [PATCH 003/150] feat(halo): thread AffectedUser from alerts to HaloPSA ticket creation When an alert payload includes an AffectedUser (UPN, optional AzureOID, optional DisplayName) and HaloPSA.LinkTicketsToUsers is enabled, pass it through to New-HaloPSATicket. Best-effort resolves the user's Azure Object ID via Graph when only the UPN was supplied, so the HaloPSA contact lookup can prefer the more reliable azureoid match. Existing behaviour is unchanged when AffectedUser is absent or the toggle is off. Co-Authored-By: Claude Opus 4.7 --- .../Public/New-CippExtAlert.ps1 | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 index bae549bf07196..aec62ffae8fb4 100644 --- a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 +++ b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 @@ -20,7 +20,37 @@ function New-CippExtAlert { Write-Host "MappedId: $MappedId" if (!$mappedId) { $MappedId = 1 } Write-Host "MappedId: $MappedId" - New-HaloPSATicket -Title $Alert.AlertTitle -Description $Alert.AlertText -Client $mappedId + + $TicketParams = @{ + Title = $Alert.AlertTitle + Description = $Alert.AlertText + Client = $MappedId + } + + if ($Alert.AffectedUser -and $Configuration.HaloPSA.LinkTicketsToUsers) { + $UPN = $Alert.AffectedUser.UPN + $OID = $Alert.AffectedUser.AzureOID + $Display = $Alert.AffectedUser.DisplayName + + # Best-effort: resolve UPN -> Azure Object ID via Graph if we don't already have it. + # Failure here is non-fatal; Get-HaloUser will still try the email-based lookup. + if (-not $OID -and $UPN -and $Alert.TenantId) { + try { + $EncodedUPN = [System.Uri]::EscapeDataString($UPN) + $GraphUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$EncodedUPN`?`$select=id,displayName,userPrincipalName" -tenantid $Alert.TenantId -AsApp $true + if ($GraphUser.id) { $OID = $GraphUser.id } + if (-not $Display -and $GraphUser.displayName) { $Display = $GraphUser.displayName } + } catch { + Write-Information "Could not resolve Graph user for $UPN in tenant $($Alert.TenantId): $($_.Exception.Message)" + } + } + + if ($UPN) { $TicketParams.UserUPN = $UPN } + if ($OID) { $TicketParams.AzureOID = $OID } + if ($Display) { $TicketParams.DisplayName = $Display } + } + + New-HaloPSATicket @TicketParams } } 'Gradient' { From 21d7a2fba3e55445cbca16b1ccd9e22dc1440eb7 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:51:42 +0100 Subject: [PATCH 004/150] feat(alerts): add AffectedUser parameter to Send-CIPPAlert Lets callers attach an affected-user object (UPN, AzureOID, DisplayName) to PSA alerts. The user is added to the Alert hashtable handed to New-CippExtAlert, which uses it to link the resulting HaloPSA ticket to the matching contact. Co-Authored-By: Claude Opus 4.7 --- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index d069a2e2eb84e..18ef1d8f75c05 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -16,6 +16,7 @@ function Send-CIPPAlert { $TableName, $RowKey = [string][guid]::NewGuid(), $Attachments, + $AffectedUser, [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' @@ -334,6 +335,9 @@ function Send-CIPPAlert { AlertText = "$HTMLContent" AlertTitle = "$($Title)" } + if ($AffectedUser) { + $Alert.AffectedUser = $AffectedUser + } New-CippExtAlert -Alert $Alert Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info } catch { From 71a208f3b2dd3933bbbcdee1c7909aaa2450ee24 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:52:29 +0100 Subject: [PATCH 005/150] feat(alerts): split per-user PSA tickets when HaloPSA user-linking is on When the HaloPSA integration has LinkTicketsToUsers enabled and a scheduled alert returns rows containing a UPN-like field (UserPrincipalName, UPN, userId, Userkey), Send-CIPPScheduledTaskAlert now groups the result rows by that field and emits one PSA call per affected user. Each call carries an AffectedUser payload so the resulting HaloPSA ticket lands on the correct contact. Rows with no UPN value still produce a single tenant-scoped ticket, and any unexpected error in the split path falls back to today's consolidated-ticket behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 698be2e0e2440..a79af2d6302be 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -137,7 +137,54 @@ function Send-CIPPScheduledTaskAlert { # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { '*psa*' { - Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + $PsaSplitSent = $false + if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { + try { + $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig + $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue + $HaloConfig = $ExtConfig.HaloPSA + + if ($HaloConfig -and $HaloConfig.Enabled -and $HaloConfig.LinkTicketsToUsers) { + $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') + $RowProperties = $Results[0].PSObject.Properties.Name + $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + if ($UpnField) { + $DisplayFieldCandidates = @('DisplayName', 'displayName', 'userDisplayName') + $DisplayField = $DisplayFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + $Groups = $Results | Group-Object -Property $UpnField + + foreach ($Group in $Groups) { + $GroupKey = $Group.Name + $GroupHTMLFragment = $Group.Group | ConvertTo-Html -Fragment + $GroupHTML = $GroupHTMLFragment -replace '', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + + if ([string]::IsNullOrWhiteSpace($GroupKey)) { + # Rows without a usable user identifier - send as a single tenant-scoped ticket. + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $GroupHTML -TenantFilter $TenantFilter + } else { + $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } + $UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey } + $UserTitle = "$title - $UserLabel" + $AffectedUser = [pscustomobject]@{ + UPN = $GroupKey + DisplayName = $GroupDisplayName + } + Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser + } + } + $PsaSplitSent = $true + } + } + } catch { + Write-Information "Failed to split PSA alert by user, falling back to consolidated ticket: $($_.Exception.Message)" + } + } + + if (-not $PsaSplitSent) { + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } } '*email*' { $EmailParams = @{ From 95386fc3c766373d22830dd06783086e746eebfe Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:55:01 +0100 Subject: [PATCH 006/150] fix(halo): escape ClientId variable in Get-HaloUser warning message PowerShell parses '$ClientId:' as a scope-qualified variable reference, breaking the script's parser. Wrap the variable in ${} so it's interpolated correctly inside the warning message. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 7ac85349ff1a0..d2f0fa50fa9c1 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -42,7 +42,7 @@ function Get-HaloUser { return $Response } catch { $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } - Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client $ClientId: $Message" -sev Warning + Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client ${ClientId}: $Message" -sev Warning return @() } } From b190cf0bd874bc6da24f7e281150a1682c3e0350 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 15:28:39 +0100 Subject: [PATCH 007/150] fix(halo): populate site_id from matched user record HaloPSA rejects tickets with a specific user (userlookup.id != -1) when site_id is null - "Please select a valid Client/Site/User". The General User fallback (id = -1) auto-resolves the site, but a real contact does not. Get-HaloUser now returns both the matched user's id and site_id. New- HaloPSATicket pulls the site_id from that record onto the payload so the ticket can be created against the correct site under the client. Unmatched users still produce site_id = null, preserving today's General User behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 17 +++++++++++++---- .../Public/Halo/New-HaloPSATicket.ps1 | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index d2f0fa50fa9c1..5b25112ab2193 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -4,8 +4,9 @@ function Get-HaloUser { Look up a HaloPSA user/contact for a Microsoft 365 end-user. .DESCRIPTION Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure - Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns the - matched HaloPSA user object's id, or $null when no match is found. + Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns a + small object containing the matched user's id and site_id (Halo requires both when a + specific user is set on a ticket), or $null when no match is found. .PARAMETER AzureOID The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. .PARAMETER Email @@ -47,16 +48,24 @@ function Get-HaloUser { } } + $BuildResult = { + param($MatchedUser) + [pscustomobject]@{ + id = $MatchedUser.id + site_id = $MatchedUser.site_id + } + } + if ($AzureOID) { $Results = & $TrySearch $AzureOID $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 - if ($Match) { return $Match.id } + if ($Match) { return & $BuildResult $Match } } if ($Email) { $Results = & $TrySearch $Email $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 - if ($Match) { return $Match.id } + if ($Match) { return & $BuildResult $Match } } return $null diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 35991feef2a31..14f8d2c524676 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -16,11 +16,11 @@ function New-HaloPSATicket { # Resolve affected user to a HaloPSA contact when the integration is configured for it. # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). - $MatchedUserId = $null + $MatchedUser = $null $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) if ($UserLinkActive) { - $MatchedUserId = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token - if (-not $MatchedUserId) { + $MatchedUser = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token + if (-not $MatchedUser) { $UnmatchedLabel = if ($DisplayName) { "$DisplayName ($UserUPN)" } else { $UserUPN } Write-LogMessage -API 'HaloPSATicket' -message "No HaloPSA contact match for $UserUPN in client $client - falling back to General User" -sev Warning $description = "$description

Affected user: $UnmatchedLabel - no matching HaloPSA contact found, ticket assigned to General User.

" @@ -32,6 +32,11 @@ function New-HaloPSATicket { $HashInput = if ($UserLinkActive -and $UserUPN) { "$title|$UserUPN" } else { $title } $TitleHash = Get-StringHash -String $HashInput + # Halo requires a site_id whenever a specific user is set on the ticket; pull it from the + # matched user record. When no user is matched, leave site_id null and let Halo resolve it + # from the General User (id = -1). + $SiteId = if ($MatchedUser) { $MatchedUser.site_id } else { $null } + if ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" if ($ExistingTicket) { @@ -81,13 +86,13 @@ function New-HaloPSATicket { } } - $UserLookupId = if ($MatchedUserId) { $MatchedUserId } else { -1 } - $UserLookupDisplay = if ($MatchedUserId) { + $UserLookupId = if ($MatchedUser) { $MatchedUser.id } else { -1 } + $UserLookupDisplay = if ($MatchedUser) { if ($DisplayName) { $DisplayName } else { $UserUPN } } else { 'Enter Details Manually' } - $UserNameValue = if ($MatchedUserId) { + $UserNameValue = if ($MatchedUser) { if ($DisplayName) { $DisplayName } else { $UserUPN } } else { $null @@ -102,7 +107,7 @@ function New-HaloPSATicket { } client_id = ($client | Select-Object -Last 1) _forcereassign = $true - site_id = $null + site_id = $SiteId user_name = $UserNameValue reportedby = $null summary = $title From a74f113a1c5d254138041a24f12d95673b345964 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 15:31:58 +0100 Subject: [PATCH 008/150] fix(halo): cast user id and site_id to [int] in Get-HaloUser Invoke-RestMethod deserialises JSON numbers as [double] by default, so a site_id of 95 round-trips as "95.0" - which Halo's ticket endpoint rejects ("Input string '95.0' is not a valid integer"). Cast both id and site_id to [int] when building the result so ConvertTo-Json emits them as plain integers. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 5b25112ab2193..f957a7a4ce04c 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -50,9 +50,11 @@ function Get-HaloUser { $BuildResult = { param($MatchedUser) + # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers + # doesn't serialise back as e.g. "95.0", which Halo rejects. [pscustomobject]@{ - id = $MatchedUser.id - site_id = $MatchedUser.site_id + id = [int]$MatchedUser.id + site_id = [int]$MatchedUser.site_id } } From 0582c232c8306c08e4291d6c91d7909cf4e8ff8f Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 17:04:59 +0100 Subject: [PATCH 009/150] feat(halo): link audit-log PSA tickets to the affected user Audit-log driven alerts (new inbox rule, role change, MFA disabled, sessions revoked, etc.) now extract the affected user from the audit record and pass it through to the PSA pipeline as AffectedUser. When HaloPSA.LinkTicketsToUsers is enabled, the resulting Halo ticket lands on the matching contact instead of the client's General User. Detection prefers ObjectId (target of the action) over UserId/Userkey, taking the resolved UPN from the upstream GUID-mapped CIPP* property when available and using the raw GUID directly as the AzureOID for the Halo lookup. Service principal events naturally fall through with no AffectedUser (no @ in any candidate), preserving the original General User behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 6ca3b39290fa4..1e478b6a683b6 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -121,6 +121,38 @@ function Invoke-CippWebhookProcessing { $AuditLogLink = '{0}/tenant/administration/audit-logs/log?logId={1}&tenantFilter={2}' -f $CIPPURL, $LogId, $Tenant.defaultDomainName $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment + # Derive the affected end-user from the audit record so PSA tickets can be linked to the + # right HaloPSA contact when HaloPSA.LinkTicketsToUsers is enabled. The upstream GUID mapper + # has already attached CIPP-prefixed properties (e.g. CIPPObjectId) holding the resolved UPN + # for any property that contained a user's Object ID; the raw property usually still holds + # the original GUID, which we can use directly as the AzureOID for the Halo lookup. + $AffectedUser = $null + $GuidRegex = '^[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}$' + $UserCandidates = @( + @{ Raw = 'ObjectId'; Mapped = 'CIPPObjectId' } + @{ Raw = 'UserId'; Mapped = 'CIPPUserId' } + @{ Raw = 'Userkey'; Mapped = 'CIPPUserkey' } + ) + foreach ($Candidate in $UserCandidates) { + $RawValue = $Data.$($Candidate.Raw) + $MappedValue = $Data.$($Candidate.Mapped) + if (-not $RawValue -and -not $MappedValue) { continue } + + $UPN = $null; $OID = $null + if ($MappedValue -is [string] -and $MappedValue -match '@') { $UPN = $MappedValue } + elseif ($RawValue -is [string] -and $RawValue -match '@') { $UPN = $RawValue } + + if ($RawValue -is [string] -and $RawValue -match $GuidRegex) { $OID = $RawValue } + + if ($UPN -or $OID) { + $AffectedUser = [pscustomobject]@{ + UPN = $UPN + AzureOID = $OID + } + break + } + } + Write-Host 'Going to create the content' foreach ($action in $ActionList ) { switch ($action) { @@ -142,6 +174,9 @@ function Invoke-CippWebhookProcessing { HTMLContent = $GenerateEmail.htmlcontent TenantFilter = $TenantFilter } + if ($AffectedUser) { + $CIPPAlert.AffectedUser = $AffectedUser + } Send-CIPPAlert @CIPPAlert } 'generateWebhook' { From e7657c7f23c3c9ca36b867c93a349aa3ff552f55 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 17:13:53 +0100 Subject: [PATCH 010/150] chore(alerts): surface PSA delivery outcome to Information stream Adds visibility to the PSA branch of Send-CIPPAlert so callers can see what actually happened: - When sendtoIntegration is disabled in CippNotifications config, log a clear skip reason instead of silently doing nothing. - When an AffectedUser is attached, log which UPN/OID is being targeted. - Capture and surface the result string returned by New-CippExtAlert (which carries through "Ticket created in HaloPSA: " or the failure message). No behavioural change to delivery - just diagnostics. Co-Authored-By: Claude Opus 4.7 --- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 41 +++++++++++++--------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index cfeebf4b95cbf..edd6a2af17edf 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -327,24 +327,31 @@ function Send-CIPPAlert { if ($Type -eq 'psa') { Write-Information 'Trying to send to PSA' - if ($config.sendtoIntegration) { - if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { - try { - $Alert = @{ - TenantId = $TenantFilter - AlertText = "$HTMLContent" - AlertTitle = "$($Title)" - } - if ($AffectedUser) { - $Alert.AffectedUser = $AffectedUser - } - New-CippExtAlert -Alert $Alert - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if (-not $config.sendtoIntegration) { + Write-Information 'PSA delivery skipped: sendtoIntegration is disabled in CippNotifications config. Enable it under Settings -> Notifications to route alerts to your PSA.' + return + } + if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { + try { + $Alert = @{ + TenantId = $TenantFilter + AlertText = "$HTMLContent" + AlertTitle = "$($Title)" + } + if ($AffectedUser) { + $Alert.AffectedUser = $AffectedUser + $UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' } + Write-Information "PSA alert AffectedUser: $UserLabel" + } + $PsaResult = New-CippExtAlert -Alert $Alert + if ($PsaResult) { + Write-Information "PSA result: $PsaResult" } + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } } From d83a6fb09e82868b9b8fcb3cb13435cf0b76a510 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 08:44:57 +0100 Subject: [PATCH 011/150] fix(halo): cast client_id to [int] on outbound ticket payload The HaloPSA mapping table stores IntegrationId as a string, which then threads through New-CippExtAlert -> New-HaloPSATicket and lands in the Tickets payload as e.g. "client_id":"57". Halo rejects this with the generic "Please select a valid Client/Site/User" error - the same class of bug we already fixed for site_id. Direct PowerShell test calls used integer literals so they didn't hit this; only the audit-log and scheduled-alert paths (which read the mapping from storage) tripped it. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 14f8d2c524676..5a40005888337 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -105,7 +105,7 @@ function New-HaloPSATicket { id = $UserLookupId lookupdisplay = $UserLookupDisplay } - client_id = ($client | Select-Object -Last 1) + client_id = [int]($client | Select-Object -Last 1) _forcereassign = $true site_id = $SiteId user_name = $UserNameValue From ec152c637b2205ab6b58b3c56f44344bb94972b7 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:03:54 +0100 Subject: [PATCH 012/150] fix(halo): match user lookup against all common identity fields Real-world HaloPSA contacts populated by AD/Azure AD sync often store the user's UPN in 'networklogin' or 'aaduserid' rather than (or in addition to) 'emailaddress'. The previous lookup only checked 'emailaddress', so a contact like Bob whose UPN was synced into 'networklogin' wouldn't match even though the data was right there. Now matches against: AzureOID -> azureoid, aaduserid Email -> emailaddress, networklogin, aaduserid Field lists are defined once at the top so adding more is a one-line change. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index f957a7a4ce04c..8786d4f826abe 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -4,9 +4,11 @@ function Get-HaloUser { Look up a HaloPSA user/contact for a Microsoft 365 end-user. .DESCRIPTION Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure - Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns a - small object containing the matched user's id and site_id (Halo requires both when a - specific user is set on a ticket), or $null when no match is found. + Object ID (against the HaloPSA contact fields 'azureoid' and 'aaduserid'), then falls back + to the user's email/UPN (against 'emailaddress', 'networklogin' and 'aaduserid' - Halo's + AD-sync contacts often store the UPN in any of these). Returns a small object containing + the matched user's id and site_id (Halo requires both when a specific user is set on a + ticket), or $null when no match is found. .PARAMETER AzureOID The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. .PARAMETER Email @@ -58,15 +60,31 @@ function Get-HaloUser { } } + # HaloPSA contacts can carry the user identity in several fields depending on how AD/Azure AD + # sync is set up. Match against all known candidates so partial integrations still resolve. + $AzureIdFields = @('azureoid', 'aaduserid') + $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') + + $MatchAny = { + param($Results, $Term, $Fields) + foreach ($Result in $Results) { + foreach ($Field in $Fields) { + $Value = $Result.$Field + if ($Value -and ($Value -ieq $Term)) { return $Result } + } + } + return $null + } + if ($AzureOID) { $Results = & $TrySearch $AzureOID - $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 + $Match = & $MatchAny $Results $AzureOID $AzureIdFields if ($Match) { return & $BuildResult $Match } } if ($Email) { $Results = & $TrySearch $Email - $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 + $Match = & $MatchAny $Results $Email $EmailFields if ($Match) { return & $BuildResult $Match } } From 9947afcb25f22ef0942d6496beec445086f951cd Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:08:54 +0100 Subject: [PATCH 013/150] fix(halo): combine search results before filtering on identity fields HaloPSA's /Users?search= parameter does not search the azureoid field, so an OID-only search returns zero rows even when a contact has that exact OID populated. Bob's contact in the test sandbox proved this: azureoid was set correctly but emailaddress/networklogin/aaduserid were all blank, so neither the OID search nor the email-field filter found him. Restructured to: 1. Run a search for each supplied term (AzureOID, Email) and merge the results into a deduped candidate pool keyed by user id. 2. Filter the pool preferring AzureOID matches against azureoid/aaduserid, then fall back to email matches against emailaddress/networklogin/ aaduserid. This way the email search brings the contact into scope and the OID filter catches it, even when none of the email-shaped fields are populated on the Halo record. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 8786d4f826abe..561ef19ac6e54 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -65,27 +65,42 @@ function Get-HaloUser { $AzureIdFields = @('azureoid', 'aaduserid') $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') - $MatchAny = { - param($Results, $Term, $Fields) - foreach ($Result in $Results) { - foreach ($Field in $Fields) { - $Value = $Result.$Field - if ($Value -and ($Value -ieq $Term)) { return $Result } + # Halo's /Users?search= parameter doesn't search the azureoid field, so a search by Object ID + # alone returns zero rows even when a contact has that exact OID set. Strategy: + # 1. Run a search per supplied term and combine the results into a deduped candidate pool. + # 2. Filter the pool client-side, preferring AzureOID matches (most reliable) over email. + $Candidates = @{} + foreach ($Term in @($AzureOID, $Email) | Where-Object { $_ }) { + foreach ($Result in (& $TrySearch $Term)) { + if ($Result.id -and -not $Candidates.ContainsKey([string]$Result.id)) { + $Candidates[[string]$Result.id] = $Result } } - return $null + } + + $MatchOnAnyField = { + param($User, $Term, $Fields) + foreach ($Field in $Fields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $Term)) { return $true } + } + return $false } if ($AzureOID) { - $Results = & $TrySearch $AzureOID - $Match = & $MatchAny $Results $AzureOID $AzureIdFields - if ($Match) { return & $BuildResult $Match } + foreach ($User in $Candidates.Values) { + if (& $MatchOnAnyField $User $AzureOID $AzureIdFields) { + return & $BuildResult $User + } + } } if ($Email) { - $Results = & $TrySearch $Email - $Match = & $MatchAny $Results $Email $EmailFields - if ($Match) { return & $BuildResult $Match } + foreach ($User in $Candidates.Values) { + if (& $MatchOnAnyField $User $Email $EmailFields) { + return & $BuildResult $User + } + } } return $null From aa681a17c90f52d3593c0a4be8b6cc1a33090a68 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:14:31 +0100 Subject: [PATCH 014/150] fix(halo): use advanced_search for exact field-level user lookup Halo's basic ?search= parameter only searches a fixed set of indexed fields (name, email, logins...) and notably NOT azureoid. So an OID-only lookup returned zero results even when a contact had that exact OID populated. Switch the AzureOID path to ?advanced_search= with filter_type=2 (equality) against azureoid and aaduserid in turn. This queries the underlying field directly and short-circuits as soon as we find a match, avoiding the previous fetch-and-filter dance for the common case. The email path still uses the basic search but now also checks the azureoid field on returned records, so contacts with azureoid set and blank email fields still match when both terms are supplied. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 91 +++++++++++-------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 561ef19ac6e54..9ed145df4b5c1 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -36,6 +36,38 @@ function Get-HaloUser { $Headers = @{ Authorization = "Bearer $($Token.access_token)" } $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" + $BuildResult = { + param($MatchedUser) + # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers + # doesn't serialise back as e.g. "95.0", which Halo rejects. + [pscustomobject]@{ + id = [int]$MatchedUser.id + site_id = [int]$MatchedUser.site_id + } + } + + # Halo's basic ?search= parameter only searches a fixed set of indexed fields (name, email, + # logins...) and notably NOT azureoid. Use ?advanced_search= with filter_type=2 (=) for an + # exact-match query against a specific field. + $TryAdvancedSearch = { + param($FilterName, $FilterValue) + try { + $Filter = ConvertTo-Json -Compress -InputObject @(@{ + filter_name = $FilterName + filter_type = 2 # 2 = exact equality + filter_value = $FilterValue + }) + $EncodedFilter = [System.Uri]::EscapeDataString($Filter) + $Response = Invoke-RestMethod -Uri "$BaseUri&advanced_search=$EncodedFilter" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + return @() + } + } + $TrySearch = { param($Term) try { @@ -50,55 +82,36 @@ function Get-HaloUser { } } - $BuildResult = { - param($MatchedUser) - # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers - # doesn't serialise back as e.g. "95.0", which Halo rejects. - [pscustomobject]@{ - id = [int]$MatchedUser.id - site_id = [int]$MatchedUser.site_id - } - } - # HaloPSA contacts can carry the user identity in several fields depending on how AD/Azure AD # sync is set up. Match against all known candidates so partial integrations still resolve. $AzureIdFields = @('azureoid', 'aaduserid') $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') - # Halo's /Users?search= parameter doesn't search the azureoid field, so a search by Object ID - # alone returns zero rows even when a contact has that exact OID set. Strategy: - # 1. Run a search per supplied term and combine the results into a deduped candidate pool. - # 2. Filter the pool client-side, preferring AzureOID matches (most reliable) over email. - $Candidates = @{} - foreach ($Term in @($AzureOID, $Email) | Where-Object { $_ }) { - foreach ($Result in (& $TrySearch $Term)) { - if ($Result.id -and -not $Candidates.ContainsKey([string]$Result.id)) { - $Candidates[[string]$Result.id] = $Result - } - } - } - - $MatchOnAnyField = { - param($User, $Term, $Fields) - foreach ($Field in $Fields) { - $Value = $User.$Field - if ($Value -and ($Value -ieq $Term)) { return $true } - } - return $false - } - + # Try AzureOID first via advanced_search - exact-match on each AD identifier field, returning + # the first hit. This is the most reliable path because Halo's azureoid field is the cleanest + # link back to the Entra user. if ($AzureOID) { - foreach ($User in $Candidates.Values) { - if (& $MatchOnAnyField $User $AzureOID $AzureIdFields) { - return & $BuildResult $User - } + foreach ($Field in $AzureIdFields) { + $Match = (& $TryAdvancedSearch $Field $AzureOID) | Where-Object { $_.id } | Select-Object -First 1 + if ($Match) { return & $BuildResult $Match } } } + # Fall back to email: the basic search indexes email-shaped fields and returns candidates; + # filter client-side against any of the email-bearing fields, and also re-check the AzureOID + # against returned records (handy when a contact has azureoid set but blank email fields). if ($Email) { - foreach ($User in $Candidates.Values) { - if (& $MatchOnAnyField $User $Email $EmailFields) { - return & $BuildResult $User + $Results = & $TrySearch $Email + foreach ($User in $Results) { + if ($AzureOID) { + foreach ($Field in $AzureIdFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $AzureOID)) { return & $BuildResult $User } + } + } + foreach ($Field in $EmailFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $Email)) { return & $BuildResult $User } } } } From 7283ce2de8b9dcc5eca751435b10cc8952f781d1 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 11:05:12 +0100 Subject: [PATCH 015/150] fix(halo): silence advanced_search noise and recover from note-add failures Two fixes for issues surfaced by real audit-log alerts: 1. Get-HaloUser: some Halo instances don't whitelist azureoid/aaduserid as filterable advanced_search fields, returning "Invalid advanced search parameter(s)". The email-search fallback already handles this case correctly, but every alert was emitting two Warning-level logbook entries. Now only log when the failure is something other than the expected "field not whitelisted" rejection. 2. New-HaloPSATicket: when ConsolidateTickets is on and a note-add to an existing ticket fails (permission error on the configured outcome, ticket type that doesn't accept the action, etc.), the function used to return the error and never create a new ticket. The alert was effectively lost. Now log the failure and fall through to creating a new ticket so the alert reaches the technician one way or another. Co-Authored-By: Claude Opus 4.7 --- .../CippExtensions/Public/Halo/Get-HaloUser.ps1 | 7 ++++++- .../Public/Halo/New-HaloPSATicket.ps1 | 14 ++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 9ed145df4b5c1..cc2df9f62cb99 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -63,7 +63,12 @@ function Get-HaloUser { return $Response } catch { $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } - Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + # Some Halo instances don't whitelist these fields for advanced_search even though they + # exist on the user record. That's expected - the email-search fallback handles it. Only + # log unexpected failures. + if ($Message -notmatch 'Invalid advanced search parameter') { + Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + } return @() } } diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 5a40005888337..e2eb1eaf60743 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -59,12 +59,13 @@ function New-HaloPSATicket { } $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) + $NoteAdded = $false try { if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } Write-Information "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" + $NoteAdded = $true } - return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } catch { $Message = if ($_.ErrorDetails.Message) { @@ -73,10 +74,15 @@ function New-HaloPSATicket { else { $_.Exception.message } - Write-LogMessage -message "Failed to add note to HaloPSA ticket: $Message" -API 'HaloPSATicket' -sev Error -LogData (Get-CippException -Exception $_) - Write-Information "Failed to add note to HaloPSA ticket: $Message" + # Don't return here - if appending a note failed (e.g. permissions on the action, + # invalid outcome_id) we still want to create a fresh ticket so the alert isn't lost. + Write-LogMessage -message "Failed to add note to HaloPSA ticket $($ExistingTicket.TicketID): $Message - falling back to creating a new ticket" -API 'HaloPSATicket' -sev Warning -LogData (Get-CippException -Exception $_) + Write-Information "Failed to add note to HaloPSA ticket: $Message; creating a new ticket instead" Write-Information "Body we tried to ship: $body" - return "Failed to add note to HaloPSA ticket: $Message" + } + + if ($NoteAdded) { + return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } } } From 1e83607197caa28569ddcb0b058d7d11aba71f0c Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Fri, 15 May 2026 10:03:29 +0100 Subject: [PATCH 016/150] feat(alerts): per-task PsaTicketStrategy override for the HaloPSA splitter Adds a per-scheduled-alert setting that can override the global HaloPSA.LinkTicketsToUsers toggle on a single alert basis. Lets MSPs keep wide alerts like 'users without MFA' as one consolidated ticket per tenant when individual tickets would flood their PSA, while still splitting per-user for the alerts where granularity matters. Stored as a new column 'PsaTicketStrategy' on the ScheduledTasks row with values: - 'split' - always one ticket per affected user - 'consolidated' - always one ticket per tenant - '' / null - inherit the integration's global setting (default) Send-CIPPScheduledTaskAlert resolves the per-task strategy first and only falls back to the global toggle when the task hasn't expressed a preference, so existing alerts created before this change keep working exactly as before. --- Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 1 + .../CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index f9a5c01599239..e87479a95e857 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -201,6 +201,7 @@ function Add-CIPPScheduledTask { Results = 'Planned' AlertComment = [string]$task.AlertComment CustomSubject = [string]$task.CustomSubject + PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy) } diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index df99f76f01bc9..543588c90fc75 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -148,7 +148,18 @@ function Send-CIPPScheduledTaskAlert { $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue $HaloConfig = $ExtConfig.HaloPSA - if ($HaloConfig -and $HaloConfig.Enabled -and $HaloConfig.LinkTicketsToUsers) { + # Per-task PsaTicketStrategy (configured on the alert) overrides the global + # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis + # whether a wide alert (e.g. "users without MFA") should produce one ticket + # per user or one consolidated ticket per tenant. + $TaskStrategy = $TaskInfo.PsaTicketStrategy + $ShouldSplit = switch ($TaskStrategy) { + 'split' { $true } + 'consolidated' { $false } + default { [bool]$HaloConfig.LinkTicketsToUsers } + } + + if ($HaloConfig -and $HaloConfig.Enabled -and $ShouldSplit) { $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') $RowProperties = $Results[0].PSObject.Properties.Name $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 From 7d02b0b141d7b19e5f618d014f97b744f12ddf9b Mon Sep 17 00:00:00 2001 From: computersunplugged Date: Mon, 8 Jun 2026 11:19:55 +1000 Subject: [PATCH 017/150] Major Update Updated to write to and read from cetralised CIPP database --- Config/CIPPDBCacheTypes.json | 5 + Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 | 164 ++++++++++++++++++ Modules/CIPPCore/Public/Get-DefenderCves.ps1 | 127 ++++++++++++++ .../CIPPCore/Public/Get-DefenderTvmRaw.ps1 | 65 +++++++ .../Public/Invoke-CIPPDBCacheCollection.ps1 | 6 +- Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 | 31 ++++ .../DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 | 135 ++++++++++++++ .../MEM/Invoke-ExecAddCippCveException.ps1 | 113 ++++++++++++ .../MEM/Invoke-ExecRemoveCippCveException.ps1 | 77 ++++++++ .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 162 +++++++++++++++++ .../NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 | 133 ++++++++++++++ .../Invoke-NinjaOneExtensionScheduler.ps1 | 22 ++- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 97 +++++++++++ .../NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 | 126 ++++++++++++++ .../Public/NinjaOne/Push-NinjaOneQueue.ps1 | 7 +- 15 files changed, 1264 insertions(+), 6 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 create mode 100644 Modules/CIPPCore/Public/Get-DefenderCves.ps1 create mode 100644 Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 create mode 100644 Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 create mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 create mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index eb833092befd3..c85e07fbba9aa 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -368,5 +368,10 @@ "type": "ExoTransportConfig", "friendlyName": "Exchange Transport Config", "description": "Exchange Online transport configuration including SMTP authentication settings" + }, + { + "type": "DefenderCVEs", + "friendlyName": "Defender CVEs", + "description": "All Defender CVEs for Devices" } ] diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 new file mode 100644 index 0000000000000..6023e78108da3 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -0,0 +1,164 @@ +function Get-CIPPCVEReport { + <# + .SYNOPSIS + Generates a CVE report from the CIPP Reporting database + + .DESCRIPTION + Retrieves Defender CVE data for a tenant from the reporting database + Optimized for high-performance cross-referencing and memory efficiency. + + .PARAMETER TenantFilter + The tenant to generate the report for, or 'AllTenants' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllCachedCves = $RawCveData.Data | ConvertFrom-Json + + # Filter results by Tenant + $RawCveItems = [System.Collections.Generic.List[object]]::new() + + if ($TenantFilter -eq 'AllTenants') { + # Validate against active tenants to ensure we don't return orphaned data + $TenantList = Get-Tenants -IncludeErrors + foreach ($Item in $AllCachedCves) { + if ($TenantList.defaultDomainName -contains $Item.customerId) { + [void]$RawCveItems.Add($Item) + } + } + } else { + $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + foreach ($Item in $AllCachedCves) { + if ($Item.customerId -eq $TenantFilter) { + [void]$RawCveItems.Add($Item) + } + } + } + + if ($RawCveItems.Count -eq 0) { + return @() + } + + # Build filtered exception items + foreach ($Ex in $AllExceptions) { + if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL'){ + if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { + $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() + } + + [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) + } + } + + # Process raw CVE items + $CveMasterTable = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + + if ($ExceptionsByCve.ContainsKey($CveKey)){ + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionType = $_.exceptionType } } }else{''} + exceptionComment = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } }else{''} + exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} + exceptionDate = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } }else{''} + exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } }else{''} + cacheTimeStamp = $Target.lastUpdated + }) + } + + return $SortedCves | Sort-Object -Property cveId + + } catch { + Write-LogMessage -API 'CVEReport' -tenant $TenantFilter -message "Failed to generate CVE report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 new file mode 100644 index 0000000000000..0a36aa2930663 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 @@ -0,0 +1,127 @@ +function get-DefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress + + [void]$Entities.Add(@{ + PartitionKey = $CveKey + RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate + recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl + exploitabilityLevel = $CveData.exploitabilityLevel + + # Meta aggregation counts + deviceCount = $CveData.AffectedDevices.Count + + # All individual device variations compressed safely inside a single field + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $UniqueCves Unique CVEs" -sev 'Info' + + return $Entities + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 new file mode 100644 index 0000000000000..7e1b567738dba --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -0,0 +1,65 @@ +function Get-DefenderTvmRaw { + <# + .SYNOPSIS + Fetch Defender TVM SoftwareVulnerabilitiesByMachine with paging. + .PARAMETER TenantId + Microsoft Entra tenant id to query. + .PARAMETER MaxPages + Optional page cap (0 = no cap). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TenantId, + [int]$MaxPages = 0 + ) + + $scope = 'https://api.securitycenter.microsoft.com/.default' + $uri = 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' + $all = New-Object System.Collections.Generic.List[object] + $page = 0 + + try { + do { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Fetching page $($page + 1)" -Sev 'Debug' + + $resp = New-GraphGetRequest -tenantid $TenantId -uri $uri -scope $scope + + if ($resp -is [System.Collections.IDictionary]) { + if ($resp.ContainsKey('value')) { + $rows = $resp.value + $nextLink = $resp.'@odata.nextLink' + if ($rows) { $all.AddRange($rows) } + $uri = $nextLink + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Page $($page + 1): $($rows.Count) records" -Sev 'Debug' + } + else { + $all.Add($resp) + $uri = $null + } + } + elseif ($resp -is [System.Collections.IEnumerable] -and $resp -isnot [string]) { + $all.AddRange($resp) + $uri = $null + } + else { + $all.Add($resp) + $uri = $null + } + + $page++ + + if ($page -gt 100) { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Reached 100 page safety limit — stopping" -Sev 'Warning' + break + } + + } while ($uri -and ($MaxPages -eq 0 -or $page -lt $MaxPages)) + + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Defender TVM fetch complete: $($all.Count) records across $page page(s)" -Sev 'Info' + return $all + } + catch { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Error on page $page`: $($_.Exception.Message)" -Sev 'Error' + throw + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 2538fc8493fb8..579c38c4da9e1 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -15,6 +15,7 @@ function Invoke-CIPPDBCacheCollection { - ConditionalAccess: CA policies and registration details - IdentityProtection: Risky users/SPs, risk detections, PIM - Intune: Managed devices, policies, app protection + - Defender: Defender Vulnerabilities .PARAMETER CollectionType The group of cache functions to execute @@ -31,7 +32,7 @@ function Invoke-CIPPDBCacheCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams')] + [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams', 'Defender')] [string]$CollectionType, [Parameter(Mandatory = $true)] @@ -151,6 +152,9 @@ function Invoke-CIPPDBCacheCollection { 'TeamsActivity' 'TeamsVoice' ) + Defender = @( + 'DefenderCVEs' + ) } $CacheTypes = $Collections[$CollectionType] diff --git a/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 new file mode 100644 index 0000000000000..be860ac91a379 --- /dev/null +++ b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 @@ -0,0 +1,31 @@ +function New-VulnCsvBytes { + <# + .SYNOPSIS + Build a CSV payload (UTF-8 bytes) from objects with explicit headers. + .PARAMETER Rows + Array of PSCustomObject where property names match the provided headers. + .PARAMETER Headers + Ordered list of column headers (and property names). + #> + [CmdletBinding()] + param( + [Parameter()][object[]]$Rows = @(), + [Parameter(Mandatory)][string[]]$Headers + ) + + $Sb = [System.Text.StringBuilder]::new() + [void]$Sb.AppendLine(($Headers -join ',')) + + foreach ($Row in $Rows) { + $Cells = foreach ($Header in $Headers) { + $Val = $Row.$Header + if ($null -ne $Val) { + $S = [string]$Val + if ($S -match '[,"\r\n]') { '"' + ($S -replace '"', '""') + '"' } else { $S } + } else { '' } + } + [void]$Sb.AppendLine(($Cells -join ',')) + } + + return [System.Text.Encoding]::UTF8.GetBytes($Sb.ToString()) +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 new file mode 100644 index 0000000000000..cc7f898ef4825 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -0,0 +1,135 @@ +function Set-CIPPDBCacheDefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress + + [void]$Entities.Add(@{ + PartitionKey = $CveKey + RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate + recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl + exploitabilityLevel = $CveData.exploitabilityLevel + + # Meta aggregation counts + deviceCount = $CveData.AffectedDevices.Count + + # All individual device variations compressed safely inside a single field + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Entities: ($Entities | Out-String)" -sev 'Info' + $Entities | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' -AddCount + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 new file mode 100644 index 0000000000000..060ac28b2d690 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 @@ -0,0 +1,113 @@ +function Invoke-ExecAddCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = [string]$Request.Body.cveId + $ExceptionType = [string]$Request.Body.exceptionType + $ApplyTo = [string]$Request.Body.applyTo + $Justification = [string]$Request.Body.justification + $ExpiryDate = if ($Request.Body.expiryDate) { [string]$Request.Body.expiryDate } else { $null } + + if (-not $CveId -or -not $ExceptionType -or -not $ApplyTo -or -not $Justification) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId, exceptionType, applyTo, and justification are required' } + } + } + + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $CveCacheTable = Get-CIPPTable -TableName 'CveCache' + + # Load all existing exceptions for this CVE + $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" + + $TenantsToUpdate = switch ($ApplyTo) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw "Current tenant must be selected to use 'Current Tenant Only' option" + } + @($TenantFilter) + } + 'AllAffected' { + $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AffectedEntries = $RawCveData.Data | ConvertFrom-Json | -Filter "PartitionKey eq '$CveId'" + @($AffectedEntries | Select-Object -ExpandProperty customerId -Unique) + } + 'Global' { + @('ALL') + } + default { + throw "Invalid applyTo value: $ApplyTo" + } + } + + $Username = $Headers.'x-ms-client-principal-name' + $CurrentDate = (Get-Date).ToUniversalTime().ToString('o') + $ReadableDate = (Get-Date).ToString() + + $ExceptionsAdded = [System.Collections.Generic.List[string]]::new() + $ExceptionsUpdated = [System.Collections.Generic.List[string]]::new() + + # Build all exception entities in memory, track add vs update + $ExceptionEntities = foreach ($TenantId in $TenantsToUpdate) { + $ExistingException = $AllCveExceptions | Where-Object { $_.RowKey -eq $TenantId } + + if ($ExistingException) { + [void]$ExceptionsUpdated.Add($TenantId) + } else { + [void]$ExceptionsAdded.Add($TenantId) + } + + @{ + PartitionKey = [string]$CveId + RowKey = [string]$TenantId + cveId = [string]$CveId + customerId = [string]$TenantId + exceptionType = [string]$ExceptionType + exceptionComment = [string]$Justification + exceptionCreatedBy = [string]$Username + exceptionCreatedDate = [string]$CurrentDate + exceptionReadableDate = [string]$ReadableDate + exceptionExpiry = $ExpiryDate ?? '' + source = 'CIPP' + } + } + + # Write all exception entities in one batch + if (@($ExceptionEntities).Count -gt 0) { + Add-CIPPAzDataTableEntity @CveExceptionsTable -Entity @($ExceptionEntities) -Force + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Added/updated CVE exception for $CveId across $($TenantsToUpdate.Count) tenant(s)" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Results = "Successfully applied exception to CVE $CveId" + TenantsAffected = $TenantsToUpdate.Count + ExceptionsAdded = $ExceptionsAdded.Count + ExceptionsUpdated = $ExceptionsUpdated.Count + Details = "Added: $($ExceptionsAdded -join ', '), Updated: $($ExceptionsUpdated -join ', ')" + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to add CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to add exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 new file mode 100644 index 0000000000000..a263d81176ac0 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 @@ -0,0 +1,77 @@ +function Invoke-ExecRemoveCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = $Request.Query.cveId ?? $Request.Body.cveId + $RemoveScope = $Request.Query.removeScope ?? $Request.Body.removeScope + + if (-not $CveId) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId is required' } + } + } + + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + + # Load all exceptions for this CVE + $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" + + $ExceptionsToRemove = switch ($RemoveScope) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw 'Current tenant must be selected' + } + @($TenantFilter) + } + 'AllAffected' { + @($AllCveExceptions | Where-Object { $_.RowKey -ne 'ALL' } | Select-Object -ExpandProperty RowKey) + } + 'Global' { + @('ALL') + } + default { + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + @($TenantFilter) + } else { + throw 'removeScope must be specified when no tenant is selected' + } + } + } + + # Remove matched exception entities + $EntitiesToRemove = $AllCveExceptions | Where-Object { $_.RowKey -in $ExceptionsToRemove } + $RemovedCount = 0 + + foreach ($Entity in $EntitiesToRemove) { + Remove-AzDataTableEntity @CveExceptionsTable -Entity $Entity -Force + $RemovedCount++ + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed $RemovedCount CVE exception(s) for $CveId" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = "Successfully removed $RemovedCount exception(s) for CVE $CveId" } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to remove CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to remove exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 new file mode 100644 index 0000000000000..33c5a561001db --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -0,0 +1,162 @@ +function Invoke-ListCVEManagement { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Security.Read + #> + + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + + if ($UseReportDB -eq 'true'){ + try { + $GraphRequest = Get-CIPPCVEReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + $SortedCves = $GraphRequest + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "running cve report" -sev 'info' + } catch { + Write-Host "Error retrieving CVEs from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "Error retrieving" -sev 'info' + } + }else{ + try { + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "retrieving CVEs" -sev 'info' + $GraphRequest = get-DefenderCVEs -TenantFilter $TenantFilter + + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveItems = $GraphRequest + $AllCachedCves = $RawCveData + + $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + + if ($RawCveItems.Count -eq 0) { + return @() + } + + foreach ($Ex in $AllExceptions) { + if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL'){ + if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { + $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() + } + + [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) + } + } + + # Merge all results + $CveMasterTable = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + + if ($ExceptionsByCve.ContainsKey($CveKey)){ + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionType = $_.exceptionType } } }else{''} + exceptionComment = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } }else{''} + exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} + exceptionDate = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } }else{''} + exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } }else{''} + cacheTimeStamp = $Target.lastUpdated + }) + $StatusCode = [HttpStatusCode]::OK + } + + } catch { + Write-Host "Error retrieving CVEs: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + } + Return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($SortedCves | Sort-Object -Property cveId) + } +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 new file mode 100644 index 0000000000000..2cb023761abf9 --- /dev/null +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 @@ -0,0 +1,133 @@ +function Invoke-NinjaOneCveSyncTenant { + [CmdletBinding()] + param ( + $QueueItem + ) + + try { + $MappedTenant = $QueueItem.MappedTenant + + $Customer = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -eq $MappedTenant.RowKey } + + if (($Customer | Measure-Object).count -ne 1) { + throw "Unable to match the received ID to a tenant. QueueItem: $($QueueItem | ConvertTo-Json -Depth 10 | Out-String)" + } + + $TenantFilter = $Customer.defaultDomainName + + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Starting CVE sync for $($Customer.displayName)" -sev 'Info' + + # Load NinjaOne config + $Table = Get-CIPPTable -TableName Extensionsconfig + $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).NinjaOne + + if (-not $Configuration -or -not $Configuration.Instance) { + throw 'NinjaOne configuration is missing or incomplete' + } + + $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' + $ScanGroupName = "$ScanGroupPrefix$TenantFilter" + $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" + + # Get token + $Token = Get-NinjaOneToken -configuration $Configuration + + if (-not $Token -or -not $Token.access_token) { + throw 'Failed to retrieve NinjaOne access token' + } + + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + + # Get scan groups + $ScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop + + if (-not $ScanGroups) { + throw 'Scan groups response was empty' + } + + # Resolve scan group for this tenant + $ResolvedScanGroup = $ScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + + if (-not $ResolvedScanGroup) { + $Available = ($ScanGroups | Select-Object -First 10 | ForEach-Object { "ID $($_.id): $($_.groupName)" }) -join ', ' + throw "Scan group '$ScanGroupName' not found. Available: $Available" + } + + $ResolvedScanGroupId = $ResolvedScanGroup.id + $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader + $CveIdHeader = $ResolvedScanGroup.cveIdHeader + + if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { + throw 'Scan group missing required header config' + } + + # Pull Defender TVM data + $AllVulns = Get-CIPPCVEReport -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message 'No vulnerability data returned — skipping' -sev 'Warning' + return $true + } + + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) vulnerabilities" -sev 'Info' + + # Filter CIPP exceptions + $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable + $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } + + if ($ApplicableExceptions) { + $ExceptedCveIds = $ApplicableExceptions | Select-Object -ExpandProperty cveId -Unique + $BeforeCount = $AllVulns.Count + $AllVulns = $AllVulns | Where-Object { $_.cveId -notin $ExceptedCveIds } + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Filtered $($BeforeCount - $AllVulns.Count) excepted CVEs — $($AllVulns.Count) remaining" -sev 'Info' + } + + # Build CSV rows + $CsvRows = [System.Collections.Generic.List[object]]::new() + $SkippedCount = 0 + + foreach ($Item in $AllVulns) { + if ([string]::IsNullOrWhiteSpace($Item.cveId) -or [string]::IsNullOrWhiteSpace($Item.deviceName)) { + $SkippedCount++ + continue + } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Item.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + + $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + + if (-not $CsvBytes -or $CsvBytes.Length -eq 0) { + throw 'Failed to generate CSV bytes' + } + + # Upload and poll for completion + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $Response = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers $Headers + + $FinalStatus = $Response.status ?? 'unknown' + $ProcessedCount = $Response.recordsProcessed ?? '?' + + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Warning' + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Failed CVE sync: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + return $true +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 index 7dd47505a39c7..0c160259c92ee 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 @@ -42,6 +42,15 @@ function Invoke-NinjaOneExtensionScheduler { 'FunctionName' = 'NinjaOneQueue' } } + + $CveBatch = foreach ($Tenant in $TenantsToProcess) { + [PSCustomObject]@{ + 'NinjaAction' = 'CveSyncTenant' + 'MappedTenant' = $Tenant + 'FunctionName' = 'NinjaOneQueue' + } + } + if (($Batch | Measure-Object).Count -gt 0) { $InputObject = [PSCustomObject]@{ OrchestratorName = 'NinjaOneOrchestrator' @@ -52,6 +61,15 @@ function Invoke-NinjaOneExtensionScheduler { Write-Host "Started permissions orchestration with ID = '$InstanceId'" } + if (($CveBatch | Measure-Object).Count -gt 0) { + $CveInputObject = [PSCustomObject]@{ + OrchestratorName = 'NinjaOneOrchestrator' + Batch = @($CveBatch) + } + $CveInstanceId = Start-CIPPOrchestrator -InputObject $CveInputObject + Write-Host "Started CVE sync orchestration with ID = '$CveInstanceId'" + } + $AddObject = @{ PartitionKey = 'NinjaConfig' RowKey = 'NinjaLastRunTime' @@ -59,7 +77,7 @@ function Invoke-NinjaOneExtensionScheduler { } Add-AzDataTableEntity @Table -Entity $AddObject -Force - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' } else { if ($LastRunTime -lt (Get-Date).AddMinutes(-90)) { @@ -95,7 +113,7 @@ function Invoke-NinjaOneExtensionScheduler { } if (($CatchupTenants | Measure-Object).count -gt 0) { - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' } } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 5c6753fc6de9e..6ceb616c16fbc 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1846,6 +1846,10 @@ function Invoke-NinjaOneTenantSync { ### CIPP Applied Standards Cards + $ModuleBase = Get-Module CIPPExtensions | Select-Object -ExpandProperty ModuleBase + $CIPPRoot = (Get-Item $ModuleBase).Parent.Parent.FullName + Set-Location $CIPPRoot + try { $StandardsDefinitions = Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/KelvinTegelaar/CIPP/refs/heads/main/src/data/standards.json' $AppliedStandards = Get-CIPPStandards -TenantFilter $Customer.defaultDomainName @@ -2195,6 +2199,99 @@ function Invoke-NinjaOneTenantSync { $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/organization/$($MappedTenant.IntegrationId)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaOrgUpdate | ConvertTo-Json -Depth 100) + + # CVE Sync — runs as part of tenant sync if enabled + if ($Configuration.CveSyncEnabled -eq $true) { + try { + $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' + $ScanGroupName = "$ScanGroupPrefix$TenantFilter" + $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" + + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' + } else { + $ResolvedScanGroupId = $ResolvedScanGroup.id + $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader + $CveIdHeader = $ResolvedScanGroup.cveIdHeader + + if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group missing required header config" -sev 'Warning' + } else { + $RawVulns = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllVulns = $RawVulns.Data | ConvertFrom-Json + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' + } else { + $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable + $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } + + if ($ApplicableExceptions) { + $ExceptedCveIds = $ApplicableExceptions | Select-Object -ExpandProperty cveId -Unique + $BeforeCount = $AllVulns.Count + $AllVulns = $AllVulns | Where-Object { $_.cveId -notin $ExceptedCveIds } + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — filtered $($BeforeCount - $AllVulns.Count) excepted CVEs, $($AllVulns.Count) remaining" -sev 'Info' + } + + $CsvRows = [System.Collections.Generic.List[object]]::new() + $SkippedCount = 0 + + foreach ($Item in $AllVulns) { + if ([string]::IsNullOrWhiteSpace($Item.cveId)) { + $SkippedCount++ + continue + } + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + $AffectedDevices = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Dev in $Devices) { + [void]$AffectedDevices.Add(@{ deviceName = $Dev.deviceName }) + } + } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + + $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + + if ($CsvBytes -and $CsvBytes.Length -gt 0) { + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $CveResp = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers @{ Authorization = "Bearer $($Token.access_token)" } + + $FinalStatus = $CveResp.status ?? 'unknown' + $ProcessedCount = $CveResp.recordsProcessed ?? '?' + + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed" -sev 'Warning' + } + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — failed to generate CSV bytes' -sev 'Warning' + } + } + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + # Do not rethrow — CVE sync failure should not fail the whole tenant sync + } + } + Write-Information 'Cleaning Users Cache' if (($ParsedUsers | Measure-Object).count -gt 0) { Remove-AzDataTableEntity -Force @UsersTable -Entity ($ParsedUsers | Select-Object PartitionKey, RowKey) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 new file mode 100644 index 0000000000000..f6c65c9788d67 --- /dev/null +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 @@ -0,0 +1,126 @@ +function Invoke-NinjaOneVulnCsvUpload { + <# + .SYNOPSIS + Upload CVE CSV to NinjaOne vulnerability scan group via multipart POST, + then poll until processing completes. Retries the full upload+poll cycle + on transient failures or a FAILED processing status. + .PARAMETER Uri + Full NinjaOne API upload URI including scan group ID. + .PARAMETER PollUri + NinjaOne API URI for the scan group (GET) used to poll processing status. + .PARAMETER CsvBytes + UTF-8 encoded CSV payload as a byte array. + .PARAMETER Headers + Hashtable of HTTP headers including Authorization bearer token. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$PollUri, + [Parameter(Mandatory)][byte[]]$CsvBytes, + [Parameter(Mandatory)][hashtable]$Headers + ) + + $Boundary = [System.Guid]::NewGuid().ToString() + $LF = "`r`n" + $MaxRetries = 5 + $RetryDelay = 5 + $PollDelay = 10 + $MaxPolls = 18 + $Attempt = 0 + + $BodyLines = @( + "--$Boundary" + 'Content-Disposition: form-data; name="csv"; filename="cve.csv"' + 'Content-Type: text/csv' + '' + ) + + $HeaderText = $BodyLines -join $LF + $HeaderBytes = [System.Text.Encoding]::UTF8.GetBytes($HeaderText + $LF) + + $TrailerText = "$LF--$Boundary--$LF" + $TrailerBytes = [System.Text.Encoding]::UTF8.GetBytes($TrailerText) + + while ($Attempt -le $MaxRetries) { + $Mem = [System.IO.MemoryStream]::new() + try { + $Mem.Write($HeaderBytes, 0, $HeaderBytes.Length) + $Mem.Write($CsvBytes, 0, $CsvBytes.Length) + $Mem.Write($TrailerBytes, 0, $TrailerBytes.Length) + $Mem.Position = 0 + + if ($Attempt -eq 0) { + Write-LogMessage -API 'NinjaOne' -message "Uploading CVE CSV to NinjaOne ($($CsvBytes.Length) bytes)" -sev 'Debug' + } else { + Write-LogMessage -API 'NinjaOne' -message "Retrying CVE CSV upload (attempt $Attempt of $MaxRetries)" -sev 'Warning' + } + + $Resp = Invoke-RestMethod -Method POST -Uri $Uri ` + -Headers $Headers ` + -ContentType "multipart/form-data; boundary=$Boundary" ` + -Body $Mem ` + -ErrorAction Stop + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + + # Do not retry on 404 — scan group not found is a config issue, not transient + if ($_.Exception.Response.StatusCode.value__ -eq 404) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (404 — scan group not found, not retrying): $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed after $MaxRetries retries: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + } finally { + $Mem.Dispose() + } + + # Upload accepted — poll until no longer IN_PROGRESS + if ($Resp.status -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOne' -message "Upload accepted, polling for completion (max $($MaxPolls * $PollDelay)s)" -sev 'Debug' + + $PollCount = 0 + while ($Resp.status -eq 'IN_PROGRESS' -and $PollCount -lt $MaxPolls) { + Start-Sleep -Seconds $PollDelay + $PollCount++ + try { + $Resp = Invoke-RestMethod -Method Get -Uri $PollUri -Headers $Headers -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOne' -message "Poll failed on attempt $PollCount — will retry: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + } + } + + if ($Resp.status -eq 'IN_PROGRESS') { + # Timed out waiting — upload succeeded but processing is still running + Write-LogMessage -API 'NinjaOne' -message "Polling timed out after $($MaxPolls * $PollDelay)s — upload accepted by NinjaOne but processing status unknown" -sev 'Warning' + return $Resp + } + } + + # FAILED status — treat as retryable + if ($Resp.status -eq 'FAILED') { + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s" -sev 'Warning' + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status after $MaxRetries retries — giving up" -sev 'Error' + return $Resp + } + } + + # COMPLETE or any other terminal status — return + return $Resp + } +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 index 470220016842b..d5b85d58e372c 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 @@ -7,9 +7,10 @@ function Push-NinjaOneQueue { Switch ($Item.NinjaAction) { 'StartAutoMapping' { Invoke-NinjaOneOrgMapping } - 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } - 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } - 'SyncTenants' { Invoke-NinjaOneSync } + 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } + 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } + 'SyncTenants' { Invoke-NinjaOneSync } + 'CveSyncTenant' { Invoke-NinjaOneCveSyncTenant -QueueItem $Item } } return $true } From b68c93fd940c96812b6266e75f3bdcc6ac4c4fe8 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 8 Jun 2026 22:14:00 +1000 Subject: [PATCH 018/150] Update Push-CIPPDBCacheData.ps1 Added license check for new Defender category Signed-off-by: DamienMatthys --- .../Push-CIPPDBCacheData.ps1 | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 074278d77cbb3..24f93b25fd47e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -65,6 +65,14 @@ function Push-CIPPDBCacheData { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } + $DefenderCapable = $false + try { + $DefenderCapable = Test-CIPPStandardLicense -StandardName Compliance'DefenderLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MDE_SMB', 'WIN_DEF_ATP', 'DEFENDER_ENDPOINT_P1') -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + $SharePointCapable = $false try { $SharePointCapable = Test-CIPPStandardLicense -StandardName 'SharePointLicenseCheck' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog @@ -190,6 +198,18 @@ function Push-CIPPDBCacheData { } else { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } + + if ($DefenderCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'Defender' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache Defender - $TenantFilter" + }) + } else { + Write-Host "Skipping Defender data collection for $TenantFilter - no required license" + } if ($SharePointCapable) { $Tasks.Add(@{ From 4c020dd9eac4544f1dadbf445b381fd61046e256 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 8 Jun 2026 22:16:03 +1000 Subject: [PATCH 019/150] Update Set-CIPPDBCacheDefenderCVEs.ps1 Cleaned up logging Signed-off-by: DamienMatthys --- .../CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 index cc7f898ef4825..f6cf4850f65fb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheDefenderCVEs { return } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Debug' try{ # Initialize a tracker for this tenant session $CveAggregator = @{} @@ -116,17 +116,17 @@ function Set-CIPPDBCacheDefenderCVEs { $SuccessCount = 0 $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count try { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Entities: ($Entities | Out-String)" -sev 'Info' + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $UniqueCves CVEs" -sev 'Info' $Entities | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' -AddCount } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage } - $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count - } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage From 85ef802da7d0f7144786fa045625561d617d93c0 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:14:02 +1000 Subject: [PATCH 020/150] Delete Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 Remove old sync function Signed-off-by: DamienMatthys --- .../NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 | 133 ------------------ 1 file changed, 133 deletions(-) delete mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 deleted file mode 100644 index 2cb023761abf9..0000000000000 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 +++ /dev/null @@ -1,133 +0,0 @@ -function Invoke-NinjaOneCveSyncTenant { - [CmdletBinding()] - param ( - $QueueItem - ) - - try { - $MappedTenant = $QueueItem.MappedTenant - - $Customer = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -eq $MappedTenant.RowKey } - - if (($Customer | Measure-Object).count -ne 1) { - throw "Unable to match the received ID to a tenant. QueueItem: $($QueueItem | ConvertTo-Json -Depth 10 | Out-String)" - } - - $TenantFilter = $Customer.defaultDomainName - - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Starting CVE sync for $($Customer.displayName)" -sev 'Info' - - # Load NinjaOne config - $Table = Get-CIPPTable -TableName Extensionsconfig - $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).NinjaOne - - if (-not $Configuration -or -not $Configuration.Instance) { - throw 'NinjaOne configuration is missing or incomplete' - } - - $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' - $ScanGroupName = "$ScanGroupPrefix$TenantFilter" - $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" - - # Get token - $Token = Get-NinjaOneToken -configuration $Configuration - - if (-not $Token -or -not $Token.access_token) { - throw 'Failed to retrieve NinjaOne access token' - } - - $Headers = @{ Authorization = "Bearer $($Token.access_token)" } - - # Get scan groups - $ScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop - - if (-not $ScanGroups) { - throw 'Scan groups response was empty' - } - - # Resolve scan group for this tenant - $ResolvedScanGroup = $ScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } - - if (-not $ResolvedScanGroup) { - $Available = ($ScanGroups | Select-Object -First 10 | ForEach-Object { "ID $($_.id): $($_.groupName)" }) -join ', ' - throw "Scan group '$ScanGroupName' not found. Available: $Available" - } - - $ResolvedScanGroupId = $ResolvedScanGroup.id - $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader - $CveIdHeader = $ResolvedScanGroup.cveIdHeader - - if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { - throw 'Scan group missing required header config' - } - - # Pull Defender TVM data - $AllVulns = Get-CIPPCVEReport -TenantId $TenantFilter -MaxPages 0 - - if (-not $AllVulns) { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message 'No vulnerability data returned — skipping' -sev 'Warning' - return $true - } - - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) vulnerabilities" -sev 'Info' - - # Filter CIPP exceptions - $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' - $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable - $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } - - if ($ApplicableExceptions) { - $ExceptedCveIds = $ApplicableExceptions | Select-Object -ExpandProperty cveId -Unique - $BeforeCount = $AllVulns.Count - $AllVulns = $AllVulns | Where-Object { $_.cveId -notin $ExceptedCveIds } - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Filtered $($BeforeCount - $AllVulns.Count) excepted CVEs — $($AllVulns.Count) remaining" -sev 'Info' - } - - # Build CSV rows - $CsvRows = [System.Collections.Generic.List[object]]::new() - $SkippedCount = 0 - - foreach ($Item in $AllVulns) { - if ([string]::IsNullOrWhiteSpace($Item.cveId) -or [string]::IsNullOrWhiteSpace($Item.deviceName)) { - $SkippedCount++ - continue - } - [void]$CsvRows.Add([PSCustomObject]@{ - $DeviceIdHeader = $Item.deviceName.Trim() - $CveIdHeader = $Item.cveId.Trim() - }) - } - - if ($SkippedCount -gt 0) { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' - } - - $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) - - if (-not $CsvBytes -or $CsvBytes.Length -eq 0) { - throw 'Failed to generate CSV bytes' - } - - # Upload and poll for completion - $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" - $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" - $Response = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers $Headers - - $FinalStatus = $Response.status ?? 'unknown' - $ProcessedCount = $Response.recordsProcessed ?? '?' - - if ($FinalStatus -eq 'COMPLETE') { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Info' - } elseif ($FinalStatus -eq 'IN_PROGRESS') { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' - } else { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Warning' - } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Failed CVE sync: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage - } - - return $true -} From 7ae8fcdbda8f6fa30058cd704c6667f1f6e3739c Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:14:35 +1000 Subject: [PATCH 021/150] Update Push-NinjaOneQueue.ps1 Remove old sync schedule Signed-off-by: DamienMatthys --- Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 index d5b85d58e372c..feff03bc7a207 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 @@ -10,7 +10,6 @@ function Push-NinjaOneQueue { 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } 'SyncTenants' { Invoke-NinjaOneSync } - 'CveSyncTenant' { Invoke-NinjaOneCveSyncTenant -QueueItem $Item } } return $true } From 7420db61543e2f8a255e72aab01e00dd0ed805d1 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:18:24 +1000 Subject: [PATCH 022/150] Update Invoke-NinjaOneTenantSync.ps1 Fixed sync to add one CVE per Device Signed-off-by: DamienMatthys --- .../Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 6ceb616c16fbc..f7880cd8d8c34 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2247,15 +2247,13 @@ function Invoke-NinjaOneTenantSync { } if ($Item.deviceDetailsJson) { $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique - $AffectedDevices = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($Dev in $Devices) { - [void]$AffectedDevices.Add(@{ deviceName = $Dev.deviceName }) - } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } } - [void]$CsvRows.Add([PSCustomObject]@{ - $DeviceIdHeader = $Dev.deviceName.Trim() - $CveIdHeader = $Item.cveId.Trim() - }) } if ($SkippedCount -gt 0) { From 4fb1aded1b397a634413175b0f01e2b6f1ea8ca7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:17:51 +0200 Subject: [PATCH 023/150] feat(mailbox): return SMTP client auth state --- .../Users/Invoke-ListUserMailboxDetails.ps1 | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index 97cebd8840b99..9a59cf7bbd983 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -241,37 +241,38 @@ function Invoke-ListUserMailboxDetails { # Build the GraphRequest object $GraphRequest = [ordered]@{ - ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward - ForwardingAddress = $ForwardingAddress - LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled - RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled - ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied - InPlaceHold = $InPlaceHold - EDiscoveryHold = $EDiscoveryHold - PurviewRetentionHold = $PurviewRetentionHold - ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold - HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled - EWSEnabled = $CASRequest.EwsEnabled - MailboxMAPIEnabled = $CASRequest.MAPIEnabled - MailboxOWAEnabled = $CASRequest.OWAEnabled - MailboxImapEnabled = $CASRequest.ImapEnabled - MailboxPopEnabled = $CASRequest.PopEnabled - MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled - Permissions = @($ParsedPerms) - ProhibitSendQuota = $ProhibitSendQuota - ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota - ItemCount = [math]::Round($StatsRequest.ItemCount, 2) - TotalItemSize = $TotalItemSize - TotalArchiveItemSize = $TotalArchiveItemSize - TotalArchiveItemCount = $TotalArchiveItemCount - BlockedForSpam = $BlockedForSpam - ArchiveMailBox = $ArchiveEnabled - AutoExpandingArchive = $AutoExpandingArchiveEnabled - AutoExpandingArchiveScope = $AutoExpandingArchiveScope - RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails - Mailbox = $MailboxDetailedRequest - RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy - MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, + ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward + ForwardingAddress = $ForwardingAddress + LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled + RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled + ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied + InPlaceHold = $InPlaceHold + EDiscoveryHold = $EDiscoveryHold + PurviewRetentionHold = $PurviewRetentionHold + ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold + HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled + EWSEnabled = $CASRequest.EwsEnabled + MailboxMAPIEnabled = $CASRequest.MAPIEnabled + MailboxOWAEnabled = $CASRequest.OWAEnabled + MailboxImapEnabled = $CASRequest.ImapEnabled + MailboxPopEnabled = $CASRequest.PopEnabled + MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled + SmtpClientAuthenticationDisabled = $CASRequest.SmtpClientAuthenticationDisabled + Permissions = @($ParsedPerms) + ProhibitSendQuota = $ProhibitSendQuota + ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota + ItemCount = [math]::Round($StatsRequest.ItemCount, 2) + TotalItemSize = $TotalItemSize + TotalArchiveItemSize = $TotalArchiveItemSize + TotalArchiveItemCount = $TotalArchiveItemCount + BlockedForSpam = $BlockedForSpam + ArchiveMailBox = $ArchiveEnabled + AutoExpandingArchive = $AutoExpandingArchiveEnabled + AutoExpandingArchiveScope = $AutoExpandingArchiveScope + RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails + Mailbox = $MailboxDetailedRequest + RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy + MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } }, From 843ebfb409ac09eaeea6ad5de89853e329ca8bba Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:52:17 +0800 Subject: [PATCH 024/150] Fix mailcontact when values un-set --- .../Invoke-CIPPStandardMailContacts.ps1 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 index 49e6233afbf8a..e2bea7df24a79 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 @@ -46,9 +46,10 @@ function Invoke-CIPPStandardMailContacts { $contacts = $settings $TechAndSecurityContacts = @(@($contacts.SecurityContact, $contacts.TechContact) | Where-Object { $_ } | Select-Object -Unique) - $marketingMatch = @($CurrentInfo.marketingNotificationEmails) -contains $contacts.MarketingContact - $techMatch = -not (Compare-Object @($CurrentInfo.technicalNotificationMails) $TechAndSecurityContacts) - $generalMatch = $CurrentInfo.privacyProfile.contactEmail -eq $contacts.GeneralContact + # If an input value is null/empty, ignore the target tenant's current state and treat it as compliant. + $marketingMatch = [string]::IsNullOrWhiteSpace($contacts.MarketingContact) -or (@($CurrentInfo.marketingNotificationEmails) -contains $contacts.MarketingContact) + $techMatch = $TechAndSecurityContacts.Count -eq 0 -or (-not (Compare-Object @($CurrentInfo.technicalNotificationMails) $TechAndSecurityContacts)) + $generalMatch = [string]::IsNullOrWhiteSpace($contacts.GeneralContact) -or ($CurrentInfo.privacyProfile.contactEmail -eq $contacts.GeneralContact) $state = $marketingMatch -and $techMatch -and $generalMatch @@ -74,7 +75,7 @@ function Invoke-CIPPStandardMailContacts { if ($Settings.alert -eq $true) { - if ($CurrentInfo.marketingNotificationEmails -eq $Contacts.MarketingContact) { + if (!$Contacts.MarketingContact -or $CurrentInfo.marketingNotificationEmails -eq $Contacts.MarketingContact) { Write-LogMessage -API 'Standards' -tenant $tenant -message "Marketing contact email is set to $($Contacts.MarketingContact)" -sev Info } else { $Object = $CurrentInfo | Select-Object marketingNotificationEmails @@ -95,7 +96,7 @@ function Invoke-CIPPStandardMailContacts { Write-StandardsAlert -message "Technical contact email is not set to $($Contacts.TechContact)" -object $Object -tenant $tenant -standardName 'MailContacts' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message "Technical contact email is not set to $($Contacts.TechContact)" -sev Info } - if ($CurrentInfo.privacyProfile.contactEmail -eq $Contacts.GeneralContact) { + if (!$Contacts.GeneralContact -or $CurrentInfo.privacyProfile.contactEmail -eq $Contacts.GeneralContact) { Write-LogMessage -API 'Standards' -tenant $tenant -message "General contact email is set to $($Contacts.GeneralContact)" -sev Info } else { $Object = $CurrentInfo | Select-Object privacyProfile @@ -110,10 +111,11 @@ function Invoke-CIPPStandardMailContacts { technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $CurrentInfo.privacyProfile.contactEmail } + # When an input value is null/empty, mirror the current state so the field is reported as compliant. $ExpectedValue = @{ - marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object -Unique) - technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) - contactEmail = $Contacts.GeneralContact + marketingNotificationEmails = @(if ([string]::IsNullOrWhiteSpace($Contacts.MarketingContact)) { $CurrentValue.marketingNotificationEmails } else { $Contacts.MarketingContact | Sort-Object -Unique }) + technicalNotificationMails = @(if ($TechAndSecurityContacts.Count -eq 0) { $CurrentValue.technicalNotificationMails } else { $TechAndSecurityContacts | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique }) + contactEmail = if ([string]::IsNullOrWhiteSpace($Contacts.GeneralContact)) { $CurrentValue.contactEmail } else { $Contacts.GeneralContact } } Set-CIPPStandardsCompareField -FieldName 'standards.MailContacts' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant Add-CIPPBPAField -FieldName 'MailContacts' -FieldValue $CurrentInfo -StoreAs json -Tenant $tenant From d2e54ee857d41054c49d891398da86e35799eff4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:52:30 +0800 Subject: [PATCH 025/150] SSO app password policy modification --- .../Authentication/Add-CIPPSSOAppSecret.ps1 | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 index 7d40ec88282f0..ffb9018b0e883 100644 --- a/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 @@ -3,16 +3,24 @@ function Add-CIPPSSOAppSecret { .SYNOPSIS Creates a client secret on the CIPP-SSO app registration with retry. .DESCRIPTION - Adds a new password credential to the given app object via Graph. Retries up to - MaxRetries times with backoff because Entra propagation can take a few seconds - after the app is freshly created or its app-management-policy exemption is set. - Throws on final failure so callers can persist Status=error + LastError. + Adds a new password credential to the given app object via Graph. Before adding the + secret it ensures the app is exempt from the tenant default app-management policy (so a + 'passwordAddition' restriction can't block the secret) via Update-AppManagementPolicy, + and honours any 'passwordLifetime' restriction when building the credential body. + Retries up to MaxRetries times with backoff because Entra propagation can take a few + seconds after the app is freshly created or its app-management-policy exemption is set: + replication misses back off 3s, and credential-policy blocks back off min(30, 5*attempt)s + while the exemption propagates. Throws on final failure so callers can persist + Status=error + LastError. .PARAMETER ObjectId Graph object ID of the application (NOT the appId/clientId). + .PARAMETER AppId + AppId/clientId of the application, used to target the app-management-policy exemption. + Resolved from ObjectId when not supplied. .PARAMETER DisplayName Display name to set on the password credential. Defaults to 'CIPP-SSO-Secret'. .PARAMETER MaxRetries - Number of secret-creation attempts before giving up. Defaults to 5. + Number of secret-creation attempts before giving up. Defaults to 6. #> [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] [CmdletBinding()] @@ -20,32 +28,78 @@ function Add-CIPPSSOAppSecret { [Parameter(Mandatory = $true)] [string]$ObjectId, + [Parameter(Mandatory = $false)] + [string]$AppId, + [Parameter(Mandatory = $false)] [string]$DisplayName = 'CIPP-SSO-Secret', [Parameter(Mandatory = $false)] - [int]$MaxRetries = 5 + [int]$MaxRetries = 6 ) + # Update-AppManagementPolicy targets the app by appId/clientId; resolve it from the object id when not supplied. + if (-not $AppId) { + try { + $SSOApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId`?`$select=id,appId" -NoAuthCheck $true -AsApp $true + $AppId = $SSOApp.appId + } catch { + Write-Warning "[SSO-Secret] Failed to resolve appId for objectId $ObjectId : $($_.Exception.Message)" + } + } + + # Ensure the app is exempt from any credential-addition restriction before adding the secret. + if ($AppId) { + try { + $PolicyUpdate = Update-AppManagementPolicy -ApplicationId $AppId + Write-Information "[SSO-Secret] App management policy: $($PolicyUpdate.PolicyAction)" + } catch { + Write-Information "[SSO-Secret] Failed to update app management policy: $($_.Exception.Message)" + } + } + + # Honour the tenant password-lifetime restriction (if enforced) when building the credential body. + $AppManagementPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -AsApp $true -NoAuthCheck $true + $PasswordExpirationPolicy = $AppManagementPolicy.applicationRestrictions.passwordcredentials | + Where-Object { $_.restrictionType -eq 'passwordLifetime' } + if (-not ($PasswordExpirationPolicy.state -eq 'disabled' -or $null -eq $PasswordExpirationPolicy.state)) { + $TimeToExpiration = [System.Xml.XmlConvert]::ToTimeSpan($PasswordExpirationPolicy.maxLifetime) + $ExpirationDate = (Get-Date).AddDays($TimeToExpiration.Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`",`"endDateTime`":`"$ExpirationDate`"}}" + } else { + $PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`"}}" + } + $SecretText = $null - $SecretAttempt = 0 - $BackoffSchedule = @(2, 5, 10, 15, 30) $LastException = $null - - while ($SecretAttempt -lt $MaxRetries -and -not $SecretText) { + for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) { try { - $PasswordBody = @{ passwordCredential = @{ displayName = $DisplayName } } | ConvertTo-Json -Compress - $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true + $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body $PasswordBody -maxRetries 3 $SecretText = $PasswordResult.secretText Write-Information "[SSO-Secret] Client secret created on objectId $ObjectId" + break } catch { - $SecretAttempt++ $LastException = $_ - Write-Warning "[SSO-Secret] Secret creation attempt $SecretAttempt/$MaxRetries failed: $($_.Exception.Message)" - if ($SecretAttempt -lt $MaxRetries) { - $Delay = $BackoffSchedule[[Math]::Min($SecretAttempt - 1, $BackoffSchedule.Count - 1)] - Start-Sleep -Seconds $Delay + $ExceptionMessage = $_.Exception.Message + $IsNotReplicatedYet = $ExceptionMessage -match "Resource '.*' does not exist or one of its queried reference-property objects are not present" + $IsCredentialPolicyBlocked = $ExceptionMessage -match 'Credential type not allowed as per assigned policy' + Write-Warning "[SSO-Secret] Secret creation attempt $Attempt/$MaxRetries failed: $ExceptionMessage" + + if ($IsNotReplicatedYet -and $Attempt -lt $MaxRetries) { + $DelaySeconds = 3 + Write-Information "[SSO-Secret] Application object not yet replicated for addPassword (attempt $Attempt of $MaxRetries). Retrying in $DelaySeconds second(s)." + Start-Sleep -Seconds $DelaySeconds + continue + } + + if ($IsCredentialPolicyBlocked -and $Attempt -lt $MaxRetries) { + $DelaySeconds = [Math]::Min(30, 5 * $Attempt) + Write-Information "[SSO-Secret] Credential policy still blocks addPassword (attempt $Attempt of $MaxRetries). Waiting for policy propagation and retrying in $DelaySeconds second(s)." + Start-Sleep -Seconds $DelaySeconds + continue } + + throw } } From d2655de84a26a7202ea5702091eab002f415191c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:05:15 +0800 Subject: [PATCH 026/150] github template id sync fixes --- .../Public/Tools/Import-CommunityTemplate.ps1 | 49 ++++++++++++------- .../MEM/Invoke-ListIntuneTemplates.ps1 | 11 ++++- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 56d3c5843e44e..48e7a2fa2bd21 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -84,15 +84,6 @@ function Import-CommunityTemplate { switch -Wildcard ($Type) { '*Group*' { - $RawJsonObj = [PSCustomObject]@{ - Displayname = $Template.displayName - Description = $Template.Description - MembershipRules = $Template.membershipRule - username = $Template.mailNickname - GUID = $id - groupType = 'generic' - } | ConvertTo-Json -Depth 100 - # Check for duplicate template $DuplicateFilter = "PartitionKey eq 'GroupTemplate'" $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue @@ -115,6 +106,19 @@ function Import-CommunityTemplate { break } + # On update, reuse the existing GUID so the JSON-embedded GUID stays in + # sync with the table RowKey (see the Intune path below for the full rationale). + $TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id } + + $RawJsonObj = [PSCustomObject]@{ + Displayname = $Template.displayName + Description = $Template.Description + MembershipRules = $Template.membershipRule + username = $Template.mailNickname + GUID = $TemplateGuid + groupType = 'generic' + } | ConvertTo-Json -Depth 100 + if ($Duplicate) { $StatusMessage = "Updating Group template '$($Template.displayName)' from source '$Source' (SHA changed)." Write-Information $StatusMessage @@ -126,7 +130,7 @@ function Import-CommunityTemplate { JSON = "$RawJsonObj" PartitionKey = 'GroupTemplate' SHA = $SHA - GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + GUID = $TemplateGuid RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } @@ -239,14 +243,6 @@ function Import-CommunityTemplate { #create a new template $DisplayName = $Template.displayName ?? $template.Name - $RawJsonObj = [PSCustomObject]@{ - Displayname = $DisplayName - Description = $Template.Description - RAWJson = $RawJson - Type = $URLName - GUID = $id - } | ConvertTo-Json -Depth 100 -Compress - # Check for duplicate template $DuplicateFilter = "PartitionKey eq 'IntuneTemplate'" $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue @@ -263,6 +259,21 @@ function Import-CommunityTemplate { } } | Select-Object -First 1 + # On update, reuse the existing template's GUID so the GUID embedded + # in the JSON blob stays in sync with the table RowKey. Minting a fresh + # GUID here desyncs the two: the standards engine resolves templates by + # RowKey, while the template picker surfaces the JSON GUID, so the drift + # would point at a GUID that no longer matches any RowKey. + $TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id } + + $RawJsonObj = [PSCustomObject]@{ + Displayname = $DisplayName + Description = $Template.Description + RAWJson = $RawJson + Type = $URLName + GUID = $TemplateGuid + } | ConvertTo-Json -Depth 100 -Compress + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { $StatusMessage = "Intune template '$DisplayName' from source '$Source' is already up to date. Skipping import." Write-Information $StatusMessage @@ -280,7 +291,7 @@ function Import-CommunityTemplate { JSON = "$RawJsonObj" PartitionKey = 'IntuneTemplate' SHA = $SHA - GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + GUID = $TemplateGuid RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 index 4944d2f2f7cbd..f258a21892b1a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 @@ -137,7 +137,16 @@ function Invoke-ListIntuneTemplates { } } | Sort-Object -Property label) } else { - $Templates = $RawTemplates.JSON | ForEach-Object { try { ConvertFrom-Json -InputObject $_ -Depth 100 -ErrorAction SilentlyContinue } catch {} } + # Force GUID to the table RowKey (the authoritative key the standards engine + # resolves against). The JSON-embedded GUID can drift out of sync with the + # RowKey after a community-repo re-sync, so never surface it as the selectable value. + $Templates = $RawTemplates | ForEach-Object { + try { + $Parsed = ConvertFrom-Json -InputObject $_.JSON -Depth 100 -ErrorAction SilentlyContinue + if ($Parsed) { $Parsed | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force } + $Parsed + } catch {} + } } } From 8e21ee1f87ad9e6f106159cd7864f3573420ee8d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:38:08 +0800 Subject: [PATCH 027/150] Fixes for when duplicate intune standards are applied to the same tenant --- .../Public/Standards/Merge-CippStandards.ps1 | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 b/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 index dcea014c0def0..7f231faf55ff1 100644 --- a/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 +++ b/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 @@ -11,14 +11,34 @@ function Merge-CippStandards { # If the standard name ends with 'Template', we treat them as arrays to merge. if ($StandardName -like '*Template') { - $ExistingIsArray = $Existing -is [System.Collections.IEnumerable] -and -not ($Existing -is [string]) - $NewIsArray = $New -is [System.Collections.IEnumerable] -and -not ($New -is [string]) + # Combine both tiers, then collapse duplicates that target the same template + # (same TemplateList.value). Without this, the same Intune/CA template configured + # in more than one tier (or in more than one standard) for a tenant gets + # concatenated into a multi-element array, which downstream stringifies into a + # doubled GUID ("Failed to find template ") that matches no RowKey. + # + # The standards engine already keys each template instance by TemplateList.value, + # so when this function runs the items share a template GUID and should resolve to + # a single deployment. Items without a TemplateList.value can't be keyed, so they + # are always kept (preserves the additive behaviour for those). + $Combined = @($Existing) + @($New) - # Make sure both are arrays - if (-not $ExistingIsArray) { $Existing = @($Existing) } - if (-not $NewIsArray) { $New = @($New) } + $Deduped = [System.Collections.Generic.List[object]]::new() + $SeenValues = [System.Collections.Generic.HashSet[string]]::new() + # Walk newest-first so the most-specific tier wins for a given template, while + # Insert(0, ...) keeps the overall ordering stable. + for ($i = $Combined.Count - 1; $i -ge 0; $i--) { + $Item = $Combined[$i] + $TemplateValue = $Item.TemplateList.value + if ([string]::IsNullOrEmpty($TemplateValue)) { + $Deduped.Insert(0, $Item) + } elseif ($SeenValues.Add([string]$TemplateValue)) { + $Deduped.Insert(0, $Item) + } + } - return $Existing + $New + if ($Deduped.Count -eq 1) { return $Deduped[0] } + return $Deduped.ToArray() } else { # Single‐value standard: override the old with the new return $New From 444a746f9c883f975dac55d5aced76fbe4afe3db Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:14:41 +0800 Subject: [PATCH 028/150] emit message when mailbox conversion might hit 50gb limit --- .../CIPPCore/Public/Set-CIPPMailboxType.ps1 | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 index ed8fdd7060e8f..f575d9fc0241c 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 @@ -14,6 +14,35 @@ function Set-CIPPMailboxType { if ([string]::IsNullOrWhiteSpace($Username)) { $Username = $UserID } $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $UserID; Type = $MailboxType } -Anchor $Username $Message = "Successfully converted $Username to a $MailboxType mailbox" + + # When converting to a shared mailbox, surface the cached mailbox size if it exceeds the + # unlicensed shared-mailbox limit (50 GiB; we warn at 49 GiB). This is best-effort: any + # lookup failure or unexpected response shape falls through to the standard success message. + if ($MailboxType -eq 'Shared') { + try { + # 49 GiB warning threshold (shared mailboxes are capped at 50 GiB without a license) + $SharedMailboxWarnBytes = 49GB + # Resolve the partition key (defaultDomainName) the reporting DB is keyed on + $PartitionKey = (Get-Tenants -TenantFilter $TenantFilter).defaultDomainName + if ($PartitionKey) { + # Server-side point lookup for this specific mailbox only. + # Cached mailbox rows are keyed RowKey = 'Mailboxes-'. + $Table = Get-CippTable -tablename 'CippReportingDB' + $Filter = "PartitionKey eq '{0}' and RowKey eq 'Mailboxes-{1}'" -f $PartitionKey, $UserID + $CachedMailbox = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1 + if ($CachedMailbox.Data) { + $StorageBytes = [int64]([string]($CachedMailbox.Data | ConvertFrom-Json).storageUsedInBytes) + if ($StorageBytes -ge $SharedMailboxWarnBytes) { + $StorageGB = [math]::Round($StorageBytes / 1GB, 1) + $Message = "$Message. Warning: detected mailbox size is $StorageGB GB, which exceeds the 50 GB shared mailbox limit. The mailbox may stop receiving mail unless an Exchange Online Plan 2 license is retained." + } + } + } + } catch { + # Best-effort size check only; ignore lookup/parse errors and return the standard message. + } + } + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter return $Message } catch { From e2b092bdd87def82904e846c693a167b45badf74 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:08:51 +0800 Subject: [PATCH 029/150] update json from frontend --- Config/standards.json | 4490 ++++++++++++++++++++++++++++------------- 1 file changed, 3140 insertions(+), 1350 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 1e2ceba142dc6..a698010ce1413 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1,4 +1,113 @@ [ + { + "name": "standards.CopilotSettings", + "cat": "Copilot (M365) Standards", + "tag": [], + "helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.", + "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.", + "executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Pin Microsoft 365 Copilot Chat", + "name": "standards.CopilotSettings.copilotChatPinning", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Copilot Access to Open Content", + "name": "standards.CopilotSettings.blockAccessToOpenFiles", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Block open content", "value": "1" }, + { "label": "Allow open content", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Designer Image Generation", + "name": "standards.CopilotSettings.imageGeneration", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Web Search in Copilot", + "name": "standards.CopilotSettings.allowWebSearch", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Admin Copilot in Microsoft 365 Admin Center", + "name": "standards.CopilotSettings.allowInAdminCenters", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + } + ], + "label": "Configure Microsoft 365 Copilot policy settings", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-09", + "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/policySettings/{id}", + "recommendedBy": [] + }, + { + "name": "standards.CopilotLimitedMode", + "cat": "Copilot (M365) Standards", + "tag": [], + "helpText": "Controls Microsoft 365 Copilot Limited Mode for Teams meetings. When enabled for a group, Copilot in Teams meetings does not respond to sentiment-related prompts (inferring emotions, behavior, or judgments) for members of the selected group. A target group is required when enabling. Managed via the Copilot admin settings Graph API.", + "docsDescription": "Configures the `copilotAdminLimitedMode` setting through the `/copilot/admin/settings/limitedMode` Microsoft Graph API (beta). When enabled, `isEnabledForGroup` is set to true and applied to the resolved target group; when disabled, `isEnabledForGroup` is set to false. NOTE: this API currently requires delegated authentication and the acting identity must be Global Administrator to write the setting.", + "executiveText": "Limits Microsoft 365 Copilot in Teams meetings so it does not provide opinions on sentiment, emotions, or judgments for a selected group of users. This helps organizations meet workplace policy, privacy, and works-council requirements while still allowing Copilot to summarize and answer factual questions grounded in the meeting.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.CopilotLimitedMode.LimitedModeEnabled", + "label": "Enable Copilot Limited Mode for a group (Teams meetings)", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.CopilotLimitedMode.GroupName", + "label": "Target Group Name (wildcard match; required when enabled)", + "required": false, + "condition": { + "field": "standards.CopilotLimitedMode.LimitedModeEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Microsoft 365 Copilot Limited Mode (Teams meetings)", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-09", + "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/settings/limitedMode", + "recommendedBy": [] + }, { "name": "standards.MailContacts", "cat": "Global Standards", @@ -77,7 +186,14 @@ "impactColour": "info", "addedDate": "2024-03-19", "powershellEquivalent": "New-MailContact", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DeployContactTemplates", @@ -102,27 +218,25 @@ } ], "label": "Deploy Mail Contact Template", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Low Impact", "impactColour": "info", "addedDate": "2025-05-31", "powershellEquivalent": "New-MailContact", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (3.1.1)", - "mip_search_auditlog", - "NIST CSF 2.0 (DE.CM-09)", - "CISAMSEXO171", - "CISAMSEXO173" - ], + "tag": ["CIS M365 7.0.0 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CISAMSEXO171", "CISAMSEXO173", "CIS_3_1_1"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", "addedComponent": [], @@ -131,12 +245,20 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Enable-OrganizationCustomization", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.RestrictThirdPartyStorageServices", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.7)"], + "tag": ["CIS M365 7.0.0 (1.3.7)"], + "appliesToTest": ["CIS_1_3_7"], "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", "executiveText": "Prevents employees from using external cloud storage services like Dropbox, Google Drive, and Box within Microsoft 365, reducing data security risks and ensuring all company data remains within controlled corporate systems. This helps maintain data governance and prevents potential data leaks to unauthorized platforms.", @@ -146,7 +268,15 @@ "impactColour": "warning", "addedDate": "2025-06-06", "powershellEquivalent": "New-MgServicePrincipal and Update-MgServicePrincipal", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.ProfilePhotos", @@ -163,14 +293,8 @@ "label": "Select value", "name": "standards.ProfilePhotos.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -179,7 +303,14 @@ "impactColour": "info", "addedDate": "2025-01-19", "powershellEquivalent": "Set-OrganizationConfig -ProfilePhotoOptions EnablePhotos and Update-MgBetaAdminPeople", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.PhishProtection", @@ -192,13 +323,10 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-01-22", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "powershellEquivalent": "Portal only", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2", "OFFICE_BUSINESS"] }, { "name": "standards.Branding", @@ -230,38 +358,26 @@ "label": "Visual Template", "name": "standards.Branding.layoutTemplateType", "options": [ - { - "label": "Full-screen background", - "value": "default" - }, - { - "label": "Partial-screen background", - "value": "verticalSplit" - } + { "label": "Full-screen background", "value": "default" }, + { "label": "Partial-screen background", "value": "verticalSplit" } ] }, - { - "type": "switch", - "name": "standards.Branding.isHeaderShown", - "label": "Show header" - }, - { - "type": "switch", - "name": "standards.Branding.isFooterShown", - "label": "Show footer" - } + { "type": "switch", "name": "standards.Branding.isHeaderShown", "label": "Show header" }, + { "type": "switch", "name": "standards.Branding.isFooterShown", "label": "Show footer" } ], "label": "Set branding for the tenant", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-05-13", "powershellEquivalent": "Portal only", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2", "OFFICE_BUSINESS"] }, { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.6)", "CustomerLockBoxEnabled"], + "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], + "appliesToTest": ["CIS_1_3_6"], "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", "executiveText": "Requires explicit organizational approval before Microsoft support staff can access company data for service operations. This provides an additional layer of data protection and ensures the organization maintains control over who can access sensitive business information, even during technical support scenarios.", @@ -271,7 +387,8 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["CustomerLockbox"] }, { "name": "standards.EnablePronouns", @@ -320,15 +437,22 @@ "name": "standards.DisableGuestDirectory", "cat": "Global Standards", "tag": [ - "CIS M365 5.0 (5.1.6.2)", + "CIS M365 7.0.0 (5.1.6.2)", "CISA (MS.AAD.5.1v1)", "EIDSCA.AP14", "EIDSCA.ST08", "EIDSCA.ST09", "NIST CSF 2.0 (PR.AA-05)", + "SMB1001 (2.8)" + ], + "appliesToTest": [ + "CIS_5_1_6_2", "EIDSCAAP07", + "EIDSCAAP14", "EIDSCAST08", - "EIDSCAST09" + "EIDSCAST09", + "SMB1001_2_8", + "ZTNA21792" ], "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.", "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)", @@ -344,12 +468,8 @@ { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (6.5.4)", - "NIST CSF 2.0 (PR.IR-01)", - "ZTNA21799", - "CISAMSEXO51" - ], + "tag": ["CIS M365 7.0.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], + "appliesToTest": ["CISAMSEXO51", "CIS_6_5_4", "ZTNA21799"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", @@ -359,19 +479,20 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (1.3.2)", - "spo_idle_session_timeout", - "NIST CSF 2.0 (PR.AA-03)", - "ZTNA21813", - "ZTNA21814", - "ZTNA21815" - ], + "tag": ["CIS M365 7.0.0 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], + "appliesToTest": ["CIS_1_3_2", "ZTNA21813", "ZTNA21814", "ZTNA21815"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", "addedComponent": [ @@ -382,26 +503,11 @@ "label": "Select value", "name": "standards.ActivityBasedTimeout.timeout", "options": [ - { - "label": "1 Hour", - "value": "01:00:00" - }, - { - "label": "3 Hours", - "value": "03:00:00" - }, - { - "label": "6 Hours", - "value": "06:00:00" - }, - { - "label": "12 Hours", - "value": "12:00:00" - }, - { - "label": "24 Hours", - "value": "1.00:00:00" - } + { "label": "1 Hour", "value": "01:00:00" }, + { "label": "3 Hours", "value": "03:00:00" }, + { "label": "6 Hours", "value": "06:00:00" }, + { "label": "12 Hours", "value": "12:00:00" }, + { "label": "24 Hours", "value": "1.00:00:00" } ] } ], @@ -416,11 +522,19 @@ "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", "tag": [ + "CIS M365 7.0.0 (5.2.3.6)", "EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03", + "SMB1001 (2.8)" + ], + "appliesToTest": [ + "CIS_5_2_3_6", + "EIDSCAAG01", "EIDSCAAG02", - "EIDSCAAG03" + "EIDSCAAG03", + "SMB1001_2_8", + "ZTNA21841" ], "helpText": "Configures the report suspicious activity settings and system credential preferences in the authentication methods policy.", "docsDescription": "Controls the authentication methods policy settings for reporting suspicious activity and system credential preferences. These settings help enhance the security of authentication in your organization.", @@ -434,18 +548,9 @@ "name": "standards.AuthMethodsSettings.ReportSuspiciousActivity", "label": "Report Suspicious Activity Settings", "options": [ - { - "label": "Microsoft managed", - "value": "default" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -456,18 +561,9 @@ "name": "standards.AuthMethodsSettings.SystemCredential", "label": "System Credential Preferences", "options": [ - { - "label": "Microsoft managed", - "value": "default" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -479,79 +575,447 @@ "recommendedBy": [] }, { - "name": "standards.AuthMethodsPolicyMigration", - "cat": "Entra (AAD) Standards", - "tag": ["EIDSCAAG01"], - "helpText": "Completes the migration of authentication methods policy to the new format", - "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", - "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", - "addedComponent": [], - "label": "Complete Authentication Methods Policy Migration", - "impact": "Medium Impact", - "impactColour": "warning", - "addedDate": "2025-07-07", - "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", - "recommendedBy": ["CIPP"] - }, - { - "name": "standards.AppDeploy", + "name": "standards.AuthenticationMethods", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Deploys selected applications to the tenant. Use a comma separated list of application IDs to deploy multiple applications. Permissions will be copied from the source application.", - "docsDescription": "Uses the CIPP functionality that deploys applications across an entire tenant base as a standard.", - "executiveText": "Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead.", + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", "addedComponent": [ { - "type": "select", + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "label": "Microsoft Authenticator", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath", + "label": "Enable Software OTP in Authenticator", + "defaultValue": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", "multiple": false, "creatable": false, - "label": "App Approval Mode", - "name": "standards.AppDeploy.mode", + "label": "Number Matching", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorNumberMatching", "options": [ - { - "label": "Template", - "value": "template" - }, - { - "label": "Copy Permissions", - "value": "copy" - } - ] + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } }, { "type": "autoComplete", - "multiple": true, + "multiple": false, "creatable": false, - "label": "Select Applications", - "name": "standards.AppDeploy.templateIds", - "api": { - "url": "/api/ListAppApprovalTemplates", - "labelField": "TemplateName", - "valueField": "TemplateId", - "queryKey": "StdAppApprovalTemplateList", - "addedField": { - "AppId": "AppId" - } - }, + "label": "Show Application Name in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], "condition": { - "field": "standards.AppDeploy.mode", + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", "compareType": "is", - "compareValue": "template" + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Geographic Location in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Companion App (Authenticator Lite)", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true } }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.FIDO2Enabled", + "label": "FIDO2 Security Keys", + "defaultValue": false + }, { "type": "textField", - "name": "standards.AppDeploy.appids", - "label": "Application IDs, comma separated", + "name": "standards.AuthenticationMethods.FIDO2Group", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, "condition": { - "field": "standards.AppDeploy.mode", - "compareType": "isNot", - "compareValue": "template" + "field": "standards.AuthenticationMethods.FIDO2Enabled", + "compareType": "is", + "compareValue": true } - } - ], - "label": "Deploy Application", - "impact": "Low Impact", + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.TAPEnabled", + "label": "Temporary Access Pass", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.TAPGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "TAP Usage Mode", + "name": "standards.AuthenticationMethods.TAPUsableOnce", + "options": [ + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } + ], + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLifetime", + "label": "TAP Default Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMinLifetime", + "label": "TAP Minimum Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMaxLifetime", + "label": "TAP Maximum Lifetime (minutes)", + "defaultValue": 480, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLength", + "label": "TAP Length (characters)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SoftwareOathEnabled", + "label": "Third-Party Software OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SoftwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SoftwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.HardwareOathEnabled", + "label": "Hardware OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.HardwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.HardwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SMSEnabled", + "label": "SMS", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SMSGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SMSEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.VoiceEnabled", + "label": "Voice Call", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.VoiceGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.VoiceEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.EmailEnabled", + "label": "Email OTP", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.EmailGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.EmailEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.x509CertificateEnabled", + "label": "Certificate-Based Authentication", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.x509CertificateGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.x509CertificateEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.QRCodePinEnabled", + "label": "QR Code Pin", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.QRCodePinGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodeLifetimeInDays", + "label": "QR Code Lifetime (days, 1-395)", + "defaultValue": 365, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodePinLength", + "label": "QR Code PIN Length (8-20)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Authentication Methods", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-28", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AdminSSPR", + "cat": "Entra (AAD) Standards", + "tag": ["EIDSCA.AP01"], + "appliesToTest": ["EIDSCAAP01", "ZTNA21842"], + "helpText": "Controls whether administrators are allowed to use Self-Service Password Reset through the Microsoft Entra authorization policy.", + "docsDescription": "Configures the allowedToUseSSPR property on the Microsoft Entra authorization policy. Microsoft documents this property as controlling whether administrators of the tenant can use Self-Service Password Reset. Use this standard to explicitly enable or disable administrator SSPR based on your security policy.", + "executiveText": "Controls whether tenant administrators can reset their own passwords through Self-Service Password Reset. Disabling this capability forces privileged accounts through more controlled recovery processes and reduces the risk of self-service recovery being misused on administrative identities.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.AdminSSPR.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] + } + ], + "label": "Set administrator Self-Service Password Reset state", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-21", + "powershellEquivalent": "Update-MgBetaPolicyAuthorizationPolicy", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AuthMethodsPolicyMigration", + "cat": "Entra (AAD) Standards", + "tag": [], + "appliesToTest": ["EIDSCAAG01"], + "helpText": "Completes the migration of authentication methods policy to the new format", + "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", + "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", + "addedComponent": [], + "label": "Complete Authentication Methods Policy Migration", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-07-07", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AppDeploy", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Deploys selected applications to the tenant. Use a comma separated list of application IDs to deploy multiple applications. Permissions will be copied from the source application.", + "docsDescription": "Uses the CIPP functionality that deploys applications across an entire tenant base as a standard.", + "executiveText": "Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "creatable": false, + "label": "App Approval Mode", + "name": "standards.AppDeploy.mode", + "options": [ + { "label": "Template", "value": "template" }, + { "label": "Copy Permissions", "value": "copy" } + ] + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Applications", + "name": "standards.AppDeploy.templateIds", + "api": { + "url": "/api/ListAppApprovalTemplates", + "labelField": "TemplateName", + "valueField": "TemplateId", + "queryKey": "StdAppApprovalTemplateList", + "addedField": { "AppId": "AppId" } + }, + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "is", + "compareValue": "template" + } + }, + { + "type": "textField", + "name": "standards.AppDeploy.appids", + "label": "Application IDs, comma separated", + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "isNot", + "compareValue": "template" + } + } + ], + "label": "Deploy Application", + "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-07", "powershellEquivalent": "Portal or Graph API", @@ -560,7 +1024,8 @@ { "name": "standards.laps", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21953", "ZTNA21955", "ZTNA24560"], + "tag": ["CIS M365 7.0.0 (5.1.4.5)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_5", "SMB1001_2_2", "ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", @@ -576,14 +1041,17 @@ "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.1)", + "CIS M365 7.0.0 (5.2.3.1)", "EIDSCA.AM03", "EIDSCA.AM04", "EIDSCA.AM06", "EIDSCA.AM07", "EIDSCA.AM09", "EIDSCA.AM10", - "NIST CSF 2.0 (PR.AA-03)", + "NIST CSF 2.0 (PR.AA-03)" + ], + "appliesToTest": [ + "CIS_5_2_3_1", "EIDSCAAM01", "EIDSCAAM03", "EIDSCAAM04", @@ -606,7 +1074,8 @@ { "name": "standards.allowOTPTokens", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM02", "EIDSCAAM02"], + "tag": ["EIDSCA.AM02"], + "appliesToTest": ["EIDSCAAM02"], "helpText": "Allows you to use MS authenticator OTP token generator", "docsDescription": "Allows you to use Microsoft Authenticator OTP token generator. Useful for using the NPS extension as MFA on VPN clients.", "executiveText": "Enables one-time password generation through Microsoft Authenticator app, providing an additional secure authentication method for employees. This is particularly useful for secure VPN access and other systems requiring multi-factor authentication.", @@ -621,7 +1090,8 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM01"], + "tag": ["CIS M365 7.0.0 (5.2.3.10)", "EIDSCA.AM01"], + "appliesToTest": ["CIS_5_2_3_10", "EIDSCAAM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", @@ -633,18 +1103,9 @@ "label": "Select value", "name": "standards.PWcompanionAppAllowedState.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - }, - { - "label": "Microsoft managed", - "value": "default" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] } ], @@ -666,12 +1127,22 @@ "EIDSCA.AF05", "EIDSCA.AF06", "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ "EIDSCAAF01", "EIDSCAAF02", "EIDSCAAF03", "EIDSCAAF04", "EIDSCAAF05", - "EIDSCAAF06" + "EIDSCAAF06", + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", + "ZTNA21838", + "ZTNA21839" ], "helpText": "Enables the FIDO2 authenticationMethod for the tenant", "docsDescription": "Enables FIDO2 capabilities for the tenant. This allows users to use FIDO2 keys like a Yubikey for authentication.", @@ -687,7 +1158,8 @@ { "name": "standards.EnableHardwareOAuth", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "Enables the HardwareOath authenticationMethod for the tenant. This allows you to use hardware tokens for generating 6 digit MFA codes.", "docsDescription": "Enables Hardware OAuth tokens for the tenant. This allows users to use hardware tokens like a Yubikey for authentication.", "executiveText": "Enables physical hardware tokens that generate secure authentication codes, providing an alternative to smartphone-based authentication. This is particularly valuable for employees who cannot use mobile devices or require the highest security standards for accessing sensitive systems.", @@ -703,6 +1175,7 @@ "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", @@ -717,7 +1190,8 @@ { "name": "standards.FormsPhishingProtection", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.5)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (1.3.5)", "Security", "PhishingProtection"], + "appliesToTest": ["CIS_1_3_5"], "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", @@ -732,10 +1206,11 @@ { "name": "standards.TAP", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21845", "ZTNA21846", "EIDSCAAT01", "EIDSCAAT02"], + "tag": [], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", - "docsDescription": "Enables Temporary Password generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", + "docsDescription": "Enables Temporary Access Pass generation for the tenant.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ { "type": "autoComplete", @@ -744,18 +1219,12 @@ "label": "Select TAP Lifetime", "name": "standards.TAP.config", "options": [ - { - "label": "Only Once", - "value": "true" - }, - { - "label": "Multiple Logons", - "value": "false" - } + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } ] } ], - "label": "Enable Temporary Access Passes", + "label": "Enable Temporary Access Passes (TAP)", "impact": "Low Impact", "impactColour": "info", "addedDate": "2022-03-15", @@ -765,7 +1234,8 @@ { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 5.0 (1.3.1)", "PWAgePolicyNew"], + "tag": ["CIS M365 7.0.0 (1.3.1)", "PWAgePolicyNew"], + "appliesToTest": ["CIS_1_3_1", "ZTNA21811"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", "executiveText": "Eliminates mandatory password expiration requirements, allowing employees to keep strong passwords indefinitely rather than forcing frequent changes that often lead to weaker passwords. This modern security approach reduces help desk calls and improves overall password security when combined with multi-factor authentication.", @@ -780,16 +1250,18 @@ { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (5.2.3.2)", - "ZTNA21848", - "ZTNA21849", - "ZTNA21850", + "tag": ["CIS M365 7.0.0 (5.2.3.2)", "SMB1001 (2.1)"], + "appliesToTest": [ + "CIS_5_2_3_2", "EIDSCAPR01", "EIDSCAPR02", "EIDSCAPR03", "EIDSCAPR05", - "EIDSCAPR06" + "EIDSCAPR06", + "SMB1001_2_1", + "ZTNA21848", + "ZTNA21849", + "ZTNA21850" ], "helpText": "**Requires Entra ID P1.** Updates and enables the Entra ID custom banned password list with the supplied words. Enter words separated by commas or semicolons. Each word must be 4-16 characters long. Maximum 1,000 words allowed.", "docsDescription": "Updates and enables the Entra ID custom banned password list with the supplied words. This supplements the global banned password list maintained by Microsoft. The custom list is limited to 1,000 key base terms of 4-16 characters each. Entra ID will [block variations and common substitutions](https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection#configure-custom-banned-passwords) of these words in user passwords. [How are passwords evaluated?](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#score-calculation)", @@ -807,12 +1279,14 @@ "impactColour": "warning", "addedDate": "2025-06-28", "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21803", "ZTNA21804"], + "tag": [], + "appliesToTest": ["ZTNA21803", "ZTNA21804"], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ @@ -823,14 +1297,8 @@ "label": "Select value", "name": "standards.ExternalMFATrusted.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -844,12 +1312,8 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (1.2.3)", - "CISA (MS.AAD.6.1v1)", - "ZTNA21772", - "ZTNA21787" - ], + "tag": ["CIS M365 7.0.0 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_2_3", "SMB1001_2_8", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", @@ -865,7 +1329,7 @@ "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.5.2)", + "CIS M365 7.0.0 (5.1.5.2)", "CISA (MS.AAD.9.1v1)", "EIDSCA.CP04", "EIDSCA.CR01", @@ -873,12 +1337,17 @@ "EIDSCA.CR03", "EIDSCA.CR04", "Essential 8 (1507)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA21869", + "NIST CSF 2.0 (PR.AA-05)" + ], + "appliesToTest": [ + "CIS_5_1_5_2", + "EIDSCACP04", "EIDSCACR01", "EIDSCACR02", "EIDSCACR03", - "EIDSCACR04" + "EIDSCACR04", + "ZTNA21809", + "ZTNA21869" ], "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", @@ -900,7 +1369,8 @@ { "name": "standards.NudgeMFA", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21889"], + "tag": ["SMB1001 (2.5)"], + "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], "helpText": "Sets the state of the registration campaign for the tenant", "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", @@ -912,14 +1382,8 @@ "label": "Select value", "name": "standards.NudgeMFA.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -943,7 +1407,8 @@ { "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.21.1v1)", "ZTNA21868"], + "tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8", "ZTNA21868"], "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", @@ -953,19 +1418,28 @@ "impactColour": "info", "addedDate": "2022-07-17", "powershellEquivalent": "Update-MgBetaDirectorySetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.2.2)", + "CIS M365 7.0.0 (5.1.2.2)", "CISA (MS.AAD.4.1v1)", "EIDSCA.AP10", "Essential 8 (1175)", "NIST CSF 2.0 (PR.AA-05)", - "EIDSCAAP10" + "SMB1001 (2.8)" ], + "appliesToTest": ["CIS_5_1_2_2", "EIDSCAAP10", "SMB1001_2_8"], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", "executiveText": "Prevents regular employees from creating application registrations that could be used to maintain unauthorized access to company systems. This security measure ensures that only authorized IT personnel can create applications, reducing the risk of persistent security breaches through malicious applications.", @@ -980,7 +1454,8 @@ { "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21954"], + "tag": ["CIS M365 7.0.0 (5.1.4.6)"], + "appliesToTest": ["CIS_5_1_4_6", "ZTNA21954"], "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", "executiveText": "Gives administrators centralized control over BitLocker recovery secrets—restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", @@ -992,14 +1467,8 @@ "label": "Select state", "name": "standards.BitLockerKeysForOwnedDevice.state", "options": [ - { - "label": "Restrict", - "value": "restrict" - }, - { - "label": "Allow", - "value": "allow" - } + { "label": "Restrict", "value": "restrict" }, + { "label": "Allow", "value": "allow" } ] } ], @@ -1013,7 +1482,13 @@ { "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)", "ZTNA21868"], + "tag": [ + "CIS M365 7.0.0 (5.1.3.1)", + "CISA (MS.AAD.20.1v1)", + "NIST CSF 2.0 (PR.AA-05)", + "SMB1001 (2.8)" + ], + "appliesToTest": ["CIS_5_1_3_1", "SMB1001_2_8", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1041,7 +1516,8 @@ { "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (1.3.4)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_1_3_4", "EIDSCAAP05", "SMB1001_2_8"], "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ @@ -1050,6 +1526,11 @@ "name": "standards.DisableSelfServiceLicenses.Exclusions", "label": "License Ids to exclude from this standard", "required": false + }, + { + "type": "switch", + "name": "standards.DisableSelfServiceLicenses.DisableTrials", + "label": "Disable starting trials on behalf of your organization" } ], "label": "Disable Self Service Licensing", @@ -1062,7 +1543,8 @@ { "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21858"], + "tag": ["SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], "helpText": "Blocks login for guest users that have not logged in for a number of days", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ @@ -1079,26 +1561,31 @@ "impactColour": "warning", "addedDate": "2022-10-20", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.5.1)", + "CIS M365 7.0.0 (5.1.5.1)", "CISA (MS.AAD.4.2v1)", "EIDSCA.AP08", "EIDSCA.AP09", "Essential 8 (1175)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA21772", - "ZTNA21774", - "ZTNA21807", + "NIST CSF 2.0 (PR.AA-05)" + ], + "appliesToTest": [ + "CIS_5_1_5_1", "EIDSCAAP08", "EIDSCAAP09", "EIDSCACP01", "EIDSCACP03", - "EIDSCACP04" + "EIDSCACP04", + "ZTNA21772", + "ZTNA21774", + "ZTNA21807", + "ZTNA21810" ], "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", @@ -1135,7 +1622,8 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "EIDSCAAP04"], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_6_3", "EIDSCAAP04", "EIDSCAAP07", "SMB1001_2_8", "ZTNA21791"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ @@ -1147,22 +1635,13 @@ "label": "Who can send invites?", "name": "standards.GuestInvite.allowInvitesFrom", "options": [ - { - "label": "Everyone", - "value": "everyone" - }, + { "label": "Everyone", "value": "everyone" }, { "label": "Admins, Guest inviters and All Members", "value": "adminsGuestInvitersAndAllMembers" }, - { - "label": "Admins and Guest inviters", - "value": "adminsAndGuestInviters" - }, - { - "label": "None", - "value": "none" - } + { "label": "Admins and Guest inviters", "value": "adminsAndGuestInviters" }, + { "label": "None", "value": "none" } ] } ], @@ -1176,11 +1655,7 @@ { "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", - "tag": [ - "Essential 8 (1501)", - "NIST CSF 2.0 (ID.AM-08)", - "NIST CSF 2.0 (PR.PS-03)" - ], + "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], "helpText": "**Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days.", "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", @@ -1194,17 +1669,14 @@ } } ], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": true - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": true }, "label": "Cleanup stale Entra devices", "impact": "High Impact", "impactColour": "danger", "addedDate": "2025-01-19", "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.UndoOauth", @@ -1223,7 +1695,8 @@ { "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.11.1v1)", "ZTNA21843"], + "tag": ["CISA (MS.AAD.11.1v1)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9", "ZTNA21843"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", @@ -1239,10 +1712,20 @@ "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)", - "EIDSCAAS04" + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_3_5", + "EIDSCAAS04", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_9" ], "helpText": "This blocks users from using SMS as an MFA method. If a user only has SMS as a MFA method, they will be unable to log in.", "docsDescription": "Disables SMS as an MFA method for the tenant. If a user only has SMS as a MFA method, they will be unable to sign in.", @@ -1259,10 +1742,20 @@ "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)", - "EIDSCAAV01" + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_3_5", + "EIDSCAAV01", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_9" ], "helpText": "This blocks users from using Voice call as an MFA method. If a user only has Voice as a MFA method, they will be unable to log in.", "docsDescription": "Disables Voice call as an MFA method for the tenant. If a user only has Voice call as a MFA method, they will be unable to sign in.", @@ -1278,7 +1771,14 @@ { "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 5.0 (2.3.5)", "NIST CSF 2.0 (PR.AA-03)"], + "tag": [ + "CIS M365 7.0.0 (5.2.3.7)", + "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": ["CIS_5_2_3_7", "SMB1001_2_5", "SMB1001_2_5_L4", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], @@ -1289,6 +1789,28 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", "recommendedBy": [] }, + { + "name": "standards.EmailAsAlternateLoginId", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures the tenant-wide Email as alternate login ID setting in Home Realm Discovery policy. Enabling this can help during migrations, if users are changing UPN.", + "docsDescription": "Sets the Home Realm Discovery policy AlternateIdLogin setting to enable or disable using email as an alternate sign-in ID.", + "executiveText": "Controls whether users can sign in with email as an alternate identifier, allowing organizations to align sign-in behavior with their identity strategy and reduce authentication ambiguity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.EmailAsAlternateLoginId.Enabled", + "label": "Enable Email as Alternate Login ID", + "defaultValue": true + } + ], + "label": "Configure Email as alternate login ID", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-03", + "powershellEquivalent": "Invoke-MgGraphRequest https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies/", + "recommendedBy": ["CIPP"] + }, { "name": "standards.Disablex509Certificate", "cat": "Entra (AAD) Standards", @@ -1323,15 +1845,20 @@ "name": "standards.PerUserMFA", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.2.1)", - "CIS M365 5.0 (1.1.1)", - "CIS M365 5.0 (1.1.2)", "CISA (MS.AAD.1.1v1)", "CISA (MS.AAD.1.2v1)", "Essential 8 (1504)", "Essential 8 (1173)", "Essential 8 (1401)", "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", "ZTNA21780", "ZTNA21782", "ZTNA21796" @@ -1359,11 +1886,7 @@ "creatable": false, "name": "standards.UserPreferredLanguage.preferredLanguage", "label": "Preferred Language", - "api": { - "url": "/languageList.json", - "labelField": "tag", - "valueField": "tag" - } + "api": { "url": "/languageList.json", "labelField": "tag", "valueField": "tag" } } ], "label": "Preferred language for all users", @@ -1376,7 +1899,21 @@ { "name": "standards.AppManagementPolicy", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": [ + "CIS M365 7.0.0 (5.1.5.3)", + "CIS M365 7.0.0 (5.1.5.4)", + "CIS M365 7.0.0 (5.1.5.5)", + "CIS M365 7.0.0 (5.1.5.6)" + ], + "appliesToTest": [ + "CIS_5_1_5_3", + "CIS_5_1_5_4", + "CIS_5_1_5_5", + "CIS_5_1_5_6", + "ZTNA21773", + "ZTNA21896", + "ZTNA21992" + ], "helpText": "Configures the default app management policy to control application and service principal credential restrictions such as password and key credential lifetimes.", "docsDescription": "Configures the default app management policy to control application and service principal credential restrictions. This includes password addition restrictions, custom password addition, symmetric key addition, and credential lifetime limits for both applications and service principals.", "executiveText": "Enforces credential restrictions on application registrations and service principals to limit how secrets and certificates are created and how long they remain valid. This reduces the risk of long-lived or unmanaged credentials being used to access your tenant.", @@ -1389,14 +1926,8 @@ "name": "standards.AppManagementPolicy.passwordCredentialsPasswordAddition", "label": "Disable Password Addition", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1407,14 +1938,8 @@ "name": "standards.AppManagementPolicy.passwordCredentialsCustomPasswordAddition", "label": "Disable Custom Password", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1440,7 +1965,8 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.6)"], + "appliesToTest": ["CIS_2_1_6"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", "addedComponent": [ @@ -1455,7 +1981,14 @@ "impactColour": "info", "addedDate": "2023-05-03", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.MessageExpiration", @@ -1469,7 +2002,14 @@ "impactColour": "info", "addedDate": "2024-02-23", "powershellEquivalent": "Set-TransportConfig -MessageExpirationTimeout 12.00:00:00", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.GlobalQuarantineNotifications", @@ -1484,18 +2024,9 @@ "label": "Select value", "name": "standards.GlobalQuarantineNotifications.NotificationInterval", "options": [ - { - "label": "4 hours", - "value": "04:00:00" - }, - { - "label": "1 day/Daily", - "value": "1.00:00:00" - }, - { - "label": "7 days/Weekly", - "value": "7.00:00:00" - } + { "label": "4 hours", "value": "04:00:00" }, + { "label": "1 day/Daily", "value": "1.00:00:00" }, + { "label": "7 days/Weekly", "value": "7.00:00:00" } ] } ], @@ -1504,7 +2035,14 @@ "impactColour": "info", "addedDate": "2024-05-03", "powershellEquivalent": "Set-QuarantinePolicy -EndUserSpamNotificationFrequency", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableTNEF", @@ -1519,7 +2057,14 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-RemoteDomain -Identity 'Default' -TNEFEnabled $false", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.FocusedInbox", @@ -1535,14 +2080,8 @@ "label": "Select value", "name": "standards.FocusedInbox.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1551,7 +2090,14 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-OrganizationConfig -FocusedInboxOn $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.CloudMessageRecall", @@ -1567,14 +2113,8 @@ "label": "Select value", "name": "standards.CloudMessageRecall.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -1583,7 +2123,14 @@ "impactColour": "info", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -MessageRecallEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoExpandArchive", @@ -1598,7 +2145,14 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Set-OrganizationConfig -AutoExpandingArchive", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.TwoClickEmailProtection", @@ -1615,14 +2169,8 @@ "label": "Select value", "name": "standards.TwoClickEmailProtection.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1631,12 +2179,20 @@ "impactColour": "info", "addedDate": "2025-06-13", "powershellEquivalent": "Set-OrganizationConfig -TwoClickMailPreviewEnabled $true | $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableOnlineArchiving", "cat": "Exchange Standards", - "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)"], + "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)", "SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Enables the In-Place Online Archive for all UserMailboxes with a valid license.", "executiveText": "Automatically enables online email archiving for all licensed employees, providing additional storage for older emails while maintaining easy access. This helps manage mailbox sizes, improves email performance, and supports compliance with data retention requirements.", "addedComponent": [], @@ -1645,12 +2201,20 @@ "impactColour": "info", "addedDate": "2024-01-20", "powershellEquivalent": "Enable-Mailbox -Archive $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableLitigationHold", "cat": "Exchange Standards", - "tag": [], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Enables litigation hold for all UserMailboxes with a valid license.", "executiveText": "Preserves all email content for legal and compliance purposes by preventing permanent deletion of emails, even when users attempt to delete them. This is essential for organizations subject to legal discovery requirements or regulatory compliance mandates.", "addedComponent": [ @@ -1667,12 +2231,20 @@ "impactColour": "info", "addedDate": "2024-06-25", "powershellEquivalent": "Set-Mailbox -LitigationHoldEnabled $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.2.3)", "ORCA111", "ORCA240", "CISAMSEXO71"], + "tag": ["CIS M365 7.0.0 (6.2.3)"], + "appliesToTest": ["CISAMSEXO71", "CIS_6_2_3", "ORCA111", "ORCA240"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", @@ -1683,14 +2255,8 @@ "label": "Select value", "name": "standards.SpoofWarn.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1706,13 +2272,21 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2021-11-16", - "powershellEquivalent": "Set-ExternalInOutlook \u2013Enabled $true or $false", - "recommendedBy": ["CIS", "CIPP"] + "powershellEquivalent": "Set-ExternalInOutlook –Enabled $true or $false", + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.5.2)", "exo_mailtipsenabled"], + "tag": ["CIS M365 7.0.0 (6.5.2)", "exo_mailtipsenabled"], + "appliesToTest": ["CIS_6_5_2"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", "addedComponent": [ @@ -1729,7 +2303,14 @@ "impactColour": "info", "addedDate": "2024-01-14", "powershellEquivalent": "Set-OrganizationConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.TeamsMeetingsByDefault", @@ -1745,14 +2326,8 @@ "label": "Select value", "name": "standards.TeamsMeetingsByDefault.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -1761,7 +2336,14 @@ "impactColour": "info", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -OnlineMeetingsByDefaultEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableViva", @@ -1781,7 +2363,8 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.9)"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_9", "SMB1001_2_12"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], @@ -1790,12 +2373,52 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "Rotate-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.EnableExchangeCloudManagement", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures cloud-based management of Exchange attributes for directory-synced users with remote mailboxes in Exchange Online. This allows you to enable or disable management of Exchange attributes directly in the cloud without requiring an on-premises Exchange server. More information can be found [here](https://learn.microsoft.com/da-dk/exchange/hybrid-deployment/enable-exchange-attributes-cloud-management).", + "docsDescription": "Configures the IsExchangeCloudManaged property for mailboxes, allowing Exchange attributes (aliases, mailbox flags, custom attributes, etc.) to be managed directly in Exchange Online or revert back to on-premises management. This feature helps organizations retire their last on-premises Exchange server in hybrid deployments while maintaining the ability to manage recipient attributes. Identity attributes (names, UPN) remain managed on-premises via Active Directory. More information can be found [here](https://learn.microsoft.com/da-dk/exchange/hybrid-deployment/enable-exchange-attributes-cloud-management).", + "executiveText": "Configures cloud-based management of Exchange mailbox attributes for hybrid organizations. When enabled, eliminates the dependency on on-premises Exchange servers for attribute management. This modernizes email administration, reduces infrastructure complexity, and allows direct management of mailbox properties through cloud portals and PowerShell. When disabled, returns management to on-premises Exchange servers.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "name": "standards.EnableExchangeCloudManagement.state", + "label": "Cloud Management State", + "options": [ + { "label": "Cloud Management", "value": true }, + { "label": "On-Premises Management", "value": false } + ] + } + ], + "label": "Configure Exchange Cloud Management for Remote/On-Premises Mailboxes", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-03-28", + "powershellEquivalent": "Set-Mailbox -Identity user@domain.com -IsExchangeCloudManaged $true or $false", + "recommendedBy": ["Microsoft", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV" + ] }, { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.9)", "ORCA108", "CISAMSEXO31"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CISAMSEXO31", "CIS_2_1_9", "ORCA108", "ORCA108_1", "SMB1001_2_12"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], @@ -1804,12 +2427,20 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "New-DkimSigningConfig and Set-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (2.1.10)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_10", "SMB1001_2_12"], "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", @@ -1823,10 +2454,7 @@ "label": "Value", "name": "standards.AddDMARCToMOERA.RecordValue", "options": [ - { - "label": "v=DMARC1; p=reject; (recommended)", - "value": "v=DMARC1; p=reject;" - } + { "label": "v=DMARC1; p=reject; (recommended)", "value": "v=DMARC1; p=reject;" } ] } ], @@ -1836,23 +2464,21 @@ "addedDate": "2025-06-16", "powershellEquivalent": "Portal only", "recommendedBy": ["CIS", "Microsoft"], - "disabledFeatures": { - "remediate": true - } + "disabledFeatures": { "remediate": true } }, { "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.1.1)", - "CIS M365 5.0 (6.1.2)", - "CIS M365 5.0 (6.1.3)", + "CIS M365 7.0.0 (6.1.1)", + "CIS M365 7.0.0 (6.1.2)", + "CIS M365 7.0.0 (6.1.3)", "exo_mailboxaudit", "Essential 8 (1509)", "Essential 8 (1683)", - "NIST CSF 2.0 (DE.CM-09)", - "CISAMSEXO131" + "NIST CSF 2.0 (DE.CM-09)" ], + "appliesToTest": ["CISAMSEXO131", "CIS_6_1_1", "CIS_6_1_2", "CIS_6_1_3"], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "executiveText": "Enables comprehensive logging of all email access and modifications across all employee mailboxes, providing detailed audit trails for security investigations and compliance requirements. This helps detect unauthorized access, data breaches, and supports regulatory compliance efforts.", @@ -1862,7 +2488,14 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -AuditDisabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoArchive", @@ -1888,7 +2521,14 @@ "impactColour": "info", "addedDate": "2025-12-11", "powershellEquivalent": "Set-OrganizationConfig -AutoArchivingThresholdPercentage 80-100", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoArchiveMailbox", @@ -1905,14 +2545,8 @@ "label": "Select value", "name": "standards.AutoArchiveMailbox.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1921,7 +2555,14 @@ "impactColour": "info", "addedDate": "2026-01-16", "powershellEquivalent": "Set-OrganizationConfig -AutoEnableArchiveMailbox $true|$false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SendReceiveLimitTenant", @@ -1956,7 +2597,14 @@ "impactColour": "info", "addedDate": "2023-11-16", "powershellEquivalent": "Set-MailboxPlan", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.calDefault", @@ -1965,11 +2613,7 @@ "helpText": "Sets the default sharing level for the default calendar, for all users", "docsDescription": "Sets the default sharing level for the default calendar for all users in the tenant. You can read about the different sharing levels [here.](https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxfolderpermission?view=exchange-ps#-accessrights)", "executiveText": "Configures how much calendar information employees share by default with colleagues, balancing collaboration needs with privacy. This setting determines whether others can see meeting details, free/busy times, or just availability, helping optimize scheduling while protecting sensitive meeting information.", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "addedComponent": [ { "type": "autoComplete", @@ -2001,10 +2645,7 @@ "label": "Non Editing Author - The user has full read access and create items. Can can delete only own items.", "value": "NonEditingAuthor" }, - { - "label": "Reviewer - The user can read all items in the folder.", - "value": "Reviewer" - }, + { "label": "Reviewer - The user can read all items in the folder.", "value": "Reviewer" }, { "label": "Contributor - The user can create items and folders.", "value": "Contributor" @@ -2017,10 +2658,7 @@ "label": "Limited Details - The user can view free/busy time within the calendar and the subject and location of appointments.", "value": "LimitedDetails" }, - { - "label": "None - The user has no permissions on the folder.", - "value": "none" - } + { "label": "None - The user has no permissions on the folder.", "value": "none" } ] } ], @@ -2029,12 +2667,20 @@ "impactColour": "info", "addedDate": "2023-04-27", "powershellEquivalent": "Set-MailboxFolderPermission", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.15)"], + "appliesToTest": ["CIS_2_1_15"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", "executiveText": "Sets limits on how many emails employees can send per hour and per day to prevent spam and protect the organization's email reputation. When limits are exceeded, the system can alert administrators or temporarily block the user, helping detect compromised accounts or prevent abuse.", @@ -2076,14 +2722,8 @@ "name": "standards.EXOOutboundSpamLimits.ActionWhenThresholdReached", "label": "Action When Threshold Reached", "options": [ - { - "label": "Alert", - "value": "Alert" - }, - { - "label": "Block User", - "value": "BlockUser" - }, + { "label": "Alert", "value": "Alert" }, + { "label": "Block User", "value": "BlockUser" }, { "label": "Block user from sending mail for the rest of the day", "value": "BlockUserForToday" @@ -2096,17 +2736,20 @@ "impactColour": "info", "addedDate": "2025-05-13", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIPP", "CIS"] + "recommendedBy": ["CIPP", "CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (1.3.3)", - "exo_individualsharing", - "ZTNA21803", - "CISAMSEXO62" - ], + "tag": ["CIS M365 7.0.0 (1.3.3)", "exo_individualsharing"], + "appliesToTest": ["CISAMSEXO62", "CIS_1_3_3", "ZTNA21803"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", @@ -2116,7 +2759,14 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Get-SharingPolicy | Set-SharingPolicy -Enabled $False", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoAddProxy", @@ -2132,20 +2782,13 @@ "addedDate": "2025-02-07", "powershellEquivalent": "Set-Mailbox -EmailAddresses @{add=$EmailAddress}", "recommendedBy": [], - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - } + "disabledFeatures": { "report": false, "warn": true, "remediate": false } }, { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.5.3)", - "exo_storageproviderrestricted", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (6.5.3)", "exo_storageproviderrestricted"], + "appliesToTest": ["CIS_6_5_3", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", @@ -2155,12 +2798,20 @@ "impactColour": "info", "addedDate": "2024-01-17", "powershellEquivalent": "Get-OwaMailboxPolicy | Set-OwaMailboxPolicy -AdditionalStorageProvidersEnabled $False", - "recommendedBy": ["CIS"] - }, + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": ["CIS M365 5.0 (2.1.13)"], + "tag": ["CIS M365 7.0.0 (2.1.13)"], + "appliesToTest": ["CIS_2_1_13"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", @@ -2176,7 +2827,14 @@ "impactColour": "info", "addedDate": "2025-02-15", "powershellEquivalent": "Set-HostedConnectionFilterPolicy \"Default\" -EnableSafeList $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.ShortenMeetings", @@ -2191,18 +2849,9 @@ "label": "Select value", "name": "standards.ShortenMeetings.ShortenEventScopeDefault", "options": [ - { - "label": "Disabled/None", - "value": "None" - }, - { - "label": "End early", - "value": "EndEarly" - }, - { - "label": "Start late", - "value": "StartLate" - } + { "label": "Disabled/None", "value": "None" }, + { "label": "End early", "value": "EndEarly" }, + { "label": "Start late", "value": "StartLate" } ] }, { @@ -2231,12 +2880,20 @@ "impactColour": "warning", "addedDate": "2024-05-27", "powershellEquivalent": "Set-OrganizationConfig -ShortenEventScopeDefault -DefaultMinutesToReduceShortEventsBy -DefaultMinutesToReduceLongEventsBy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.Bookings", "cat": "Exchange Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (1.3.9)"], + "appliesToTest": ["CIS_1_3_9"], "helpText": "Sets the state of Bookings on the tenant. Bookings is a scheduling tool that allows users to book appointments with others both internal and external.", "docsDescription": "", "executiveText": "Controls whether employees can use Microsoft Bookings to create online appointment scheduling pages for internal and external clients. This feature can improve customer service and streamline appointment management, but may need to be controlled for security or business process reasons.", @@ -2247,14 +2904,8 @@ "label": "Select value", "name": "standards.Bookings.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -2263,12 +2914,20 @@ "impactColour": "warning", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -BookingsEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXODirectSend", "cat": "Exchange Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (6.5.5)"], + "appliesToTest": ["CIS_6_5_5"], "helpText": "Sets the state of Direct Send in Exchange Online. Direct Send allows applications to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication.", "docsDescription": "Controls whether applications can use Direct Send to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication. A detailed explanation from Microsoft can be found [here.](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365)", "executiveText": "Controls whether business applications and devices (like printers or scanners) can send emails through the company's email system without authentication. While this enables convenient features like scan-to-email, it may pose security risks and should be carefully managed.", @@ -2280,14 +2939,8 @@ "label": "Select value", "name": "standards.EXODirectSend.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -2302,12 +2955,12 @@ "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.3.1)", + "CIS M365 7.0.0 (6.3.1)", "exo_outlookaddins", "NIST CSF 2.0 (PR.AA-05)", - "NIST CSF 2.0 (PR.PS-05)", - "ZTNA21817" + "NIST CSF 2.0 (PR.PS-05)" ], + "appliesToTest": ["CIS_6_3_1", "ZTNA21817"], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions. This ensures only vetted and approved tools can access company email data while maintaining centralized control over email functionality.", @@ -2317,7 +2970,14 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Get-ManagementRoleAssignment | Remove-ManagementRoleAssignment", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SafeSendersDisable", @@ -2326,17 +2986,20 @@ "helpText": "Loops through all users and removes the Safe Senders list. This is to prevent SPF bypass attacks, as the Safe Senders list is not checked by SPF.", "executiveText": "Removes user-defined safe sender lists to prevent security bypasses where malicious emails could avoid spam filtering. This ensures all emails go through proper security screening, even if users have previously marked senders as 'safe', improving overall email security.", "addedComponent": [], - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "label": "Remove Safe Senders to prevent SPF bypass", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2023-10-26", "powershellEquivalent": "Set-MailboxJunkEmailConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DelegateSentItems", @@ -2357,7 +3020,14 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SendFromAlias", @@ -2384,12 +3054,54 @@ "impactColour": "warning", "addedDate": "2022-05-25", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { - "name": "standards.UserSubmissions", + "name": "standards.DlpViaDcsEnabled", "cat": "Exchange Standards", "tag": [], + "helpText": "Sets whether Outlook on the web uses Data Classification Services for DLP evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "docsDescription": "Configures whether Outlook on the web uses Data Classification Services (DCS)-based Data Loss Prevention (DLP) policy evaluation instead of Exchange-based evaluation. Review DLP policies before enabling this setting, as some legacy Exchange-based predicates are not supported with DCS-based evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "executiveText": "Improves how Outlook on the web applies Data Loss Prevention policies, giving users clearer guidance when sensitive information may be shared and helping reduce accidental data exposure.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.DlpViaDcsEnabled.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Set OWA DLP evaluation via DCS", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-20", + "powershellEquivalent": "Set-OrganizationConfig -DlpViaDcsEnabled", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.UserSubmissions", + "cat": "Exchange Standards", + "tag": ["CIS M365 7.0.0 (8.6.1)"], + "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", "executiveText": "Enables employees to easily report suspicious emails directly from Outlook, helping improve the organization's spam and phishing detection systems. This crowdsourced approach to security allows users to contribute to threat detection while providing valuable feedback to enhance email security filters.", @@ -2400,14 +3112,8 @@ "label": "Select value", "name": "standards.UserSubmissions.state", "options": [ - { - "label": "Enabled", - "value": "enable" - }, - { - "label": "Disabled", - "value": "disable" - } + { "label": "Enabled", "value": "enable" }, + { "label": "Disabled", "value": "disable" } ] }, { @@ -2422,16 +3128,25 @@ "impactColour": "warning", "addedDate": "2024-06-28", "powershellEquivalent": "New-ReportSubmissionPolicy or Set-ReportSubmissionPolicy and New-ReportSubmissionRule or Set-ReportSubmissionRule", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (1.2.2)", + "CIS M365 7.0.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", - "NIST CSF 2.0 (PR.AA-01)" + "NIST CSF 2.0 (PR.AA-01)", + "SMB1001 (2.3)" ], + "appliesToTest": ["CIS_1_2_2", "SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", @@ -2446,7 +3161,8 @@ { "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", - "tag": ["NIST CSF 2.0 (PR.AA-01)"], + "tag": ["NIST CSF 2.0 (PR.AA-01)", "SMB1001 (2.3)"], + "appliesToTest": ["SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "executiveText": "Prevents direct login to resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", @@ -2456,18 +3172,26 @@ "impactColour": "warning", "addedDate": "2025-06-01", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": ["Microsoft", "CIPP"] + "recommendedBy": ["Microsoft", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXODisableAutoForwarding", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.2.1)", + "CIS M365 7.0.0 (6.2.1)", "mdo_autoforwardingmode", "mdo_blockmailforward", "CISA (MS.EXO.4.1v1)", "NIST CSF 2.0 (PR.DS-02)" ], + "appliesToTest": ["CIS_6_2_1"], "helpText": "Disables the ability for users to automatically forward e-mails to external recipients.", "docsDescription": "Disables the ability for users to automatically forward e-mails to external recipients. This is to prevent data exfiltration. Please check if there are any legitimate use cases for this feature before implementing, like forwarding invoices and such.", "executiveText": "Prevents employees from automatically forwarding company emails to external addresses, protecting against data leaks and unauthorized information sharing. This security measure helps maintain control over sensitive business communications while preventing both accidental and intentional data exfiltration.", @@ -2477,12 +3201,20 @@ "impactColour": "danger", "addedDate": "2024-07-26", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode 'Off'", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.RetentionPolicyTag", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.4.1)"], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "docsDescription": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "executiveText": "Automatically and permanently removes deleted emails after a specified number of days, helping manage storage costs and ensuring compliance with data retention policies. This prevents accumulation of unnecessary deleted items while maintaining a reasonable recovery window for accidentally deleted emails.", @@ -2499,7 +3231,14 @@ "impactColour": "danger", "addedDate": "2025-02-02", "powershellEquivalent": "Set-RetentionPolicyTag", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.QuarantineRequestAlert", @@ -2520,7 +3259,14 @@ "impactColour": "info", "addedDate": "2024-07-15", "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SharePointMassDeletionAlert", @@ -2556,18 +3302,15 @@ "impactColour": "info", "addedDate": "2025-04-07", "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["RMS_S_PREMIUM2"] }, { "name": "standards.SafeLinksTemplatePolicy", "label": "SafeLinks Policy Template", "cat": "Templates", "multiple": false, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2025-04-29", "helpText": "Deploy and manage SafeLinks policy templates to protect against malicious URLs in emails and Office documents.", @@ -2586,16 +3329,29 @@ "queryKey": "ListSafeLinksPolicyTemplates" } } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.1)", + "CIS M365 7.0.0 (2.1.1)", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps", - "NIST CSF 2.0 (DE.CM-09)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO151", + "CISAMSEXO152", + "CISAMSEXO153", + "CIS_2_1_1", "ORCA105", "ORCA106", "ORCA107", @@ -2606,13 +3362,11 @@ "ORCA119", "ORCA156", "ORCA179", + "ORCA189_2", "ORCA226", "ORCA236", "ORCA237", - "ORCA238", - "CISAMSEXO151", - "CISAMSEXO152", - "CISAMSEXO153" + "ORCA238" ], "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ @@ -2652,7 +3406,14 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeLinksPolicy or New-SafeLinksPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AntiPhishPolicy", @@ -2665,8 +3426,14 @@ "mdo_spam_notifications_only_for_admins", "mdo_antiphishingpolicies", "mdo_phishthresholdlevel", - "CIS M365 5.0 (2.1.7)", - "NIST CSF 2.0 (DE.CM-09)", + "CIS M365 7.0.0 (2.1.7)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO111", + "CISAMSEXO112", + "CISAMSEXO113", + "CIS_2_1_7", "ORCA104", "ORCA115", "ORCA180", @@ -2686,10 +3453,7 @@ "ORCA244", "ZTNA21784", "ZTNA21817", - "ZTNA21819", - "CISAMSEXO111", - "CISAMSEXO112", - "CISAMSEXO113" + "ZTNA21819" ], "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ @@ -2740,14 +3504,8 @@ "label": "If the message is detected as spoof by spoof intelligence", "name": "standards.AntiPhishPolicy.AuthenticationFailAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move to Junk Folder", "value": "MoveToJmf" } ] }, { @@ -2757,14 +3515,8 @@ "label": "Quarantine policy for Spoof", "name": "standards.AntiPhishPolicy.SpoofQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2777,18 +3529,9 @@ "label": "If a message is detected as user impersonation", "name": "standards.AntiPhishPolicy.TargetedUserProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2798,14 +3541,8 @@ "label": "Quarantine policy for user impersonation", "name": "standards.AntiPhishPolicy.TargetedUserQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2818,18 +3555,9 @@ "label": "If a message is detected as domain impersonation", "name": "standards.AntiPhishPolicy.TargetedDomainProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2843,14 +3571,8 @@ "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" }, - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - } + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" } ] }, { @@ -2859,18 +3581,9 @@ "label": "If Mailbox Intelligence detects an impersonated user", "name": "standards.AntiPhishPolicy.MailboxIntelligenceProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2880,14 +3593,8 @@ "label": "Apply quarantine policy", "name": "standards.AntiPhishPolicy.MailboxIntelligenceQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2900,20 +3607,26 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.4)", + "CIS M365 7.0.0 (2.1.4)", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy", - "NIST CSF 2.0 (DE.CM-09)", - "ORCA158", - "ORCA227" + "NIST CSF 2.0 (DE.CM-09)" ], + "appliesToTest": ["CIS_2_1_4", "ORCA158", "ORCA189", "ORCA227"], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ { @@ -2929,18 +3642,9 @@ "label": "Safe Attachment Action", "name": "standards.SafeAttachmentPolicy.SafeAttachmentAction", "options": [ - { - "label": "Allow", - "value": "Allow" - }, - { - "label": "Block", - "value": "Block" - }, - { - "label": "DynamicDelivery", - "value": "DynamicDelivery" - } + { "label": "Allow", "value": "Allow" }, + { "label": "Block", "value": "Block" }, + { "label": "DynamicDelivery", "value": "DynamicDelivery" } ] }, { @@ -2950,25 +3654,15 @@ "label": "QuarantineTag", "name": "standards.SafeAttachmentPolicy.QuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" } ] }, - { - "type": "switch", - "label": "Redirect", - "name": "standards.SafeAttachmentPolicy.Redirect" - }, + { "type": "switch", "label": "Redirect", "name": "standards.SafeAttachmentPolicy.Redirect" }, { "type": "textField", "name": "standards.SafeAttachmentPolicy.RedirectAddress", @@ -2986,12 +3680,20 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeAttachmentPolicy or New-SafeAttachmentPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS M365 5.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_2_1_5", "ORCA225"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ { @@ -3007,12 +3709,21 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AtpPolicyForO365", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.PhishingSimulations", "cat": "Defender Standards", - "tag": [], + "tag": ["SMB1001 (1.11)", "SMB1001 (5.1)"], + "appliesToTest": ["SMB1001_1_11", "SMB1001_5_1"], "helpText": "This creates a phishing simulation policy that enables phishing simulations for the entire tenant.", "addedComponent": [ { @@ -3052,27 +3763,42 @@ "impactColour": "info", "addedDate": "2025-03-27", "powershellEquivalent": "New-TenantAllowBlockListItems, New-PhishSimOverridePolicy and New-ExoPhishSimOverrideRule", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.2)", - "CIS M365 5.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.2)", + "CIS M365 7.0.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.11)", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware", - "NIST CSF 2.0 (DE.CM-09)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO101", + "CISAMSEXO102", + "CISAMSEXO103", + "CISAMSEXO95", + "CIS_2_1_11", + "CIS_2_1_2", + "CIS_2_1_3", + "ORCA120_malware", "ORCA121", "ORCA124", + "ORCA205", "ORCA232", "ZTNA21817", - "ZTNA21819", - "CISAMSEXO95", - "CISAMSEXO101", - "CISAMSEXO102", - "CISAMSEXO103" + "ZTNA21819" ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ @@ -3089,14 +3815,8 @@ "label": "FileTypeAction", "name": "standards.MalwareFilterPolicy.FileTypeAction", "options": [ - { - "label": "Reject", - "value": "Reject" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Reject", "value": "Reject" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -3112,14 +3832,8 @@ "label": "QuarantineTag", "name": "standards.MalwareFilterPolicy.QuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3166,7 +3880,14 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.PhishSimSpoofIntelligence", @@ -3195,17 +3916,34 @@ "impactColour": "info", "addedDate": "2025-03-28", "powershellEquivalent": "New-TenantAllowBlockListSpoofItems", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SpamFilterPolicy", "cat": "Defender Standards", - "tag": [ + "tag": [], + "appliesToTest": [ + "CISAMSEXO141", + "CISAMSEXO142", + "CISAMSEXO143", "ORCA100", "ORCA101", "ORCA102", "ORCA103", "ORCA104", + "ORCA109", + "ORCA110", + "ORCA118_1", + "ORCA118_3", + "ORCA120_phish", + "ORCA120_spam", "ORCA123", "ORCA139", "ORCA140", @@ -3214,10 +3952,7 @@ "ORCA143", "ORCA224", "ORCA231", - "ORCA241", - "CISAMSEXO141", - "CISAMSEXO142", - "CISAMSEXO143" + "ORCA241" ], "helpText": "This standard creates a Spam filter policy similar to the default strict policy.", "docsDescription": "This standard creates a Spam filter policy similar to the default strict policy, the following settings are configured to on by default: IncreaseScoreWithNumericIps, IncreaseScoreWithRedirectToOtherPort, MarkAsSpamEmptyMessages, MarkAsSpamJavaScriptInHtml, MarkAsSpamSpfRecordHardFail, MarkAsSpamFromAddressAuthFail, MarkAsSpamNdrBackscatter, MarkAsSpamBulkMail, InlineSafetyTipsEnabled, PhishZapEnabled, SpamZapEnabled", @@ -3247,14 +3982,8 @@ "label": "Spam Action", "name": "standards.SpamFilterPolicy.SpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3265,14 +3994,8 @@ "label": "Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.SpamQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3287,14 +4010,8 @@ "label": "High Confidence Spam Action", "name": "standards.SpamFilterPolicy.HighConfidenceSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3305,14 +4022,8 @@ "label": "High Confidence Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidenceSpamQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3327,14 +4038,8 @@ "label": "Bulk Spam Action", "name": "standards.SpamFilterPolicy.BulkSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3345,14 +4050,8 @@ "label": "Bulk Quarantine Tag", "name": "standards.SpamFilterPolicy.BulkQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3367,14 +4066,8 @@ "label": "Phish Spam Action", "name": "standards.SpamFilterPolicy.PhishSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3385,14 +4078,8 @@ "label": "Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.PhishQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3407,14 +4094,8 @@ "label": "High Confidence Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidencePhishQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3521,16 +4202,19 @@ "impactColour": "warning", "addedDate": "2024-07-15", "powershellEquivalent": "New-HostedContentFilterPolicy or Set-HostedContentFilterPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.QuarantineTemplate", "cat": "Defender Standards", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "tag": [], "helpText": "This standard creates a Custom Quarantine Policies that can be used in Anti-Spam and all MDO365 policies. Quarantine Policies can be used to specify recipients permissions, enable end-user spam notifications, and specify the release action preference", "executiveText": "Creates standardized quarantine policies that define how employees can interact with quarantined emails, including permissions to release, delete, or preview suspicious messages. This ensures consistent security handling across the organization while providing appropriate user access to manage quarantined content.", @@ -3608,7 +4292,14 @@ "impactColour": "info", "addedDate": "2025-05-16", "powershellEquivalent": "Set-QuarantinePolicy or New-QuarantinePolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.IntuneWindowsDiagnostic", @@ -3636,7 +4327,8 @@ "impactColour": "info", "addedDate": "2026-01-27", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.WindowsBackupRestore", @@ -3664,7 +4356,8 @@ "impactColour": "info", "addedDate": "2026-02-26", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceRetirementDays", @@ -3684,7 +4377,8 @@ "impactColour": "info", "addedDate": "2023-05-19", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneBrandingProfile", @@ -3758,12 +4452,14 @@ "impactColour": "info", "addedDate": "2024-06-20", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.IntuneComplianceSettings", "cat": "Intune Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (4.1)"], + "appliesToTest": ["CIS_4_1"], "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", "addedComponent": [ @@ -3775,14 +4471,8 @@ "name": "standards.IntuneComplianceSettings.secureByDefault", "label": "Mark devices with no compliance policy as", "options": [ - { - "label": "Compliant", - "value": "false" - }, - { - "label": "Non-Compliant", - "value": "true" - } + { "label": "Compliant", "value": "false" }, + { "label": "Non-Compliant", "value": "true" } ] }, { @@ -3801,7 +4491,8 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.MDMScope", @@ -3816,18 +4507,9 @@ "label": "MDM User Scope?", "type": "radio", "options": [ - { - "label": "All", - "value": "all" - }, - { - "label": "None", - "value": "none" - }, - { - "label": "Custom Group", - "value": "selected" - } + { "label": "All", "value": "all" }, + { "label": "None", "value": "none" }, + { "label": "Custom Group", "value": "selected" } ] }, { @@ -3842,12 +4524,14 @@ "impactColour": "info", "addedDate": "2025-02-18", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": ["CISA (MS.AAD.19.1v1)"], + "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], + "appliesToTest": ["CIS_4_2"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ @@ -3917,7 +4601,8 @@ "impactColour": "info", "addedDate": "2025-04-01", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.MDMEnrollmentDuringRegistration", @@ -3938,7 +4623,8 @@ "impactColour": "warning", "addedDate": "2025-12-15", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration", @@ -3953,18 +4639,9 @@ "label": "Configure Windows Hello for Business", "multiple": false, "options": [ - { - "label": "Not configured", - "value": "notConfigured" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -3999,18 +4676,9 @@ "label": "Lowercase letters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4019,18 +4687,9 @@ "label": "Uppercase letters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4039,18 +4698,9 @@ "label": "Special characters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4077,18 +4727,9 @@ "label": "Use enhanced anti-spoofing when available", "multiple": false, "options": [ - { - "label": "Not configured", - "value": "notConfigured" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -4096,6 +4737,28 @@ "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", "label": "Allow phone sign-in", "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedSignInSecurity", + "label": "Enable enhanced sign-in security", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "0" }, + { "label": "Enabled on capable hardware", "value": "1" }, + { "label": "Disabled on all systems", "value": "2" } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityKeyForSignIn", + "label": "Use security keys for sign-in", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] } ], "label": "Windows Hello for Business enrollment configuration", @@ -4103,12 +4766,14 @@ "impactColour": "info", "addedDate": "2025-09-25", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", - "tag": ["CISA (MS.AAD.17.1v1)", "ZTNA21801", "ZTNA21802"], + "tag": ["CIS M365 7.0.0 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], + "appliesToTest": ["CIS_5_1_4_2", "ZTNA21801", "ZTNA21802", "ZTNA21837"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", "addedComponent": [ @@ -4124,12 +4789,14 @@ "impactColour": "warning", "addedDate": "2023-03-27", "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceRegLocalAdmins", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (5.1.4.3)", "CIS M365 7.0.0 (5.1.4.4)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_3", "CIS_5_1_4_4", "SMB1001_2_2"], "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", "executiveText": "Controls whether employees who enroll devices automatically receive local administrator access. Disabling registering-user admin rights follows least-privilege principles and reduces security risk from over-privileged endpoints.", @@ -4158,6 +4825,7 @@ "name": "standards.intuneRestrictUserDeviceRegistration", "cat": "Entra (AAD) Standards", "tag": [], + "appliesToTest": [], "helpText": "Controls whether users can register devices with Entra.", "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", @@ -4176,10 +4844,34 @@ "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", "recommendedBy": [] }, + { + "name": "standards.intuneRestrictUserDeviceJoin", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.1.4.1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_4_1", "SMB1001_2_8"], + "helpText": "Controls whether users can join devices to Entra.", + "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", + "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.intuneRestrictUserDeviceJoin.disableUserDeviceJoin", + "label": "Disable users from joining devices", + "defaultValue": true + } + ], + "label": "Configure user restriction for Entra device join", + "impact": "High Impact", + "impactColour": "warning", + "addedDate": "2026-05-15", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", - "tag": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], + "tag": [], + "appliesToTest": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", @@ -4192,7 +4884,8 @@ { "name": "standards.DeletedUserRentention", "cat": "SharePoint Standards", - "tag": [], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Sets the retention period for deleted users OneDrive to the specified period of time. The default is 30 days.", "docsDescription": "When a OneDrive user gets deleted, the personal SharePoint site is saved for selected amount of time that data can be retrieved from it.", "executiveText": "Preserves departed employees' OneDrive files for a specified period, allowing time to recover important business documents before permanent deletion. This helps prevent data loss while managing storage costs and maintaining compliance with data retention policies.", @@ -4203,54 +4896,18 @@ "name": "standards.DeletedUserRentention.Days", "label": "Retention time (Default 30 days)", "options": [ - { - "label": "30 days", - "value": "30" - }, - { - "label": "90 days", - "value": "90" - }, - { - "label": "1 year", - "value": "365" - }, - { - "label": "2 years", - "value": "730" - }, - { - "label": "3 years", - "value": "1095" - }, - { - "label": "4 years", - "value": "1460" - }, - { - "label": "5 years", - "value": "1825" - }, - { - "label": "6 years", - "value": "2190" - }, - { - "label": "7 years", - "value": "2555" - }, - { - "label": "8 years", - "value": "2920" - }, - { - "label": "9 years", - "value": "3285" - }, - { - "label": "10 years", - "value": "3650" - } + { "label": "30 days", "value": "30" }, + { "label": "90 days", "value": "90" }, + { "label": "1 year", "value": "365" }, + { "label": "2 years", "value": "730" }, + { "label": "3 years", "value": "1095" }, + { "label": "4 years", "value": "1460" }, + { "label": "5 years", "value": "1825" }, + { "label": "6 years", "value": "2190" }, + { "label": "7 years", "value": "2555" }, + { "label": "8 years", "value": "2920" }, + { "label": "9 years", "value": "3285" }, + { "label": "10 years", "value": "3650" } ] } ], @@ -4259,7 +4916,15 @@ "impactColour": "info", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPFileRequests", @@ -4290,7 +4955,15 @@ "impactColour": "warning", "addedDate": "2025-07-30", "powershellEquivalent": "Set-SPOTenant -CoreRequestFilesLinkEnabled $true -OneDriveRequestFilesLinkEnabled $true -CoreRequestFilesLinkExpirationInDays 30 -OneDriveRequestFilesLinkExpirationInDays 30", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.TenantDefaultTimezone", @@ -4310,12 +4983,21 @@ "impactColour": "info", "addedDate": "2024-04-20", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS M365 5.0 (7.2.2)"], + "tag": ["CIS M365 7.0.0 (7.2.2)"], + "appliesToTest": ["CIS_7_2_2"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", "addedComponent": [], @@ -4324,17 +5006,21 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EnableAzureADB2BIntegration $true", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.3.1)", - "CISA (MS.SPO.3.1v1)", - "NIST CSF 2.0 (DE.CM-09)", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_7_3_1", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], @@ -4343,7 +5029,15 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DisallowInfectedFileDownload $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDisableLegacyWorkflows", @@ -4357,7 +5051,15 @@ "impactColour": "info", "addedDate": "2024-07-15", "powershellEquivalent": "Set-SPOTenant -DisableWorkflow2010 $true -DisableWorkflow2013 $true -DisableBackToClassic $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDirectSharing", @@ -4371,18 +5073,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType Direct", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.9)", - "CISA (MS.SPO.1.5v1)", - "ZTNA21803", - "ZTNA21804", - "ZTNA21858" - ], + "tag": ["CIS M365 7.0.0 (7.2.9)", "CISA (MS.SPO.1.5v1)"], + "appliesToTest": ["CIS_7_2_9", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ @@ -4402,17 +5107,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.10)", - "CISA (MS.SPO.1.6v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.10)", "CISA (MS.SPO.1.6v1)"], + "appliesToTest": ["CIS_7_2_10", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ @@ -4432,18 +5141,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DefaultSharingLink", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.7)", - "CIS M365 5.0 (7.2.11)", - "CISA (MS.SPO.1.4v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.7)", "CIS M365 7.0.0 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "appliesToTest": ["CIS_7_2_11", "CIS_7_2_7", "ZTNA21803", "ZTNA21804"], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", "executiveText": "Configures SharePoint default sharing links to implement the principle of least privilege for document sharing. This security measure reduces the risk of accidental data modification while maintaining collaboration functionality, requiring users to explicitly select Edit permissions when necessary. The sharing type setting controls whether links are restricted to specific recipients or available to the entire organization. This reduces the risk of accidental data exposure through link sharing.", @@ -4456,14 +5168,8 @@ "label": "Default Sharing Link Type", "name": "standards.DefaultSharingLink.SharingLinkType", "options": [ - { - "label": "Direct - Only the people the user specifies", - "value": "Direct" - }, - { - "label": "Internal - Only people in your organization", - "value": "Internal" - } + { "label": "Direct - Only the people the user specifies", "value": "Direct" }, + { "label": "Internal - Only people in your organization", "value": "Internal" } ] } ], @@ -4472,7 +5178,15 @@ "impactColour": "info", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType [Direct|Internal] -DefaultLinkPermission View", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableAddShortcutsToOneDrive", @@ -4488,14 +5202,8 @@ "label": "Add Shortcuts To OneDrive button state", "name": "standards.DisableAddShortcutsToOneDrive.state", "options": [ - { - "label": "Disabled", - "value": "true" - }, - { - "label": "Enabled", - "value": "false" - } + { "label": "Disabled", "value": "true" }, + { "label": "Enabled", "value": "false" } ] } ], @@ -4504,7 +5212,15 @@ "impactColour": "warning", "addedDate": "2023-07-25", "powershellEquivalent": "Set-SPOTenant -DisableAddShortcutsToOneDrive $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPSyncButtonState", @@ -4520,14 +5236,8 @@ "label": "SharePoint Sync Button state", "name": "standards.SPSyncButtonState.state", "options": [ - { - "label": "Disabled", - "value": "true" - }, - { - "label": "Enabled", - "value": "false" - } + { "label": "Disabled", "value": "true" }, + { "label": "Enabled", "value": "false" } ] } ], @@ -4536,20 +5246,26 @@ "impactColour": "warning", "addedDate": "2024-07-26", "powershellEquivalent": "Set-SPOTenant -HideSyncButtonOnTeamSite $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", "tag": [ - "CIS M365 5.0 (6.5.1)", - "CIS M365 5.0 (7.2.1)", + "CIS M365 7.0.0 (7.2.1)", "spo_legacy_auth", "CISA (MS.AAD.3.1v1)", - "NIST CSF 2.0 (PR.IR-01)", - "ZTNA21776", - "ZTNA21797" + "NIST CSF 2.0 (PR.IR-01)" ], + "appliesToTest": ["CIS_7_2_1", "ZTNA21776", "ZTNA21797"], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", "executiveText": "Disables outdated authentication methods for SharePoint access, forcing applications and users to use modern, more secure authentication protocols. This significantly improves security by eliminating vulnerable authentication pathways while requiring updates to older applications.", @@ -4559,18 +5275,26 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.sharingCapability", "cat": "SharePoint Standards", "tag": [ - "CIS M365 5.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.4)", "CISA (MS.AAD.14.1v1)", - "CISA (MS.SPO.1.1v1)", - "ZTNA21803", - "ZTNA21804" + "CISA (MS.SPO.1.1v1)" ], + "appliesToTest": ["CIS_7_2_3", "CIS_7_2_4", "ZTNA21803", "ZTNA21804"], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", "addedComponent": [ @@ -4604,18 +5328,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.5)", - "CISA (MS.AAD.14.2v1)", - "CISA (MS.SPO.1.2v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], + "appliesToTest": ["CIS_7_2_5", "ZTNA21803", "ZTNA21804"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Prevents external users from sharing company documents with additional people, maintaining control over document distribution and preventing unauthorized access expansion. This security measure ensures that external sharing remains within intended boundaries set by internal employees.", @@ -4625,12 +5352,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableUserSiteCreate", "cat": "SharePoint Standards", - "tag": [], + "tag": ["SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8"], "helpText": "Disables users from creating new SharePoint sites", "docsDescription": "Disables standard users from creating SharePoint sites, also disables the ability to fully create teams", "executiveText": "Restricts the creation of new SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces and ensuring proper governance. This maintains organized information architecture while requiring approval for new collaborative environments.", @@ -4640,7 +5376,15 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.ExcludedfileExt", @@ -4660,7 +5404,15 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.disableMacSync", @@ -4674,17 +5426,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.3)", - "CISA (MS.SPO.2.1v1)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA24824" - ], + "tag": ["CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "appliesToTest": ["ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -4696,14 +5452,8 @@ "name": "standards.unmanagedSync.state", "label": "State", "options": [ - { - "label": "Allow limited, web-only access", - "value": "1" - }, - { - "label": "Block access", - "value": "2" - } + { "label": "Allow limited, web-only access", "value": "1" }, + { "label": "Block access", "value": "2" } ], "required": false } @@ -4713,18 +5463,14 @@ "impactColour": "danger", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -ConditionalAccessPolicy AllowFullAccess | AllowLimitedAccess | BlockAccess", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.6)", - "CISA (MS.AAD.14.3v1)", - "CISA (MS.SPO.1.3v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], + "appliesToTest": ["CIS_7_2_6", "ZTNA21803", "ZTNA21804"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", "addedComponent": [ @@ -4734,18 +5480,9 @@ "name": "standards.sharingDomainRestriction.Mode", "label": "Limit external sharing by domains", "options": [ - { - "label": "Off", - "value": "none" - }, - { - "label": "Restrict sharing to specific domains", - "value": "allowList" - }, - { - "label": "Block sharing to specific domains", - "value": "blockList" - } + { "label": "Off", "value": "none" }, + { "label": "Restrict sharing to specific domains", "value": "allowList" }, + { "label": "Block sharing to specific domains", "value": "blockList" } ] }, { @@ -4760,18 +5497,40 @@ "impactColour": "danger", "addedDate": "2024-06-20", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.TeamsGlobalMeetingPolicy", "cat": "Teams Standards", "tag": [ - "CIS M365 5.0 (8.5.1)", - "CIS M365 5.0 (8.5.2)", - "CIS M365 5.0 (8.5.3)", - "CIS M365 5.0 (8.5.4)", - "CIS M365 5.0 (8.5.5)", - "CIS M365 5.0 (8.5.6)" + "CIS M365 7.0.0 (8.5.1)", + "CIS M365 7.0.0 (8.5.2)", + "CIS M365 7.0.0 (8.5.3)", + "CIS M365 7.0.0 (8.5.4)", + "CIS M365 7.0.0 (8.5.5)", + "CIS M365 7.0.0 (8.5.6)", + "CIS M365 7.0.0 (8.5.7)", + "CIS M365 7.0.0 (8.5.8)", + "CIS M365 7.0.0 (8.5.9)" + ], + "appliesToTest": [ + "CIS_8_5_1", + "CIS_8_5_2", + "CIS_8_5_3", + "CIS_8_5_4", + "CIS_8_5_5", + "CIS_8_5_6", + "CIS_8_5_7", + "CIS_8_5_8", + "CIS_8_5_9" ], "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl", "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", @@ -4784,22 +5543,13 @@ "name": "standards.TeamsGlobalMeetingPolicy.DesignatedPresenterRoleMode", "label": "Default value of the `Who can present?`", "options": [ - { - "label": "Everyone", - "value": "EveryoneUserOverride" - }, - { - "label": "People in my organization", - "value": "EveryoneInCompanyUserOverride" - }, + { "label": "Everyone", "value": "EveryoneUserOverride" }, + { "label": "People in my organization", "value": "EveryoneInCompanyUserOverride" }, { "label": "People in my organization and trusted organizations", "value": "EveryoneInSameAndFederatedCompanyUserOverride" }, - { - "label": "Only organizer", - "value": "OrganizerOnlyUserOverride" - } + { "label": "Only organizer", "value": "OrganizerOnlyUserOverride" } ] }, { @@ -4821,10 +5571,7 @@ "label": "Who can bypass the lobby?", "helperText": "If left blank, the current value will not be changed.", "options": [ - { - "label": "Only organizers and co-organizers", - "value": "OrganizerOnly" - }, + { "label": "Only organizers and co-organizers", "value": "OrganizerOnly" }, { "label": "People in organization excluding guests", "value": "EveryoneInCompanyExcludingGuests" @@ -4833,14 +5580,8 @@ "label": "People in same or federated organizations", "value": "EveryoneInSameAndFederatedCompany" }, - { - "label": "People who were invited", - "value": "InvitedUsers" - }, - { - "label": "Everyone", - "value": "Everyone" - } + { "label": "People who were invited", "value": "InvitedUsers" }, + { "label": "Everyone", "value": "Everyone" } ] }, { @@ -4856,18 +5597,9 @@ "name": "standards.TeamsGlobalMeetingPolicy.MeetingChatEnabledType", "label": "Meeting chat policy", "options": [ - { - "label": "On for everyone", - "value": "Enabled" - }, - { - "label": "On for everyone but anonymous users", - "value": "EnabledExceptAnonymous" - }, - { - "label": "Off for everyone", - "value": "Disabled" - } + { "label": "On for everyone", "value": "Enabled" }, + { "label": "On for everyone but anonymous users", "value": "EnabledExceptAnonymous" }, + { "label": "Off for everyone", "value": "Disabled" } ] }, { @@ -4886,7 +5618,8 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsChatProtection", @@ -4914,12 +5647,14 @@ "impactColour": "info", "addedDate": "2025-10-02", "powershellEquivalent": "Set-CsTeamsMessagingConfiguration -FileTypeCheck 'Enabled' -UrlReputationCheck 'Enabled' -ReportIncorrectSecurityDetections 'Enabled'", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalChatWithAnyone", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.3)"], + "appliesToTest": ["CIS_8_2_3"], "helpText": "Controls whether users can start Teams chats with any email address, inviting external recipients as guests via email.", "docsDescription": "Manages the Teams messaging policy setting UseB2BInvitesToAddExternalUsers. When enabled, users can start chats with any email address and recipients receive an invitation to join the chat as guests. Disabling the setting prevents these external email chats from being created, keeping conversations limited to internal users and approved guests.", "executiveText": "Allows organizations to decide if employees can launch Microsoft Teams chats with anyone on the internet using just an email address. Disabling the feature keeps conversations inside trusted boundaries and helps prevent accidental data exposure through unexpected external invitations.", @@ -4929,14 +5664,8 @@ "name": "standards.TeamsExternalChatWithAnyone.UseB2BInvitesToAddExternalUsers", "label": "Allow chatting with anyone via email", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ], "defaultValue": "Disabled" } @@ -4946,7 +5675,8 @@ "impactColour": "info", "addedDate": "2025-11-03", "powershellEquivalent": "Set-CsTeamsMessagingPolicy -Identity Global -UseB2BInvitesToAddExternalUsers $false/$true", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsEmailIntegration", @@ -4967,7 +5697,9 @@ "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", "recommendedBy": ["CIS"], - "tag": ["CIS M365 5.0 (8.1.2)"] + "tag": ["CIS M365 7.0.0 (8.1.2)"], + "appliesToTest": ["CIS_8_1_2"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsGuestAccess", @@ -4988,7 +5720,8 @@ "impactColour": "info", "addedDate": "2025-06-03", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGuestUser $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMeetingVerification", @@ -5005,10 +5738,7 @@ "label": "CAPTCHA Verification Setting", "name": "standards.TeamsMeetingVerification.CaptchaVerificationForMeetingJoin", "options": [ - { - "label": "Not Required", - "value": "NotRequired" - }, + { "label": "Not Required", "value": "NotRequired" }, { "label": "Anonymous Users and Untrusted Organizations", "value": "AnonymousUsersAndUntrustedOrganizations" @@ -5021,12 +5751,14 @@ "impactColour": "info", "addedDate": "2025-06-14", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -CaptchaVerificationForMeetingJoin", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalFileSharing", "cat": "Teams Standards", - "tag": ["CIS M365 5.0 (8.4.1)"], + "tag": ["CIS M365 7.0.0 (8.1.1)"], + "appliesToTest": ["CIS_8_1_1"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", "addedComponent": [ @@ -5061,7 +5793,8 @@ "impactColour": "info", "addedDate": "2024-07-28", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsEnrollUser", @@ -5079,14 +5812,8 @@ "name": "standards.TeamsEnrollUser.EnrollUserOverride", "label": "Voice and Face Enrollment", "options": [ - { - "label": "Disabled", - "value": "Disabled" - }, - { - "label": "Enabled", - "value": "Enabled" - } + { "label": "Disabled", "value": "Disabled" }, + { "label": "Enabled", "value": "Enabled" } ] } ], @@ -5095,12 +5822,14 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -Identity Global -EnrollUserOverride $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalAccessPolicy", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.1)", "CIS M365 7.0.0 (8.2.2)"], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_2"], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", "executiveText": "Defines the organization's policy for communicating with external users through Teams, including other organizations, Skype users, and unmanaged accounts. This fundamental setting determines the scope of external collaboration while maintaining security boundaries for business communications.", @@ -5121,12 +5850,14 @@ "impactColour": "warning", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsExternalAccessPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsFederationConfiguration", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.1)"], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_4"], "helpText": "Sets the properties of the Global federation configuration.", "docsDescription": "Sets the properties of the Global federation configuration. Federation configuration settings determine whether or not your users can communicate with users who have SIP accounts with a federated organization.", "executiveText": "Configures how the organization federates with external organizations for Teams communication, controlling whether employees can communicate with specific external domains or all external organizations. This setting enables secure inter-organizational collaboration while maintaining control over external communications.", @@ -5144,22 +5875,10 @@ "name": "standards.TeamsFederationConfiguration.DomainControl", "label": "Communication Mode", "options": [ - { - "label": "Allow all external domains", - "value": "AllowAllExternal" - }, - { - "label": "Block all external domains", - "value": "BlockAllExternal" - }, - { - "label": "Allow specific external domains", - "value": "AllowSpecificExternal" - }, - { - "label": "Block specific external domains", - "value": "BlockSpecificExternal" - } + { "label": "Allow all external domains", "value": "AllowAllExternal" }, + { "label": "Block all external domains", "value": "BlockAllExternal" }, + { "label": "Allow specific external domains", "value": "AllowSpecificExternal" }, + { "label": "Block specific external domains", "value": "BlockSpecificExternal" } ] }, { @@ -5179,7 +5898,8 @@ "impactColour": "warning", "addedDate": "2024-07-31", "powershellEquivalent": "Set-CsTenantFederationConfiguration", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMeetingRecordingExpiration", @@ -5206,12 +5926,14 @@ "impactColour": "warning", "addedDate": "2025-04-17", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -Identity Global -MeetingRecordingExpirationDays ", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMessagingPolicy", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.6.1)"], + "appliesToTest": ["CIS_8_6_1"], "helpText": "Sets the properties of the Global messaging policy.", "docsDescription": "Sets the properties of the Global messaging policy. Messaging policies control which chat and channel messaging features are available to users in Teams.", "executiveText": "Defines what messaging capabilities employees have in Teams, including the ability to edit or delete messages, create custom emojis, and report inappropriate content. These policies help maintain professional communication standards while enabling necessary collaboration features.", @@ -5248,18 +5970,9 @@ "name": "standards.TeamsMessagingPolicy.ReadReceiptsEnabledType", "label": "Read Receipts Enabled Type", "options": [ - { - "label": "User controlled", - "value": "UserPreference" - }, - { - "label": "Turned on for everyone", - "value": "Everyone" - }, - { - "label": "Turned off for everyone", - "value": "None" - } + { "label": "User controlled", "value": "UserPreference" }, + { "label": "Turned on for everyone", "value": "Everyone" }, + { "label": "Turned off for everyone", "value": "None" } ] }, { @@ -5292,17 +6005,14 @@ "impactColour": "warning", "addedDate": "2025-01-10", "powershellEquivalent": "Set-CsTeamsMessagingPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.AutopilotStatusPage", "cat": "Device Management Standards", "tag": [], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "helpText": "Deploy the Autopilot Status Page, which shows progress during device setup through Autopilot.", "docsDescription": "This standard allows configuration of the Autopilot Status Page, providing users with a visual representation of the progress during device setup. It includes options like timeout, logging, and retry settings.", "executiveText": "Provides employees with a visual progress indicator during automated device setup, improving the user experience when receiving new computers. This reduces IT support calls and helps ensure successful device deployment by guiding users through the setup process.", @@ -5370,17 +6080,15 @@ "impact": "Low Impact", "addedDate": "2023-12-30", "impactColour": "info", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.AutopilotProfile", "cat": "Device Management Standards", - "tag": [], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "tag": ["SMB1001 (2.2)"], + "appliesToTest": ["SMB1001_2_2"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "helpText": "Assign the appropriate Autopilot profile to streamline device deployment.", "docsDescription": "This standard allows the deployment of Autopilot profiles to devices, including settings such as unique name templates, language options, and local admin privileges.", "addedComponent": [ @@ -5407,11 +6115,7 @@ "required": false, "name": "standards.AutopilotProfile.Languages", "label": "Languages", - "api": { - "url": "/languageList.json", - "labelField": "languageTag", - "valueField": "tag" - } + "api": { "url": "/languageList.json", "labelField": "languageTag", "valueField": "tag" } }, { "type": "switch", @@ -5472,20 +6176,165 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2023-12-30", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, + { + "name": "standards.DevicePrepProfile", + "cat": "Device Management Standards", + "tag": ["autopilot", "device_prep", "enrollment"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages a Windows Autopilot Device Preparation profile for streamlined device enrollment.", + "docsDescription": "Deploys a Windows Autopilot Device Preparation profile through Intune configuration policies. This standard manages deployment mode, join type, account type, timeout, error messages, and optional device security group assignment. Optionally creates a new security group with the Intune Provisioning Client as owner.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileDescription", + "label": "Profile Description", + "required": false + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.DeploymentType", + "label": "Deployment Type", + "options": [ + { "label": "Single user", "value": "0" }, + { "label": "Shared", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.JoinType", + "label": "Join Type", + "options": [ + { "label": "Microsoft Entra join", "value": "0" }, + { "label": "Microsoft Entra hybrid join", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.AccountType", + "label": "Account Type", + "options": [ + { "label": "Standard user", "value": "0" }, + { "label": "Administrator", "value": "1" } + ] + }, + { + "type": "number", + "name": "standards.DevicePrepProfile.Timeout", + "label": "Timeout (minutes)", + "defaultValue": 60 + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.CustomErrorMessage", + "label": "Custom Error Message", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowSkip", + "label": "Allow users to skip setup after failure", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowDiagnostics", + "label": "Allow users to collect diagnostics", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.DeviceGroupName", + "label": "Device Security Group Name (wildcard match)", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.CreateNewGroup", + "label": "Create new group if group is not found", + "defaultValue": false + }, + { + "type": "radio", + "name": "standards.DevicePrepProfile.AssignTo", + "label": "Policy Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "All devices", "value": "AllDevices" }, + { "label": "All users and devices", "value": "AllDevicesAndUsers" } + ] + } + ], + "label": "Deploy Device Prep Profile", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-25", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.IntuneTemplate", "cat": "Templates", "label": "Intune Template", "multiple": true, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", + "tag": [ + "SMB1001 (1.2)", + "SMB1001 (1.3)", + "SMB1001 (1.4)", + "SMB1001 (1.8)", + "SMB1001 (1.9)", + "SMB1001 (1.10)", + "SMB1001 (1.12)", + "SMB1001 (2.2)", + "SMB1001 (4.7)" + ], + "appliesToTest": [ + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "SMB1001_2_2", + "SMB1001_4_7", + "ZTNA24540", + "ZTNA24541", + "ZTNA24542", + "ZTNA24543", + "ZTNA24545", + "ZTNA24547", + "ZTNA24548", + "ZTNA24549", + "ZTNA24550", + "ZTNA24552", + "ZTNA24553", + "ZTNA24564", + "ZTNA24568", + "ZTNA24569", + "ZTNA24572", + "ZTNA24574", + "ZTNA24575", + "ZTNA24576", + "ZTNA24784", + "ZTNA24839", + "ZTNA24840", + "ZTNA24870" + ], "helpText": "Deploy and manage Intune templates across devices.", "executiveText": "Deploys standardized device management configurations across all corporate devices, ensuring consistent security policies, application settings, and compliance requirements. This template-based approach streamlines device management while maintaining uniform security standards across the organization.", "addedComponent": [ @@ -5502,11 +6351,7 @@ "labelField": "Displayname", "valueField": "GUID", "showRefresh": true, - "templateView": { - "title": "Intune Template", - "property": "RAWJson", - "type": "intune" - } + "templateView": { "title": "Intune Template", "property": "RAWJson", "type": "intune" } } }, { @@ -5528,26 +6373,11 @@ "label": "Who should this template be assigned to?", "type": "radio", "options": [ - { - "label": "Do not assign", - "value": "On" - }, - { - "label": "Assign to all users", - "value": "allLicensedUsers" - }, - { - "label": "Assign to all devices", - "value": "AllDevices" - }, - { - "label": "Assign to all users and devices", - "value": "AllDevicesAndUsers" - }, - { - "label": "Assign to Custom Group", - "value": "customGroup" - } + { "label": "Do not assign", "value": "On" }, + { "label": "Assign to all users", "value": "allLicensedUsers" }, + { "label": "Assign to all devices", "value": "AllDevices" }, + { "label": "Assign to all users and devices", "value": "AllDevicesAndUsers" }, + { "label": "Assign to Custom Group", "value": "customGroup" } ] }, { @@ -5556,11 +6386,7 @@ "name": "customGroup", "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." }, - { - "type": "switch", - "name": "verifyAssignments", - "label": "Verify policy assignments" - }, + { "type": "switch", "name": "verifyAssignments", "label": "Verify policy assignments" }, { "name": "excludeGroup", "label": "Exclude Groups", @@ -5582,15 +6408,21 @@ "required": false, "helpText": "Choose whether to include or exclude devices matching the filter. Only applies if you specified a filter name above. Defaults to Include if not specified.", "options": [ - { - "label": "Include - Assign to devices matching the filter", - "value": "include" - }, - { - "label": "Exclude - Assign to devices NOT matching the filter", - "value": "exclude" - } + { "label": "Include - Assign to devices matching the filter", "value": "include" }, + { "label": "Exclude - Assign to devices NOT matching the filter", "value": "exclude" } ] + }, + { + "type": "number", + "required": false, + "name": "levenshteinDistance", + "label": "Fuzzy Match Distance (0 = exact name match only, higher values allow replacing policies with similar names based on Levenshtein distance)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + }, + "warningThreshold": 5, + "warningMessage": "Warning: values above 5 can match unrelated policies. Use with caution." } ] }, @@ -5599,14 +6431,34 @@ "cat": "Templates", "label": "Reusable Settings Template", "multiple": true, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "impactColour": "info", "addedDate": "2026-01-02", + "tag": [ + "SMB1001 (1.2)", + "SMB1001 (1.3)", + "SMB1001 (1.4)", + "SMB1001 (1.8)", + "SMB1001 (1.9)", + "SMB1001 (1.10)", + "SMB1001 (1.12)" + ], + "appliesToTest": [ + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "ZTNA24540", + "ZTNA24550", + "ZTNA24552", + "ZTNA24574", + "ZTNA24575", + "ZTNA24784" + ], "helpText": "Deploy and maintain Intune reusable settings templates that can be referenced by multiple policies.", "executiveText": "Creates and keeps reusable Intune settings templates consistent so common firewall and configuration blocks can be reused across many policies.", "addedComponent": [ @@ -5623,11 +6475,7 @@ "labelField": "displayName", "valueField": "GUID", "showRefresh": true, - "templateView": { - "title": "Reusable Settings", - "property": "RawJSON", - "type": "intune" - } + "templateView": { "title": "Reusable Settings", "property": "RawJSON", "type": "intune" } } } ], @@ -5638,11 +6486,7 @@ "name": "standards.TransportRuleTemplate", "label": "Transport Rule Template", "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy transport rules to manage email flow.", @@ -5653,7 +6497,7 @@ "name": "transportRuleTemplate", "label": "Select Transport Rule Template", "api": { - "url": "/api/ListTransportRulesTemplates", + "url": "/api/ListTransportRulesTemplates?noJson=true", "labelField": "name", "valueField": "GUID", "queryKey": "ListTransportRulesTemplates" @@ -5665,6 +6509,13 @@ "name": "overwrite", "defaultValue": true } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5672,13 +6523,57 @@ "label": "Conditional Access Template", "cat": "Templates", "multiple": true, - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", + "tag": [ + "CIS M365 7.0.0 (5.2.2.1)", + "CIS M365 7.0.0 (5.2.2.2)", + "CIS M365 7.0.0 (5.2.2.3)", + "CIS M365 7.0.0 (5.2.2.4)", + "CIS M365 7.0.0 (5.2.2.5)", + "CIS M365 7.0.0 (5.2.2.6)", + "CIS M365 7.0.0 (5.2.2.7)", + "CIS M365 7.0.0 (5.2.2.8)", + "CIS M365 7.0.0 (5.2.2.9)", + "CIS M365 7.0.0 (5.2.2.10)", + "CIS M365 7.0.0 (5.2.2.11)", + "CIS M365 7.0.0 (5.2.2.12)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.8)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_2_1", + "CIS_5_2_2_10", + "CIS_5_2_2_11", + "CIS_5_2_2_12", + "CIS_5_2_2_2", + "CIS_5_2_2_3", + "CIS_5_2_2_4", + "CIS_5_2_2_5", + "CIS_5_2_2_6", + "CIS_5_2_2_7", + "CIS_5_2_2_8", + "CIS_5_2_2_9", + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_8", + "SMB1001_2_9", + "ZTNA21783", + "ZTNA21786", + "ZTNA21806", + "ZTNA21808", + "ZTNA21824", + "ZTNA21825", + "ZTNA21828", + "ZTNA21830", + "ZTNA21883", + "ZTNA21892", + "ZTNA21941", + "ZTNA24827" + ], "helpText": "Manage conditional access policies for better security.", "executiveText": "Deploys standardized conditional access policies that automatically enforce security requirements based on user location, device compliance, and risk factors. These templates ensure consistent security controls across the organization while enabling secure access to business resources.", "addedComponent": [ @@ -5686,6 +6581,8 @@ "type": "autoComplete", "name": "TemplateList", "multiple": false, + "required": false, + "creatable": false, "label": "Select Conditional Access Template", "api": { "url": "/api/ListCATemplates", @@ -5693,8 +6590,23 @@ "valueField": "GUID", "queryKey": "ListCATemplates", "showRefresh": true, - "templateView": { - "title": "Conditional Access Policy" + "templateView": { "title": "Conditional Access Policy" } + } + }, + { + "type": "autoComplete", + "multiple": false, + "required": false, + "creatable": false, + "name": "TemplateList-Tags", + "label": "Or select a package of CA Templates", + "api": { + "queryKey": "ListCATemplates-tag-autocomplete", + "url": "/api/ListCATemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "addedField": { + "templates": "templates" } } }, @@ -5703,22 +6615,10 @@ "label": "What state should we deploy this template in?", "type": "radio", "options": [ - { - "value": "donotchange", - "label": "Do not change state" - }, - { - "value": "Enabled", - "label": "Set to enabled" - }, - { - "value": "Disabled", - "label": "Set to disabled" - }, - { - "value": "enabledForReportingButNotEnforced", - "label": "Set to report only" - } + { "value": "donotchange", "label": "Do not change state" }, + { "value": "Enabled", "label": "Set to enabled" }, + { "value": "Disabled", "label": "Set to disabled" }, + { "value": "enabledForReportingButNotEnforced", "label": "Set to report only" } ] }, { @@ -5726,22 +6626,15 @@ "name": "DisableSD", "label": "Disable Security Defaults when deploying policy" }, - { - "type": "switch", - "name": "CreateGroups", - "label": "Create groups if they do not exist" - } - ] + { "type": "switch", "name": "CreateGroups", "label": "Create groups if they do not exist" } + ], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.ExchangeConnectorTemplate", "label": "Exchange Connector Template", "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage Exchange connectors.", @@ -5758,6 +6651,13 @@ "queryKey": "ListExConnectorTemplates" } } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5765,11 +6665,9 @@ "label": "Group Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "tag": [], + "appliesToTest": [], + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage group templates.", @@ -5787,21 +6685,18 @@ "queryKey": "ListGroupTemplates" } } - ] + ], + "requiredCapabilities": ["EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", "EXCHANGE_LITE"] }, { "name": "standards.DlpCompliancePolicyTemplate", "label": "DLP Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", - "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates. Existing policies are overwritten in place.", + "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates.", "executiveText": "Deploys Data Loss Prevention policies from a standardized template library. Ensures consistent DLP coverage across tenants for sensitive data such as financial, identity, and regulated content.", "addedComponent": [ { @@ -5824,11 +6719,7 @@ "label": "Retention Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates.", @@ -5854,11 +6745,7 @@ "label": "Sensitivity Label Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates.", @@ -5884,11 +6771,7 @@ "label": "Sensitive Information Type Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Low Impact", "addedDate": "2026-05-10", "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates.", @@ -5914,11 +6797,7 @@ "label": "Assignment Filter Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2025-10-04", "helpText": "Deploy and manage assignment filter templates.", @@ -5936,6 +6815,40 @@ "queryKey": "ListAssignmentFilterTemplates" } } + ], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, + { + "name": "standards.TenantAllowBlockListTemplate", + "label": "Tenant Allow/Block List Template", + "cat": "Exchange Standards", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-04-02", + "helpText": "Deploy tenant allow/block list entries from a saved template.", + "executiveText": "Deploys standardized tenant allow/block list entries across tenants. These templates ensure consistent email filtering rules are applied, managing which senders, URLs, file hashes, and IP addresses are allowed or blocked across the organization.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "TenantAllowBlockListTemplate", + "required": false, + "multiple": true, + "label": "Select Tenant Allow/Block List Template", + "api": { + "url": "/api/ListTenantAllowBlockListTemplates", + "labelField": "templateName", + "valueField": "GUID", + "queryKey": "ListTenantAllowBlockListTemplates", + "showRefresh": true + } + } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5962,12 +6875,19 @@ "impactColour": "info", "addedDate": "2025-05-28", "powershellEquivalent": "Set-Mailbox -RecipientLimits", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableExchangeOnlinePowerShell", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.1.1)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "tag": ["Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This helps prevent attackers from using PowerShell to run malicious commands, access file systems, registry, and distribute ransomware throughout networks. Users with admin roles are automatically excluded.", "docsDescription": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This security measure follows a least privileged access approach, preventing potential attackers from using PowerShell to execute malicious commands, access sensitive systems, or distribute malware. Users with management roles containing 'Admin' are automatically excluded to ensure administrators retain PowerShell access to perform necessary management tasks.", "executiveText": "Restricts PowerShell access to Exchange Online for regular employees while maintaining access for administrators, significantly reducing security risks from compromised accounts. This prevents attackers from using PowerShell to execute malicious commands or distribute ransomware while preserving necessary administrative capabilities.", @@ -5976,12 +6896,19 @@ "impactColour": "warning", "addedDate": "2025-06-19", "powershellEquivalent": "Set-User -Identity $user -RemotePowerShellEnabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.OWAAttachmentRestrictions", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.1.2)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "tag": ["Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Restricts how users on unmanaged devices can interact with email attachments in Outlook on the web and new Outlook for Windows. Prevents downloading attachments or blocks viewing them entirely.", "docsDescription": "This standard configures the OWA mailbox policy to restrict access to email attachments on unmanaged devices. Users can be prevented from downloading attachments (but can view/edit via Office Online) or blocked from seeing attachments entirely. This helps prevent data exfiltration through email attachments on devices not managed by the organization.", "executiveText": "Restricts access to email attachments on personal or unmanaged devices while allowing full functionality on corporate-managed devices. This security measure prevents data theft through email attachments while maintaining productivity for employees using approved company devices.", @@ -5991,10 +6918,7 @@ "name": "standards.OWAAttachmentRestrictions.ConditionalAccessPolicy", "label": "Attachment Restriction Policy", "options": [ - { - "label": "Read Only (View/Edit via Office Online, no download)", - "value": "ReadOnly" - }, + { "label": "Read Only (View/Edit via Office Online, no download)", "value": "ReadOnly" }, { "label": "Read Only Plus Attachments Blocked (Cannot see attachments)", "value": "ReadOnlyPlusAttachmentsBlocked" @@ -6008,7 +6932,14 @@ "impactColour": "warning", "addedDate": "2025-08-22", "powershellEquivalent": "Set-OwaMailboxPolicy -Identity \"OwaMailboxPolicy-Default\" -ConditionalAccessPolicy ReadOnlyPlusAttachmentsBlocked", - "recommendedBy": ["Microsoft Zero Trust", "CIPP"] + "recommendedBy": ["Microsoft Zero Trust", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.LegacyEmailReportAddins", @@ -6115,6 +7046,12 @@ "placeholder": "e.g. https://example.com/*", "helperText": "Enter URLs to allowlist in the extension. Press enter to add each URL. Wildcards are allowed. This should be used for sites that are being blocked by the extension but are known to be safe." }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.domainSquattingEnabled", + "label": "Enable domain squatting detection", + "defaultValue": true + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.companyName", @@ -6122,13 +7059,6 @@ "placeholder": "YOUR-COMPANY", "required": false }, - { - "type": "textField", - "name": "standards.DeployCheckChromeExtension.companyURL", - "label": "Company URL", - "placeholder": "https://yourcompany.com", - "required": false - }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", @@ -6143,6 +7073,27 @@ "placeholder": "support@yourcompany.com", "required": false }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.supportUrl", + "label": "Support URL", + "placeholder": "https://support.yourcompany.com", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.privacyPolicyUrl", + "label": "Privacy Policy URL", + "placeholder": "https://yourcompany.com/privacy", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.aboutUrl", + "label": "About URL", + "placeholder": "https://yourcompany.com/about", + "required": false + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.primaryColor", @@ -6162,26 +7113,11 @@ "label": "Who should this app be assigned to?", "type": "radio", "options": [ - { - "label": "Do not assign", - "value": "On" - }, - { - "label": "Assign to all users", - "value": "allLicensedUsers" - }, - { - "label": "Assign to all devices", - "value": "AllDevices" - }, - { - "label": "Assign to all users and devices", - "value": "AllDevicesAndUsers" - }, - { - "label": "Assign to Custom Group", - "value": "customGroup" - } + { "label": "Do not assign", "value": "On" }, + { "label": "Assign to all users", "value": "allLicensedUsers" }, + { "label": "Assign to all devices", "value": "AllDevices" }, + { "label": "Assign to all users and devices", "value": "AllDevicesAndUsers" }, + { "label": "Assign to Custom Group", "value": "customGroup" } ] }, { @@ -6196,7 +7132,8 @@ "impactColour": "info", "addedDate": "2025-09-18", "powershellEquivalent": "Add-CIPPW32ScriptApplication", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.SecureScoreRemediation", @@ -6211,11 +7148,7 @@ "required": false, "name": "standards.SecureScoreRemediation.Default", "label": "Controls to set to Default", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6224,11 +7157,7 @@ "required": false, "name": "standards.SecureScoreRemediation.Ignored", "label": "Controls to set to Ignored", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6237,11 +7166,7 @@ "required": false, "name": "standards.SecureScoreRemediation.ThirdParty", "label": "Controls to set to Third-Party", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6250,11 +7175,7 @@ "creatable": true, "name": "standards.SecureScoreRemediation.Reviewed", "label": "Controls to set to Reviewed", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } } ], "label": "Update Secure Score Control Profiles", @@ -6271,22 +7192,12 @@ "docsDescription": "Creates five Exchange Online transport rules grouped by the first letter of user display names (A-E, F-J, K-O, P-T, U-Z). Each rule fires when an external sender's From header matches a display name in that group, prepends a configurable HTML warning banner, and skips emails from accepted organisational domains. Any manually configured sender or domain exemptions on existing rules are preserved when the standard runs. The disclaimer HTML is fully customisable via the standard settings.", "executiveText": "Protects staff from display-name impersonation attacks by injecting a visible warning banner on emails that appear to come from a colleague but originate externally. Rules are maintained automatically across all letter groups and updated whenever the standard runs.", "addedComponent": [ - { - "type": "heading", - "label": "Alert Banner (HTML)", - "required": false - }, { "type": "textField", "name": "standards.ColleagueImpersonationAlert.disclaimerHtml", "label": "Disclaimer HTML – Paste the full HTML for the warning banner", "required": true }, - { - "type": "heading", - "label": "Keyword Exclusions (Exclude certain users by keywords)", - "required": false - }, { "type": "autoComplete", "name": "standards.ColleagueImpersonationAlert.excludedMailboxes", @@ -6295,11 +7206,6 @@ "creatable": true, "required": false }, - { - "type": "heading", - "label": "Exempt Senders (Email Accounts)", - "required": false - }, { "type": "autoComplete", "name": "standards.ColleagueImpersonationAlert.additionalExemptSenders", @@ -6314,6 +7220,890 @@ "impactColour": "warning", "addedDate": "2026-03-22", "powershellEquivalent": "New-TransportRule / Set-TransportRule", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.DefenderCompliancePolicy", + "cat": "Defender Standards", + "tag": ["defender_mde_connector", "defender_intune_compliance"], + "helpText": "Configures the Microsoft Defender for Endpoint connector with Intune, enabling compliance evaluation for mobile and desktop platforms (Android, iOS, macOS, Windows). Controls which platforms connect to MDE and whether devices are blocked when partner data is missing.", + "docsDescription": "Configures the Microsoft Defender for Endpoint mobile threat defense connector with Intune. This enables compliance evaluation across platforms (Android, iOS/iPadOS, macOS, Windows) and controls settings like blocking unsupported OS versions, requiring partner data for compliance, and enabling mobile application management. The connector must be enabled before platform-specific compliance policies can evaluate device risk from MDE.", + "executiveText": "Establishes the critical link between Microsoft Defender for Endpoint and Intune, enabling security risk data from MDE to be used in device compliance policies. This ensures that only devices meeting your organization's security standards can access corporate resources, providing a foundational layer of Zero Trust security across all platforms.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectAndroid", + "label": "Connect Android devices to MDE", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectAndroidCompliance", + "label": "Connect Android 6.0.0+ (App-based MAM)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.androidDeviceBlockedOnMissingPartnerData", + "label": "Block Android if partner data unavailable", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectIos", + "label": "Connect iOS/iPadOS devices to MDE", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectIosCompliance", + "label": "Connect iOS 13.0+ (App-based MAM)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.appSync", + "label": "Enable App Sync for iOS", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.iosDeviceBlockedOnMissingPartnerData", + "label": "Block iOS if partner data unavailable", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata", + "label": "Collect certificate metadata from iOS", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata", + "label": "Collect personal certificate metadata from iOS", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectMac", + "label": "Connect macOS devices to MDE", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.macDeviceBlockedOnMissingPartnerData", + "label": "Block macOS if partner data unavailable", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.ConnectWindows", + "label": "Connect Windows 10.0.15063+ to MDE (Note: enabling this forces 'Block Windows if partner data unavailable' to on)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.windowsMobileApplicationManagementEnabled", + "label": "Connect Windows (MAM)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.windowsDeviceBlockedOnMissingPartnerData", + "label": "Block Windows if partner data unavailable (Note: Microsoft enforces this to on when Connect Windows 10.0.15063+ to MDE is on)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.BlockunsupportedOS", + "label": "Block unsupported OS versions", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.AllowMEMEnforceCompliance", + "label": "Allow MEM enforcement of compliance", + "defaultValue": false + } + ], + "label": "Defender for Endpoint - Intune Compliance Connector", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-02", + "powershellEquivalent": "Graph API - deviceManagement/mobileThreatDefenseConnectors", "recommendedBy": [] + }, + { + "name": "standards.GlobalQuarantineSettings", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures the Global Quarantine Policy settings including sender name, custom subject, disclaimer, from address, and org branding.", + "docsDescription": "Configures the Global Quarantine Policy branding and notification settings for the tenant. This includes the quarantine notification sender display name, custom subject line, disclaimer text, the from address used for notifications, and whether to use org branding. Notification frequency is managed separately by the GlobalQuarantineNotifications standard.", + "executiveText": "Ensures quarantine notification emails are branded and configured consistently, so end users receive clear, professional alerts about quarantined messages and know how to request release.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.SenderName", + "label": "Sender Display Name (e.g. Contoso-Office365Alerts)", + "helperText": "Will be overridden if an active sender address with an existing display name is used.", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.CustomSubject", + "label": "Subject", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.CustomDisclaimer", + "label": "Disclaimer (max 200 characters)", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.FromAddress", + "label": "Specify Sender Address (must be an internal mailbox)", + "required": false + }, + { + "type": "switch", + "name": "standards.GlobalQuarantineSettings.OrganizationBrandingEnabled", + "label": "Use Organization Branding (logo)", + "helperText": "Requires branding to be configured in the Microsoft 365 admin centre." + } + ], + "label": "Configure Global Quarantine Notification Settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-02", + "powershellEquivalent": "Set-QuarantinePolicy (GlobalQuarantinePolicy)", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.SPDisableCustomScripts", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Prevents users from running custom scripts on SharePoint and OneDrive sites. Custom scripts can modify site behaviors and bypass governance controls.", + "docsDescription": "Disables the ability to add and run custom scripts on SharePoint and OneDrive sites at the tenant level. When custom scripts are allowed, governance cannot be enforced, and the capabilities of inserted code cannot be scoped or blocked. Microsoft recommends using the SharePoint Framework instead of custom scripts.", + "executiveText": "Blocks custom scripts from being added to SharePoint and OneDrive sites, enforcing governance controls and preventing unscoped code execution. This aligns with Microsoft's Baseline Security Mode recommendation to permanently remove the ability to add new custom scripts, directing organizations to use the SharePoint Framework instead.", + "addedComponent": [], + "label": "Disable custom scripts on SharePoint sites", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.SPDisableStoreAccess", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Disables end users from installing applications from the Microsoft Store into SharePoint sites.", + "docsDescription": "Removes the ability for end users to install applications directly from the Microsoft Store into SharePoint. This prevents uncontrolled app installations that can increase governance costs and go against organizational policies.", + "executiveText": "Prevents end users from installing applications from the Microsoft Store into SharePoint sites, ensuring that only approved applications are available. This reduces governance overhead and aligns with Microsoft's Baseline Security Mode recommendations.", + "addedComponent": [], + "label": "Disable SharePoint Store access", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-SPOTenant -DisableSharePointStoreAccess $true", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.DisableEWS", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Disables Exchange Web Services (EWS) organization-wide. This reduces the attack surface by blocking legacy API access to mailbox data. Warning: This may break Office web add-ins on builds older than 16.0.19127.", + "docsDescription": "Disables Exchange Web Services (EWS) at the organization level to reduce attack surface. EWS provides cross-platform API access to sensitive Exchange Online data such as emails, meetings, and contacts. If compromised, attackers can access confidential data, send phishing emails, or spoof identities. Disabling EWS also reduces legacy app usage and minimizes exploitable endpoints. Note that this may break first-party features including web add-ins for Word, Excel, PowerPoint, and Outlook on builds older than 16.0.19127.", + "executiveText": "Disables Exchange Web Services (EWS) across the organization to reduce attack surface and prevent legacy API access to sensitive mailbox data. This aligns with Microsoft's Baseline Security Mode recommendation to minimize exploitable endpoints while requiring updates to applications that depend on EWS.", + "addedComponent": [], + "label": "Disable Exchange Web Services", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-OrganizationConfig -EwsEnabled $false", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.OMEBranding", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures the branding applied to Microsoft Purview (OME) encrypted emails, including the logo, background color, and the text recipients see when viewing a protected message. [Read more](https://learn.microsoft.com/en-us/purview/add-your-organization-brand-to-encrypted-messages)", + "docsDescription": "Configures Office Message Encryption (OME) branding settings for the tenant default configuration. Allows organizations to apply a custom logo (via URL), background color, button text, and portal text to encrypted emails viewed by external recipients.", + "executiveText": "Applies organizational branding to encrypted emails so recipients see a professional, on-brand experience when viewing protected messages. Reinforces brand identity while preserving security compliance.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.OMEBranding.BackgroundColor", + "label": "Background Color - Optional", + "placeholder": "#ffffff", + "helpText": "The background color of the encrypted message wrapper. Enter an HTML hex color code (e.g. #ffffff) or a named color value (e.g. white).", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.LogoUrl", + "label": "Logo Image URL - Optional (Less than 40kb 170x70 pixels)", + "placeholder": "https://example.com/logo.png or %CustomVarable%", + "helpText": "URL to your organization's logo displayed in the encrypted email and the reading portal. Supported formats: PNG, JPG, BMP, TIFF. Optimal size: 170x70 px, max 40 KB.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.IntroductionText", + "label": "Text next to the sender's name and email address - Optional", + "placeholder": "has sent you a secure message.", + "helpText": "Text that appears next to the sender's name and email address. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.ReadButtonText", + "label": "Read Button Text - Optional", + "placeholder": "Read Secure Message.", + "helpText": "Text that appears on the 'Read Message' button. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.EmailText", + "label": "Email Text below the button - Optional", + "placeholder": "Encrypted message from Contoso secure messaging system.", + "helpText": "Text that appears below the 'Read Message' button. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.PrivacyStatementUrl", + "label": "Privacy Statement URL - Optional", + "placeholder": "https://contoso.com/privacystatement.html", + "helpText": "URL for the Privacy Statement link in the encrypted email notification. Leave blank to use Microsoft's default privacy statement.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.DisclaimerText", + "label": "Disclaimer Statement - Optional", + "placeholder": "This message is confidential for the use of the addressee only.", + "helpText": "Disclaimer statement shown in the email that contains the encrypted message. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.PortalText", + "label": "Text appears at the top of the encrypted mail viewing portal - Optional", + "placeholder": "Contoso secure email portal.", + "helpText": "Text that appears at the top of the encrypted mail viewing portal. Maximum 128 characters.", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.OMEBranding.OTPEnabled", + "label": "One-Time Pass Code - Required", + "helpText": "Enable or disable authentication with a one-time pass code. When enabled, recipients without a Microsoft account can verify their identity via a code sent to their email.", + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.OMEBranding.SocialIdSignIn", + "label": "Social ID Sign-In - Required", + "helpText": "Enable or disable authentication with Microsoft, Google, or Yahoo identities. When enabled, recipients can sign in with an existing social account to view the encrypted message.", + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + } + ], + "label": "Configure Encrypted Message Branding (OME)", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-25", + "powershellEquivalent": "Set-OMEConfiguration", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.EnforcePrivateGroups", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (1.2.1)"], + "appliesToTest": ["CIS_1_2_1"], + "helpText": "Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword.", + "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 7.0.0 benchmark control 1.2.1.", + "executiveText": "Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Exclude groups by display name keyword", + "name": "standards.EnforcePrivateGroups.ExcludedGroupNames" + } + ], + "label": "Enforce Private M365 Groups", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Update-MgGroup -GroupId -Visibility Private", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.EmptyFilterIPAllowList", + "cat": "Defender Standards", + "tag": ["CIS M365 7.0.0 (2.1.12)"], + "appliesToTest": ["CIS_2_1_12"], + "helpText": "Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks.", + "docsDescription": "IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it.", + "executiveText": "Ensures the Exchange Online connection filter IP allow list is empty, preventing any IP addresses from bypassing spam filtering, spoofing checks, and sender authentication. Keeping this list empty ensures all inbound email undergoes full security scanning, reducing the risk of phishing and malware delivery through trusted-but-compromised sources.", + "addedComponent": [], + "label": "Ensure connection filter IP allow list is empty", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @()", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.TeamsZAP", + "cat": "Defender Standards", + "tag": ["CIS M365 7.0.0 (2.4.4)"], + "appliesToTest": ["CIS_2_4_4"], + "helpText": "Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery.", + "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 7.0.0 benchmark control 2.4.4.", + "executiveText": "Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users.", + "addedComponent": [], + "label": "Ensure Zero-hour auto purge for Microsoft Teams is on", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.CollaborationDomainRestriction", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.1.6.1)"], + "appliesToTest": ["CIS_5_1_6_1"], + "helpText": "Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured.", + "docsDescription": "By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list.", + "executiveText": "Restricts external collaboration invitations to approved domains only, preventing users from sharing data with unapproved external organizations. This reduces the risk of data exfiltration and ensures that collaboration occurs only with trusted business partners.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.CollaborationDomainRestriction.allowedDomains", + "label": "Allowed domains (comma separated)", + "required": false, + "placeholder": "contoso.com, fabrikam.com" + } + ], + "label": "Restrict collaboration invitations to allowed domains only", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default", + "recommendedBy": ["CIS"] + }, + { + "name": "standards.IntuneAppTemplateDeploy", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys selected Intune application templates to the tenant. Supports WinGet/Store apps, Office apps, Chocolatey apps, Win32 script apps, and MSP apps.", + "docsDescription": "Uses CIPP Intune Application Templates to deploy applications across tenants as a standard. Each template can contain multiple applications of different types which will be queued for deployment.", + "executiveText": "Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability and reducing manual deployment overhead. Supports WinGet, Office, Chocolatey, Win32, and MSP application types.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Application Templates", + "name": "standards.IntuneAppTemplateDeploy.templateIds", + "api": { + "url": "/api/ListAppTemplates", + "labelField": "displayName", + "valueField": "GUID", + "queryKey": "StdIntuneAppTemplateList" + } + } + ], + "label": "Deploy Intune Application Template", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-23", + "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", + "recommendedBy": [] + }, + { + "name": "standards.AutopatchGroup", + "cat": "Intune Standards", + "tag": [], + "beta": true, + "deprecated": true, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Deploys a Windows Autopatch group with configurable deployment ring settings for quality updates, feature updates, Edge, and Office.", + "docsDescription": "Creates or updates a Windows Autopatch deployment group with Test and Last deployment rings. Configures quality update deferrals, feature update targeting, Edge and Office update channels per ring. Uses the Autopatch API proxy to manage the group configuration.", + "executiveText": "Configures Windows Autopatch deployment groups to manage update delivery across devices. Autopatch automates Windows quality updates, feature updates, Edge, and Office updates using deployment rings with configurable deferrals and deadlines.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AutopatchGroup.GroupName", + "label": "Group Name", + "required": true, + "defaultValue": "Autopatch default group" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TargetOSVersion", + "label": "Target OS Version", + "required": true, + "options": [ + { "label": "Windows 11, version 24H2", "value": "Windows 11, version 24H2" }, + { "label": "Windows 11, version 25H2", "value": "Windows 11, version 25H2" } + ], + "defaultValue": "Windows 11, version 25H2" + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.EnableDriverUpdate", + "label": "Enable Driver Updates", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.InstallWin10OnWin11Ineligible", + "label": "Install latest Windows 10 on Windows 11 ineligible devices", + "defaultValue": false + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeferral", + "label": "Test Ring - Quality Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeadline", + "label": "Test Ring - Quality Update Deadline (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityGracePeriod", + "label": "Test Ring - Quality Update Grace Period (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeferral", + "label": "Test Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeadline", + "label": "Test Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestEdgeChannel", + "label": "Test Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Beta" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestOfficeChannel", + "label": "Test Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestDnfDeferral", + "label": "Test Ring - Driver & Firmware Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeferral", + "label": "Last Ring - Quality Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeadline", + "label": "Last Ring - Quality Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityGracePeriod", + "label": "Last Ring - Quality Update Grace Period (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeferral", + "label": "Last Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeadline", + "label": "Last Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastEdgeChannel", + "label": "Last Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Stable" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastOfficeChannel", + "label": "Last Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeferral", + "label": "Last Ring - Office Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeadline", + "label": "Last Ring - Office Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastDnfDeferral", + "label": "Last Ring - Driver & Firmware Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + } + ], + "label": "Deploy Windows Autopatch Group", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Autopatch API - POST /api/autoPatch", + "recommendedBy": [] + }, + { + "name": "standards.FIDO2PasskeyProfiles", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures FIDO2 passkey profiles including AAGUID allowlists, attestation enforcement, and passkey types for the tenant.", + "docsDescription": "Manages FIDO2 passkey profiles on the tenant authentication methods policy. Allows defining passkey profiles that control which authenticators (hardware keys, password managers, Microsoft Authenticator) are permitted via AAGUID allowlists, whether attestation is enforced, and which passkey types (device-bound, synced, or both) are allowed. This enables MSPs to centrally deploy phishing-resistant MFA configurations across tenants.", + "executiveText": "Configures passkey (FIDO2) profiles that control which authenticators users can register for phishing-resistant MFA. Supports allowlisting specific hardware keys (e.g., YubiKey models), password managers (e.g., 1Password), and Microsoft Authenticator by AAGUID, with control over attestation enforcement and passkey types.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.PasskeyTypes", + "label": "Allowed Passkey Types", + "options": [ + { "label": "Device-bound only", "value": "deviceBound" }, + { "label": "Synced only", "value": "synced" }, + { "label": "Both device-bound and synced", "value": "deviceBound,synced" } + ], + "required": true + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.AttestationEnforcement", + "label": "Attestation Enforcement", + "options": [ + { "label": "Disabled (required for synced passkeys)", "value": "disabled" }, + { "label": "Registration only", "value": "registrationOnly" } + ], + "required": true + }, + { + "type": "switch", + "name": "standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions", + "label": "Enforce AAGUID Key Restrictions" + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.EnforcementType", + "label": "Key Restriction Type", + "options": [ + { "label": "Allow listed AAGUIDs only", "value": "allow" }, + { "label": "Block listed AAGUIDs", "value": "block" } + ], + "required": false + }, + { + "type": "textField", + "name": "standards.FIDO2PasskeyProfiles.AAGUIDs", + "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)", + "required": false + } + ], + "label": "Configure FIDO2 Passkey Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-25", + "powershellEquivalent": "Graph API PATCH /policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.SmartLockout", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], + "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], + "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "addedComponent": [ + { + "type": "number", + "name": "standards.SmartLockout.LockoutDurationInSeconds", + "label": "Lockout Duration (seconds)", + "default": 60, + "required": true + }, + { + "type": "number", + "name": "standards.SmartLockout.LockoutThreshold", + "label": "Lockout Threshold (failed attempts)", + "default": 10, + "required": true + }, + { + "type": "switch", + "name": "standards.SmartLockout.EnableBannedPasswordCheckOnPremises", + "label": "Enable On-Premises Password Protection" + }, + { + "type": "radio", + "name": "standards.SmartLockout.BannedPasswordCheckOnPremisesMode", + "label": "On-Premises Mode", + "options": [ + { "label": "Audit", "value": "Audit" }, + { "label": "Enforced", "value": "Enforced" } + ] + } + ], + "label": "Configure Entra ID Smart Lockout", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] + }, + { + "name": "standards.SPOVersionControl", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Configures SharePoint Online file versioning to either use automatic version trimming managed by Microsoft, or enforce a fixed major version limit with optional version expiration.", + "docsDescription": "Configures the SharePoint Online tenant-level file versioning policy. When automatic version trimming is enabled, Microsoft intelligently manages version cleanup. When disabled, you can set a fixed maximum number of major versions to retain and optionally expire versions after a specified number of days. This helps manage storage consumption while preserving version history as needed.", + "executiveText": "Controls how SharePoint Online manages file version history at the tenant level. Automatic trimming lets Microsoft optimize storage by cleaning up old versions intelligently. Manual limits give administrators precise control over the maximum number of versions retained and their expiration, ensuring predictable storage usage and compliance with data retention policies.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.SPOVersionControl.EnableAutoTrim", + "label": "Enable Automatic Version Trimming (Microsoft managed)" + }, + { + "type": "number", + "name": "standards.SPOVersionControl.MajorVersionLimit", + "label": "Maximum Major Versions (when auto trim is off)", + "default": 50 + }, + { + "type": "number", + "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", + "label": "Expire Versions After Days (0 = never, when auto trim is off)", + "default": 0 + }, + { + "type": "switch", + "name": "standards.SPOVersionControl.ApplyToExistingSites", + "label": "Apply to all existing sites and document libraries" + } + ], + "label": "Set SharePoint File Version Limits", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-27", + "powershellEquivalent": "Set-SPOTenant -EnableAutoExpirationVersionTrim $true or Set-SPOTenant -EnableAutoExpirationVersionTrim $false -MajorVersionLimit 50 -ExpireVersionsAfterDays 365", + "recommendedBy": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + } } ] From 49395156df423a2cc656607cd44a3d4a082568b3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:17:01 +0800 Subject: [PATCH 030/150] repair GDAP role mapping actions --- .../Push-ExecOnboardTenantQueue.ps1 | 24 ++ .../Public/Test-CIPPGDAPGroupMappings.ps1 | 226 ++++++++++++++++++ .../Public/Test-CIPPGDAPRelationships.ps1 | 44 +++- .../GDAP/Invoke-ExecGDAPAccessAssignment.ps1 | 29 ++- .../Invoke-ExecGDAPRepairRoleMappings.ps1 | 74 ++++++ 5 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index ead11b0ca7c0b..05f2edba58ae4 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -118,6 +118,30 @@ function Push-ExecOnboardTenantQueue { $OnboardingSteps.Step2.Status = 'succeeded' $OnboardingSteps.Step2.Message = 'Your GDAP relationship has the required roles' } + + # Validate (and correct) that the mapped security groups still exist in the partner tenant before + # Step 3 tries to POST the access assignments - a missing group surfaces as a raw Graph + # "access container does not exist" error otherwise. + if ($OnboardingSteps.Step2.Status -ne 'failed' -and ($Item.Roles | Measure-Object).Count -gt 0) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Validating GDAP security group mappings against the partner tenant' }) + $GroupCheck = Test-CIPPGDAPGroupMappings -RoleMappings $Item.Roles -CreateMissing:([bool]$Item.AddMissingGroups) -WriteBack + foreach ($GroupResult in $GroupCheck.Results) { + if ($GroupResult.Status -in @('Stale', 'Created', 'Missing')) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $GroupResult.Message }) + } + } + # Use the corrected mappings for the remainder of the onboarding (group mapping, SAM membership, retries) + $Item.Roles = @($GroupCheck.RoleMappings) + + if (-not $GroupCheck.Valid) { + $MissingGroupNames = ($GroupCheck.MissingGroups.Name | Sort-Object -Unique) -join ', ' + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Missing GDAP security groups in the partner tenant: $MissingGroupNames" }) + $TenantOnboarding.Status = 'failed' + $OnboardingSteps.Step2.Status = 'failed' + $OnboardingSteps.Step2.Message = "The following GDAP security groups are missing in the partner tenant, recreate the GDAP roles and retry: $MissingGroupNames" + } + } + $TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) $TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress) Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 new file mode 100644 index 0000000000000..ec23d6ba8fb13 --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 @@ -0,0 +1,226 @@ +function Test-CIPPGDAPGroupMappings { + <# + .SYNOPSIS + Validate (and optionally repair) the security groups referenced by GDAP role mappings in the partner tenant. + + .DESCRIPTION + GDAP access assignments link a security group in the partner (CSP) tenant to a unified role. If the GroupId + stored in a role mapping no longer points at a real group, Graph rejects the access assignment with an + "access container does not exist" error. This helper fetches the partner tenant security groups once and, for + each mapping: + + 1. GroupId still resolves to a group -> kept as-is (Valid). + 2. GroupId is gone but a group with the expected name ("M365 GDAP " / the stored GroupName) exists + -> the mapping is resolved to that group's id (Stale - the stored id was stale). + 3. Neither exists -> recreated via the standard "M365 GDAP" group when -CreateMissing is set (Created), + otherwise reported as Missing with an actionable message instead of letting the raw Graph error surface. + + Corrections/creations can be persisted back to the GDAPRoles table (-WriteBack), a GDAP role template + (-TemplateId) and/or a GDAP invite entry (-InviteRowKey) so subsequent syncs use the corrected GroupIds. + + Returns the corrected mapping set, a per-mapping result list, the still-missing groups and an overall Valid flag. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + $RoleMappings, + $PartnerGroups, + [switch]$CreateMissing, + [switch]$WriteBack, + $TemplateId, + $InviteRowKey, + $APIName = 'GDAP Group Check', + $Headers + ) + + # Normalise input into a mutable copy so we never mutate the caller's objects in place + $Mappings = @(foreach ($Mapping in $RoleMappings) { + [PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $Mapping.GroupName + GroupId = $Mapping.GroupId + roleDefinitionId = $Mapping.roleDefinitionId + } + }) + + if (($Mappings | Measure-Object).Count -eq 0) { + return [PSCustomObject]@{ + RoleMappings = @() + Results = @() + Valid = $true + MissingGroups = @() + } + } + + # Fetch partner tenant security groups once if the caller did not already hand them to us + if (-not $PartnerGroups) { + $PartnerGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + } + + $Results = [System.Collections.Generic.List[object]]::new() + $MissingGroups = [System.Collections.Generic.List[object]]::new() + $Corrections = [System.Collections.Generic.List[object]]::new() + $CreateRequests = [System.Collections.Generic.List[object]]::new() + $CreateLookup = @{} + + foreach ($Mapping in $Mappings) { + $ExpectedName = if ($Mapping.GroupName) { $Mapping.GroupName } else { "M365 GDAP $($Mapping.RoleName)" } + + # 1. GroupId still valid in the partner tenant + if ($Mapping.GroupId -and $PartnerGroups.id -contains $Mapping.GroupId) { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Valid' + Message = '' + OldGroupId = $null + }) + continue + } + + # 2. Remap to an existing group that matches the expected name + $MatchByName = $PartnerGroups | Where-Object { $_.displayName -eq $ExpectedName } | Select-Object -First 1 + if ($MatchByName) { + $OldGroupId = $Mapping.GroupId + $Mapping.GroupId = $MatchByName.id + $Mapping.GroupName = $MatchByName.displayName + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $MatchByName.displayName + GroupId = $MatchByName.id + Status = 'Stale' + Message = "Group '$ExpectedName' exists but the stored group id '$OldGroupId' is stale; the correct group id is '$($MatchByName.id)'" + OldGroupId = $OldGroupId + }) + $Corrections.Add([PSCustomObject]@{ OldGroupId = $OldGroupId; Mapping = $Mapping }) + continue + } + + # 3. Neither the id nor a matching group exists - recreate or report as missing + if ($CreateMissing) { + $MailNickname = 'M365GDAP{0}' -f (($ExpectedName -replace '^M365 GDAP ', '') -replace '[^a-zA-Z0-9]', '') + $RequestId = "create-$($Mapping.roleDefinitionId)" + $CreateLookup[$RequestId] = $Mapping + $CreateRequests.Add(@{ + id = $RequestId + url = '/groups' + method = 'POST' + headers = @{ 'Content-Type' = 'application/json' } + body = @{ + displayName = $ExpectedName + description = "This group is used to manage M365 partner tenants at the $($Mapping.RoleName) level." + securityEnabled = $true + mailEnabled = $false + mailNickname = $MailNickname + } + }) + } else { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Missing' + Message = "Group '$ExpectedName' is missing in the partner tenant, recreate the GDAP roles before retrying" + OldGroupId = $Mapping.GroupId + }) + $MissingGroups.Add([PSCustomObject]@{ Name = $ExpectedName; Type = 'Role Mapping' }) + } + } + + # Execute any group recreations and fold the new ids back into the mappings + if ($CreateRequests.Count -gt 0 -and $PSCmdlet.ShouldProcess('Partner tenant', "Recreate $($CreateRequests.Count) missing GDAP group(s)")) { + $CreateResults = New-GraphBulkRequest -Requests @($CreateRequests) -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + foreach ($Result in $CreateResults) { + $Mapping = $CreateLookup[$Result.id] + if (-not $Mapping) { continue } + $ExpectedName = if ($Mapping.GroupName) { $Mapping.GroupName } else { "M365 GDAP $($Mapping.RoleName)" } + if ($Result.body.error) { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Missing' + Message = "Failed to recreate group '$ExpectedName': $($Result.body.error.message)" + OldGroupId = $Mapping.GroupId + }) + $MissingGroups.Add([PSCustomObject]@{ Name = $ExpectedName; Type = 'Role Mapping' }) + } else { + $OldGroupId = $Mapping.GroupId + $Mapping.GroupId = $Result.body.id + $Mapping.GroupName = $Result.body.displayName + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $Result.body.displayName + GroupId = $Result.body.id + Status = 'Created' + Message = "Recreated missing group '$($Result.body.displayName)' as '$($Result.body.id)'" + OldGroupId = $OldGroupId + }) + $Corrections.Add([PSCustomObject]@{ OldGroupId = $OldGroupId; Mapping = $Mapping }) + } + } + } + + # Persist corrected/created GroupIds back to the GDAPRoles registry (RowKey is the GroupId) + if ($WriteBack -and $Corrections.Count -gt 0) { + try { + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + foreach ($Correction in $Corrections) { + $Mapping = $Correction.Mapping + if ($Correction.OldGroupId -and $Correction.OldGroupId -ne $Mapping.GroupId) { + $OldEntity = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles' and RowKey eq '$($Correction.OldGroupId)'" + if ($OldEntity) { + Remove-AzDataTableEntity -Force @RolesTable -Entity $OldEntity + } + } + Add-CIPPAzDataTableEntity @RolesTable -Entity @{ + PartitionKey = 'Roles' + RowKey = [string]$Mapping.GroupId + RoleName = [string]$Mapping.RoleName + GroupName = [string]$Mapping.GroupName + GroupId = [string]$Mapping.GroupId + roleDefinitionId = [string]$Mapping.roleDefinitionId + } -Force + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to GDAPRoles: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + + # Optionally push the corrected mappings back to the source template/invite + if ($Corrections.Count -gt 0) { + if ($TemplateId) { + try { + Add-CIPPGDAPRoleTemplate -TemplateId $TemplateId -RoleMappings ($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) -Overwrite + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to template '$TemplateId': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + if ($InviteRowKey) { + try { + $InviteTable = Get-CIPPTable -TableName 'GDAPInvites' + $Invite = Get-CIPPAzDataTableEntity @InviteTable -Filter "RowKey eq '$InviteRowKey'" + if ($Invite) { + $Invite.RoleMappings = [string](@($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) | ConvertTo-Json -Depth 10 -Compress) + Add-CIPPAzDataTableEntity @InviteTable -Entity $Invite -Force + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to invite '$InviteRowKey': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + } + + return [PSCustomObject]@{ + RoleMappings = @($Mappings) + Results = @($Results) + Valid = (($MissingGroups | Measure-Object).Count -eq 0) + MissingGroups = @($MissingGroups) + } +} diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 index 590fef5f8ddf8..d0c801560be6c 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 @@ -8,6 +8,7 @@ function Test-CIPPGDAPRelationships { $GDAPissues = [System.Collections.Generic.List[object]]@() $MissingGroups = [System.Collections.Generic.List[object]]@() + $RoleMappingResults = [System.Collections.Generic.List[object]]@() try { #Get graph request to list all relationships. $Relationships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships?`$filter=status eq 'active'" -tenantid $env:TenantID -NoAuthCheck $true @@ -99,16 +100,51 @@ function Test-CIPPGDAPRelationships { }) | Out-Null } + # Validate that every stored GDAP role mapping still points at a group that exists in the partner tenant. + # A drifted/deleted GroupId is what causes the "access container does not exist" error during onboarding. + # Problems are added to GDAPIssues as errors (so they count toward the Errors total) and tagged with + # Category 'RoleMapping' so the frontend can keep them out of the GDAP Issues table (the detail/repair view + # lives in RoleMappingResults). + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + $StoredRoleMappings = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles'" + if (($StoredRoleMappings | Measure-Object).Count -gt 0) { + # Read-only check: do not write back or recreate groups from the access check card + $MappingCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoleMappings -Headers $Headers + $RoleMappingResults.AddRange(@($MappingCheck.Results)) + foreach ($MappingResult in $MappingCheck.Results) { + if ($MappingResult.Status -eq 'Missing') { + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Category = 'RoleMapping' + Issue = "The GDAP role mapping for '$($MappingResult.GroupName)' references a security group that no longer exists in the partner tenant. Onboarding group mapping will fail until the GDAP roles are recreated." + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/recommended-roles' + }) | Out-Null + } elseif ($MappingResult.Status -eq 'Stale') { + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Category = 'RoleMapping' + Issue = "The GDAP role mapping for '$($MappingResult.GroupName)' points at a stale group id but a matching group still exists. Use 'Repair Role Mappings' under Details to correct the stored group id." + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/recommended-roles' + }) | Out-Null + } + } + } + } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Failed to run GDAP check for $($TenantFilter): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } $GDAPRelationships = [PSCustomObject]@{ - GDAPIssues = @($GDAPissues) - MissingGroups = @($MissingGroups) - Memberships = @($SAMUserMemberships + $NestedGroups) - CIPPGroupCount = $CIPPGroupCount + GDAPIssues = @($GDAPissues) + MissingGroups = @($MissingGroups) + Memberships = @($SAMUserMemberships + $NestedGroups) + CIPPGroupCount = $CIPPGroupCount + RoleMappingResults = @($RoleMappingResults) } $Table = Get-CIPPTable -TableName AccessChecks diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 index b2e3803ea41f8..41155b7e5d81b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 @@ -48,9 +48,29 @@ function Invoke-ExecGDAPAccessAssignment { $Groups = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups?`$top=999&`$select=id,displayName&`$filter=securityEnabled eq true" -asApp $true -NoAuthCheck $true + # Validate/correct the template's group mappings against the partner tenant before creating any + # access assignments - a stale GroupId would otherwise fail with "access container does not exist". + $GroupCheck = Test-CIPPGDAPGroupMappings -RoleMappings $Mappings -PartnerGroups $Groups -WriteBack -TemplateId $RoleTemplateId -Headers $Request.Headers + $Mappings = $GroupCheck.RoleMappings + $Requests = [System.Collections.Generic.List[object]]::new() $Messages = [System.Collections.Generic.List[object]]::new() + $MappingResults = [System.Collections.Generic.List[object]]::new() + foreach ($GroupResult in $GroupCheck.Results) { + if ($GroupResult.Status -eq 'Stale') { + $MappingResults.Add(@{ resultText = $GroupResult.Message; state = 'success' }) + } elseif ($GroupResult.Status -eq 'Missing') { + $MappingResults.Add(@{ resultText = $GroupResult.Message; state = 'error' }) + } + } + + # Drop mappings whose group could not be resolved so we never POST a non-existent access container + $MissingGroupIds = @($GroupCheck.Results | Where-Object { $_.Status -eq 'Missing' } | Select-Object -ExpandProperty GroupId) + if ($MissingGroupIds.Count -gt 0) { + $Mappings = @($Mappings | Where-Object { $_.GroupId -notin $MissingGroupIds }) + } + foreach ($AccessAssignment in $AccessAssignments) { $RoleCount = ($AccessAssignment.accessDetails.unifiedRoles | Measure-Object).Count if ($Mappings.GroupId -notcontains $AccessAssignment.accessContainer.accessContainerId -and $AccessAssignment.status -notin @('deleting', 'deleted', 'error')) { @@ -159,11 +179,18 @@ function Invoke-ExecGDAPAccessAssignment { } } - } else { + } elseif ($MappingResults.Count -eq 0) { $Results = @{ resultText = 'This relationship already has the correct access assignments' state = 'success' } + } else { + $Results = @() + } + + # Surface any group mapping corrections / missing groups alongside the assignment changes + if ($MappingResults.Count -gt 0) { + $Results = @($MappingResults) + @($Results) } $Body = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 new file mode 100644 index 0000000000000..2421e3a14218c --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 @@ -0,0 +1,74 @@ +function Invoke-ExecGDAPRepairRoleMappings { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Relationship.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + Write-LogMessage -headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + $Results = [System.Collections.Generic.List[object]]::new() + + try { + # Fetch the partner tenant security groups once and reuse them for every store we repair + $PartnerGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + + # Repair the GDAPRoles registry (stale group ids are remapped to the existing "M365 GDAP" group) + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + $StoredRoles = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles'" + if (($StoredRoles | Measure-Object).Count -gt 0) { + $RoleCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoles -PartnerGroups $PartnerGroups -WriteBack -APIName $APIName -Headers $Headers + foreach ($Result in $RoleCheck.Results) { + if ($Result.Status -eq 'Stale') { + $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'success' }) + } elseif ($Result.Status -eq 'Missing') { + $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'error' }) + } + } + } + + # Repair every saved role template so onboarding/reset use the corrected group ids + $TemplatesTable = Get-CIPPTable -TableName 'GDAPRoleTemplates' + $Templates = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'RoleTemplate'" + foreach ($Template in $Templates) { + try { + $TemplateMappings = $Template.RoleMappings | ConvertFrom-Json + } catch { + $TemplateMappings = @() + } + if (($TemplateMappings | Measure-Object).Count -eq 0) { continue } + + $TemplateCheck = Test-CIPPGDAPGroupMappings -RoleMappings $TemplateMappings -PartnerGroups $PartnerGroups -TemplateId $Template.RowKey -APIName $APIName -Headers $Headers + foreach ($Result in $TemplateCheck.Results) { + if ($Result.Status -eq 'Stale') { + $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'success' }) + } elseif ($Result.Status -eq 'Missing') { + $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'error' }) + } + } + } + + if ($Results.Count -eq 0) { + $Results.Add(@{ resultText = 'All GDAP role mappings already reference existing security groups'; state = 'success' }) + } + + # Refresh the cached GDAP access check so the card reflects the repair immediately + $null = Test-CIPPGDAPRelationships -Headers $Headers + + Write-LogMessage -headers $Headers -API $APIName -message 'Repaired GDAP role mappings' -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results.Add(@{ resultText = "Failed to repair GDAP role mappings: $($ErrorMessage.NormalizedError)"; state = 'error' }) + Write-LogMessage -headers $Headers -API $APIName -message "Failed to repair GDAP role mappings: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = @($Results) } + }) +} From 2c63d3174d06ef71b3540a08e9db4db976c5c332 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:29:46 +0800 Subject: [PATCH 031/150] Schema backed standards repair action, repaired standards are prefixed with repaired and have all remediation actions disabled --- .../Tools/Repair-CippStandardsTemplate.ps1 | 247 ++++++++++++++++++ .../Invoke-listStandardTemplates.ps1 | 25 +- 2 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 diff --git a/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 new file mode 100644 index 0000000000000..e1f59d93e3d06 --- /dev/null +++ b/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 @@ -0,0 +1,247 @@ +function Repair-CippStandardsTemplate { + <# + .SYNOPSIS + Recovers a standards template whose JSON failed to parse because of case-insensitive + duplicate property names, using the standards catalog to decide which key is real, then + marks the repaired template as safe-by-default. Returns the repaired JSON string, or THROWS + a descriptive error if it cannot be safely recovered. + .DESCRIPTION + PowerShell's ConvertFrom-Json treats property names case-insensitively and throws + ("...keys with different casing" / "...duplicated keys") when a single object contains two + names that differ only by case. The known offender is the legacy 'calDefault' standard, + which was saved with both 'permissionlevel' and 'permissionLevel'. + + This is a targeted recovery routine - call it ONLY from a ConvertFrom-Json catch block. It + reparses with System.Text.Json (which tolerates duplicate property names) and, for every + object that has case-colliding keys, consults the standards catalog (Config\standards.json) + for the owning standard. The colliding key whose exact casing matches a real catalog field + is kept; the unrecognised duplicate is dropped. So calDefault keeps 'permissionLevel' + (a real field) and drops the corrupt 'permissionlevel' - rather than blindly guessing. + + Because the repair makes a best-effort choice about corrupt data, the recovered template is + also neutered so it cannot silently start remediating from an auto-fixed config: + - templateName is prefixed with "(repaired) ". + - Drift templates (type -eq 'drift'): autoRemediate is forced to $false on every standard. + - Regular templates: runManually is forced to $true (the schedule is disabled). + + Non-colliding fields are otherwise untouched, so there is no risk of dropping legitimately + stored data that the catalog does not enumerate. Arrays, numbers, nulls and nesting are + preserved exactly (single-element arrays are NOT collapsed). + + If a collision cannot be resolved from the catalog (unknown standard / neither casing is a + known field), or the JSON is malformed beyond duplicate keys, this function THROWS a + descriptive terminating error rather than guessing. The caller is expected to log it and + omit the whole template from the response. + .PARAMETER Json + The raw JSON string that failed to parse. + .PARAMETER Reference + Optional identifier (e.g. RowKey or template name) included in error/log context. + .EXAMPLE + try { + $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } catch { + try { $RepairedJson = Repair-CippStandardsTemplate -Json $JSON -Reference $RowKey } + catch { Write-LogMessage ... -message "Template $RowKey omitted: $($_.Exception.Message)" -Sev Error; return } + $Data = $RepairedJson | ConvertFrom-Json -Depth 100 + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Json, + [string]$Reference + ) + + if ([string]::IsNullOrWhiteSpace($Json)) { + throw 'Template JSON is empty.' + } + + # Tolerant reparse. System.Text.Json permits duplicate property names; if even this fails the + # record is malformed beyond the known duplicate-key issue and is genuinely unrecoverable. + try { + $doc = [System.Text.Json.JsonDocument]::Parse($Json) + } catch { + throw "Malformed JSON, not recoverable: $($_.Exception.Message)" + } + + $Schema = Get-CippStandardFieldSchema + + try { + # Determine drift vs regular (matches CIPP: $Template.type -eq 'drift') to decide how to + # neuter the repaired template. + $IsDrift = $false + if ($doc.RootElement.ValueKind -eq 'Object') { + foreach ($p in $doc.RootElement.EnumerateObject()) { + if ($p.Name -eq 'type' -and $p.Value.ValueKind -eq 'String' -and $p.Value.GetString() -eq 'drift') { + $IsDrift = $true; break + } + } + } + + $stream = [System.IO.MemoryStream]::new() + $writer = [System.Text.Json.Utf8JsonWriter]::new($stream) + try { + # Write-CippCleanJsonElement throws if it finds a collision it cannot resolve. + Write-CippCleanJsonElement -Element $doc.RootElement -Writer $writer -Schema $Schema -IsDrift $IsDrift -Context 'root' + $writer.Flush() + $clean = [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) + } finally { $writer.Dispose() } + } finally { $doc.Dispose() } + + # Validate the repaired JSON parses cleanly before handing it back; if not, it's unrecoverable. + try { + $null = $clean | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } catch { + throw "Still unreadable after de-duplicating keys: $($_.Exception.Message)" + } + return $clean +} + +function Get-CippStandardFieldSchema { + # Builds (and caches) a map of standard name -> set of valid field names (canonical casing), + # derived from the addedComponent definitions in Config\standards.json. Used only to decide + # which member of a case-colliding key pair is the legitimate one. + if ($script:CippStandardFieldSchema) { return $script:CippStandardFieldSchema } + + $map = @{} + try { + $Path = Join-Path $env:CIPPRootPath 'Config\standards.json' + if (Test-Path $Path) { + $Catalog = Get-Content $Path -Raw | ConvertFrom-Json -Depth 20 + foreach ($Std in $Catalog) { + if (-not $Std.name -or $Std.name -notlike 'standards.*') { continue } + $StandardKey = $Std.name.Substring('standards.'.Length).ToLowerInvariant() + $Fields = [System.Collections.Generic.HashSet[string]]::new() + $Prefix = "$($Std.name)." + foreach ($Component in @($Std.addedComponent)) { + if (-not $Component.name) { continue } + if ($Component.name -like "$Prefix*") { + foreach ($Segment in ($Component.name.Substring($Prefix.Length) -split '\.')) { + if ($Segment) { [void]$Fields.Add($Segment) } + } + } + } + $map[$StandardKey] = $Fields + } + } else { + Write-Host "Get-CippStandardFieldSchema: standards catalog not found at $Path" + } + } catch { + Write-Host "Get-CippStandardFieldSchema: failed to build schema: $($_.Exception.Message)" + } + + $script:CippStandardFieldSchema = $map + return $map +} + +function Write-CippCleanJsonElement { + # Internal helper for Repair-CippStandardsTemplate. Recursively rewrites a JsonElement, + # resolving case-insensitive duplicate property names by keeping the catalog-valid casing + # (throws if it can't), and neutering the repaired template so it won't auto-remediate: + # renames templateName, forces runManually (regular) or autoRemediate=false (drift). + param( + [System.Text.Json.JsonElement]$Element, + [System.Text.Json.Utf8JsonWriter]$Writer, + [hashtable]$Schema, + [bool]$IsDrift = $false, + [System.Collections.Generic.HashSet[string]]$ValidFields = $null, + [string]$StandardName = $null, + [string]$Context = 'root' + ) + switch ($Element.ValueKind) { + 'Object' { + $Writer.WriteStartObject() + $props = @($Element.EnumerateObject()) + + # Group property indices by case-insensitive name to detect collisions. + $byCi = @{} + for ($i = 0; $i -lt $props.Count; $i++) { + $ci = $props[$i].Name.ToLowerInvariant() + if (-not $byCi.ContainsKey($ci)) { $byCi[$ci] = [System.Collections.Generic.List[int]]::new() } + [void]$byCi[$ci].Add($i) + } + + # For each collision keep the catalog-valid casing; throw if it can't be resolved. + $keep = @{} + foreach ($ci in $byCi.Keys) { + $indices = $byCi[$ci] + if ($indices.Count -eq 1) { $keep[$ci] = $indices[0]; continue } + $chosen = $null + if ($ValidFields) { + foreach ($idx in $indices) { + if ($ValidFields.Contains($props[$idx].Name)) { $chosen = $idx; break } + } + } + if ($null -eq $chosen) { + $where = if ($StandardName) { "standard '$StandardName'" } else { 'the template root' } + $variants = ($indices | ForEach-Object { "'$($props[$_].Name)'" }) -join ', ' + throw "Unresolvable duplicate property '$ci' ($variants) in $where - no matching field in the standards catalog to determine the correct value." + } + $keep[$ci] = $chosen + } + + # Safety-neutering overrides to apply to THIS object. + $forceBool = @{} # canonical name -> bool value to force + if ($Context -eq 'root' -and -not $IsDrift) { $forceBool['runManually'] = $true } + elseif ($Context -eq 'standardEntry' -and $IsDrift) { $forceBool['autoRemediate'] = $false } + $forceLower = @{} + foreach ($k in $forceBool.Keys) { $forceLower[$k.ToLowerInvariant()] = $k } + $pending = [System.Collections.Generic.List[string]]@($forceBool.Keys) + + for ($i = 0; $i -lt $props.Count; $i++) { + $ci = $props[$i].Name.ToLowerInvariant() + if ($keep[$ci] -ne $i) { continue } + + $name = $props[$i].Name + + # Force a boolean value (e.g. runManually / autoRemediate) over the stored one. + if ($forceLower.ContainsKey($ci)) { + $Writer.WriteBoolean($name, [bool]$forceBool[$forceLower[$ci]]) + [void]$pending.Remove($forceLower[$ci]) + continue + } + + # Prefix the template name so it's obvious it was auto-repaired. + if ($Context -eq 'root' -and $ci -eq 'templatename' -and $props[$i].Value.ValueKind -eq 'String') { + $orig = $props[$i].Value.GetString() + $newName = if ($orig.StartsWith('(repaired) ')) { $orig } else { "(repaired) $orig" } + $Writer.WriteString($name, $newName) + continue + } + + $Writer.WritePropertyName($name) + + # Track which standard we are inside so deeper collisions can be resolved/reported + # and so per-standard neutering can be applied at the standard entry object. + $childValid = $ValidFields + $childStandard = $StandardName + $childContext = 'inside' + if ($Context -eq 'root' -and $name -eq 'standards') { + $childContext = 'container' + $childValid = $null + } elseif ($Context -eq 'container') { + $childValid = $Schema[$ci] + $childStandard = $name + $childContext = 'standardEntry' + } + Write-CippCleanJsonElement -Element $props[$i].Value -Writer $Writer -Schema $Schema -IsDrift $IsDrift -ValidFields $childValid -StandardName $childStandard -Context $childContext + } + + # Inject any forced property that wasn't present in the stored object. + foreach ($missing in $pending) { + $Writer.WriteBoolean($missing, [bool]$forceBool[$missing]) + } + + $Writer.WriteEndObject() + } + 'Array' { + $Writer.WriteStartArray() + foreach ($item in $Element.EnumerateArray()) { + Write-CippCleanJsonElement -Element $item -Writer $Writer -Schema $Schema -IsDrift $IsDrift -ValidFields $ValidFields -StandardName $StandardName -Context 'inside' + } + $Writer.WriteEndArray() + } + default { $Element.WriteTo($Writer) } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index c7977465b7e04..8f844ddb9de77 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -14,14 +14,29 @@ function Invoke-listStandardTemplates { $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'StandardsTemplateV2'" $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $RowKey = $_.RowKey $JSON = $_.JSON -replace '"Action":', '"action":' try { - $RowKey = $_.RowKey - $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - + $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { - Write-Host "$($RowKey) standard could not be loaded: $($_.Exception.Message)" - return + try { + $RepairedJSON = Repair-CippStandardsTemplate -Json $JSON -Reference $RowKey + } catch { + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was omitted from the response: $($_.Exception.Message)" -Sev 'Error' + return + } + $Data = $RepairedJSON | ConvertFrom-Json -Depth 100 + try { + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$RepairedJSON" + RowKey = "$RowKey" + PartitionKey = 'StandardsTemplateV2' + GUID = "$RowKey" + } -Force + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' contained corrupt data (case-duplicate keys) and was automatically repaired and re-saved." -Sev 'Warning' + } catch { + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired for this response but could not be re-saved: $($_.Exception.Message)" -Sev 'Warning' + } } if ($Data) { $Data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.GUID -Force From 504c21ae37e56ef01c22a190275453ea98374a68 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 17 Jun 2026 16:43:13 -0400 Subject: [PATCH 032/150] fix: patch command for updating redirect uri --- .../Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 index 2c93e87596c1a..69a66fb92bcdd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 @@ -91,7 +91,7 @@ function Invoke-ExecListAppId { redirectUris = $RedirectUris } } | ConvertTo-Json -Depth 10 - Invoke-GraphRequest -Method PATCH -Url "https://graph.microsoft.com/v1.0/applications/$($AppResponse.body.id)" -Body $AppUpdateBody -tenantid $env:TenantID -NoAuthCheck $true + $null = New-GraphPOSTRequest -type PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.body.id)" -Body $AppUpdateBody -tenantid $env:TenantID -NoAuthCheck $true Write-LogMessage -message "Updated redirect URIs for application $($env:ApplicationID) to include $NewRedirectUri" -Sev 'Info' } catch { Write-LogMessage -message "Failed to update redirect URIs for application $($env:ApplicationID)" -LogData (Get-CippException -Exception $_) -sev 'Warning' From 1164290345bb51f42aaae5a87d9d2092e8121d33 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:29:53 +0200 Subject: [PATCH 033/150] remove incorrectly added permissions --- Config/AdditionalPermissions.json | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/Config/AdditionalPermissions.json b/Config/AdditionalPermissions.json index 8279e8bdca955..ed363855ceac7 100644 --- a/Config/AdditionalPermissions.json +++ b/Config/AdditionalPermissions.json @@ -60,26 +60,5 @@ "type": "Scope" } ] - }, - { - "resourceAppId": "00000003-0000-0000-c000-000000000000", - "resourceAccess": [ - { - "id": "CopilotPolicySettings.ReadWrite", - "type": "Scope" - }, - { - "id": "CopilotSettings-LimitedMode.ReadWrite", - "type": "Scope" - }, - { - "id": "CopilotPackages.Read.All", - "type": "Scope" - }, - { - "id": "CopilotPackages.ReadWrite.All", - "type": "Scope" - } - ] } ] \ No newline at end of file From fa8a5894cd77b1cc6d410e2554ee0b295e8d3d84 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 17 Jun 2026 23:10:58 -0400 Subject: [PATCH 034/150] chore: add logging for onboarding --- .../Push-ExecOnboardTenantQueue.ps1 | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index 05f2edba58ae4..8b4dddb20c1ee 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -7,6 +7,7 @@ function Push-ExecOnboardTenantQueue { param($Item) try { $Id = $Item.id + Write-Information "Onboarding: Starting for relationship $Id" $Start = Get-Date $Logs = [System.Collections.Generic.List[object]]::new() $OnboardTable = Get-CIPPTable -TableName 'TenantOnboarding' @@ -61,6 +62,7 @@ function Push-ExecOnboardTenantQueue { $x++ Start-Sleep -Seconds 30 } while ($Relationship.status -ne 'active' -and $x -lt 6) + Write-Information "Onboarding: Step1 poll completed - status=$($Relationship.status) attempts=$x" if ($Relationship.status -eq 'active') { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'GDAP Invite Accepted' }) @@ -145,6 +147,7 @@ function Push-ExecOnboardTenantQueue { $TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) $TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress) Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop + Write-Information "Onboarding: Step2 completed - status=$($OnboardingSteps.Step2.Status) missingRoles=$($MissingRoles -join ',')" } if ($OnboardingSteps.Step2.Status -eq 'succeeded') { @@ -328,43 +331,57 @@ function Push-ExecOnboardTenantQueue { $TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) $TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress) Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop + Write-Information "Onboarding: Step3 completed - status=$($OnboardingSteps.Step3.Status)" } if ($OnboardingSteps.Step3.Status -eq 'succeeded') { # Check if the relationship was recently activated — Microsoft propagation may not have settled yet if ($Relationship.activatedDateTime) { + $MinutesSinceActivation = $null try { $ActivatedTimeUtc = ([DateTimeOffset]$Relationship.activatedDateTime).UtcDateTime $MinutesSinceActivation = ([datetime]::UtcNow - $ActivatedTimeUtc).TotalMinutes - if ($MinutesSinceActivation -lt 15) { - $RetryAtUtc = [Cronos.CronExpression]::Parse('* * * * *').GetNextOccurrence([DateTime]::UtcNow.AddMinutes(15), [TimeZoneInfo]::Utc) - $RetryEpoch = ([DateTimeOffset]$RetryAtUtc).ToUnixTimeSeconds() - $RetryDelayMinutes = ($RetryAtUtc - [DateTime]::UtcNow).TotalMinutes - $MinutesSinceActivationDisplay = ('{0:N1}' -f $MinutesSinceActivation) - $RetryDelayMinutesDisplay = ('{0:N1}' -f $RetryDelayMinutes) - $RetryLogMessage = "GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Rescheduling onboarding in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." - $Logs.Add([PSCustomObject]@{ - Date = (Get-Date).ToUniversalTime() - Log = $RetryLogMessage - }) - $RetryParams = [PSCustomObject]@{ - Item = [PSCustomObject]@{ - id = $Item.id - Roles = $Item.Roles - AutoMapRoles = $Item.AutoMapRoles - IgnoreMissingRoles = $Item.IgnoreMissingRoles - StandardsExcludeAllTenants = $Item.StandardsExcludeAllTenants - } - } - $RetryTask = [PSCustomObject]@{ - Name = "GDAP Onboarding retry: $($Relationship.customer.displayName)" - Command = [PSCustomObject]@{ value = 'Push-ExecOnboardTenantQueue' } - Parameters = $RetryParams - TenantFilter = $env:TenantID - Recurrence = '' - ScheduledTime = $RetryEpoch + } catch { + Write-Warning "Failed to parse activatedDateTime for relationship ${Id}: $($_.Exception.Message)" + } + Write-Information "Onboarding: activatedDateTime=$($Relationship.activatedDateTime) minutesSinceActivation=$MinutesSinceActivation" + if ($null -ne $MinutesSinceActivation -and $MinutesSinceActivation -lt 15) { + $RetryAtUtc = [Cronos.CronExpression]::Parse('* * * * *').GetNextOccurrence([DateTime]::UtcNow.AddMinutes(15), [TimeZoneInfo]::Utc) + $RetryEpoch = ([DateTimeOffset]$RetryAtUtc).ToUnixTimeSeconds() + $RetryDelayMinutes = ($RetryAtUtc - [DateTime]::UtcNow).TotalMinutes + $MinutesSinceActivationDisplay = ('{0:N1}' -f $MinutesSinceActivation) + $RetryDelayMinutesDisplay = ('{0:N1}' -f $RetryDelayMinutes) + $RetryParams = [PSCustomObject]@{ + Item = [PSCustomObject]@{ + id = $Item.id + Roles = $Item.Roles + AutoMapRoles = $Item.AutoMapRoles + IgnoreMissingRoles = $Item.IgnoreMissingRoles + AddMissingGroups = $Item.AddMissingGroups + StandardsExcludeAllTenants = $Item.StandardsExcludeAllTenants } - $null = Add-CIPPScheduledTask -Task $RetryTask -DesiredStartTime ([string]$RetryEpoch) + } + $RetryTask = [PSCustomObject]@{ + Name = "GDAP Onboarding retry: $($Relationship.customer.displayName)" + Command = [PSCustomObject]@{ value = 'Push-ExecOnboardTenantQueue' } + Parameters = $RetryParams + TenantFilter = $env:TenantID + Recurrence = '' + ScheduledTime = $RetryEpoch + } + try { + $ScheduleResult = Add-CIPPScheduledTask -Task $RetryTask -DesiredStartTime ([string]$RetryEpoch) + } catch { + $ScheduleResult = "Error - $($_.Exception.Message)" + } + Write-Information "Onboarding: Add-CIPPScheduledTask result=$ScheduleResult" + if ($ScheduleResult -match '^Error') { + $FailMessage = "Failed to schedule onboarding retry for $($Relationship.customer.displayName): $ScheduleResult" + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $FailMessage }) + Write-LogMessage -API 'Onboarding' -message $FailMessage -Sev 'Error' + } else { + $RetryLogMessage = "GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Rescheduling onboarding in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $RetryLogMessage }) $RetryMessage = "Rescheduled: GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Retrying in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." $OnboardingSteps.Step4.Status = 'pending' $OnboardingSteps.Step4.Message = $RetryMessage @@ -375,8 +392,6 @@ function Push-ExecOnboardTenantQueue { Write-LogMessage -API 'Onboarding' -message $RetryMessage -Sev 'Info' return } - } catch { - Write-Warning "Failed to check activatedDateTime for relationship ${Id}: $($_.Exception.Message)" } } @@ -445,6 +460,7 @@ function Push-ExecOnboardTenantQueue { } } while ($Refreshing -and (Get-Date) -lt $Start.AddMinutes(8)) + Write-Information "Onboarding: CPV refresh loop completed - success=$CPVSuccess lastError=$LastCPVError" if ($CPVSuccess) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'CPV permissions refreshed' }) $OnboardingSteps.Step4.Status = 'succeeded' @@ -558,6 +574,7 @@ function Push-ExecOnboardTenantQueue { $ApiException = $_ } + Write-Information "Onboarding: Step5 API test completed - userCount=$UserCount apiError=$ApiError" if ($UserCount -gt 0) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'API test successful' }) $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Onboarding complete' }) From 942ff39a7a8067b7b70a3a25529862f9a5638fb1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 17 Jun 2026 23:29:44 -0400 Subject: [PATCH 035/150] chore: consolidate graph log message --- Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 689285e9019e8..c69c34636766c 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -38,7 +38,6 @@ function New-GraphPOSTRequest { if (!$headers['User-Agent']) { $headers['User-Agent'] = Get-CippUserAgent - Write-Information "User-Agent: $($headers['User-Agent'])" } if (!$contentType) { @@ -50,7 +49,7 @@ function New-GraphPOSTRequest { $RawErrorBody = $null do { try { - Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($RetryCount + 1) of $maxRetries" + Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | user-agent: $($headers['User-Agent']) | attempt: $($RetryCount + 1) of $maxRetries" $ReturnedData = (Invoke-CIPPRestMethod -Uri $($uri) -Method $TYPE -Body $body -Headers $headers -ContentType $contentType -SkipHttpErrorCheck:$IgnoreErrors -ResponseHeadersVariable responseHeaders) $RequestSuccessful = $true } catch { From 920e3a1c367ee69d740911b0a0525f2822c87bb7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:34:29 +0800 Subject: [PATCH 036/150] user auth and sync logic --- .../Authentication/Initialize-CIPPAuth.ps1 | 60 +++++++ .../Timer Functions/Start-UserSyncTimer.ps1 | 158 +++++++++++------- .../CIPP/Settings/Invoke-ExecCIPPUsers.ps1 | 95 ++++++++--- 3 files changed, 228 insertions(+), 85 deletions(-) diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 index ec5c613b46929..127af216dcc51 100644 --- a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -159,6 +159,66 @@ function Initialize-CIPPAuth { Write-Information "[Auth-Init] EasyAuth policy reconcile failed (non-fatal): $_" } } + + # 3d. Reconcile API clients — ensure the EasyAuth config matches what the + # "Save to Azure" action (Set-CippApiAuth) would produce for the currently + # enabled API clients. That means BOTH lists must be checked, not just apps: + # allowedApplications = SSO app + every enabled client + # allowedAudiences = api:// for each of the above, plus the MCP host + # URIs and bare client IDs for MCP-enabled clients + # Config drifts when a client is enabled but "Save to Azure" was never run (or a + # prior save partially applied — e.g. apps set but audiences missing), which + # silently breaks API authentication for that client. + if ($AuthState.HasSAMCredentials -and -not $env:CIPP_SSO_MIGRATION_APPID -and $env:WEBSITE_AUTH_V2_CONFIG_JSON) { + try { + $ApiClientsTable = Get-CippTable -tablename 'ApiClients' + $EnabledClients = @(Get-CIPPAzDataTableEntity @ApiClientsTable -Filter 'Enabled eq true' | Where-Object { ![string]::IsNullOrEmpty($_.RowKey) }) + + if ($EnabledClients.Count -gt 0) { + $EnabledClientIds = @($EnabledClients.RowKey) + # MCPAllowed can round-trip as a bool or string; compare on string form (matches SaveToAzure) + $McpClientIds = @($EnabledClients | Where-Object { "$($_.MCPAllowed)" -eq 'True' } | ForEach-Object { $_.RowKey }) + + $ApiAuthConfig = $env:WEBSITE_AUTH_V2_CONFIG_JSON | ConvertFrom-Json -ErrorAction Stop + $AADConfig = $ApiAuthConfig.identityProviders.azureActiveDirectory + + # Desired state — keep in sync with Set-CippApiAuth's CIPPNG branch. + $DesiredApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($AADConfig.registration.clientId) { [void]$DesiredApps.Add($AADConfig.registration.clientId) } + foreach ($Id in $EnabledClientIds) { if (-not [string]::IsNullOrEmpty($Id)) { [void]$DesiredApps.Add($Id) } } + + $DesiredAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Id in $DesiredApps) { [void]$DesiredAudiences.Add("api://$Id") } + if ($McpClientIds.Count -gt 0 -and $env:WEBSITE_HOSTNAME) { + [void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)") + [void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)/api/ExecMcp") + foreach ($McpId in $McpClientIds) { if (-not [string]::IsNullOrEmpty($McpId)) { [void]$DesiredAudiences.Add($McpId) } } + } + + # Current state from the platform-injected config + $CurrentApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($App in @($AADConfig.validation.defaultAuthorizationPolicy.allowedApplications)) { if ($App) { [void]$CurrentApps.Add($App) } } + $CurrentAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Aud in @($AADConfig.validation.allowedAudiences)) { if ($Aud) { [void]$CurrentAudiences.Add($Aud) } } + + # Drift when anything the endpoint would set is missing from the live config + $AppsOk = $DesiredApps.IsSubsetOf($CurrentApps) + $AudiencesOk = $DesiredAudiences.IsSubsetOf($CurrentAudiences) + + if (-not $AppsOk -or -not $AudiencesOk) { + $MissingApps = @($DesiredApps | Where-Object { -not $CurrentApps.Contains($_) }) + $MissingAudiences = @($DesiredAudiences | Where-Object { -not $CurrentAudiences.Contains($_) }) + Write-Information "[Auth-Init] API client drift detected — missing apps: [$($MissingApps -join ', ')]; missing audiences: [$($MissingAudiences -join ', ')] — reconciling EasyAuth" + Set-CippApiAuth -TenantId $env:TenantID -ClientIds $EnabledClientIds -McpClientIds $McpClientIds + Write-Information '[Auth-Init] EasyAuth allowedApplications + allowedAudiences reconciled with enabled API clients' + } else { + Write-Information "[Auth-Init] EasyAuth already matches $($EnabledClients.Count) enabled API client(s) — no update needed" + } + } + } catch { + Write-Information "[Auth-Init] API client reconcile failed (non-fatal): $_" + } + } } elseif ($AuthState.HasSAMCredentials) { # EasyAuth NOT configured but we DO have SAM credentials — try to auto-configure Write-Information '[Auth-Init] EasyAuth not configured but SAM credentials available — attempting auto-configuration' diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index f4578bccecdfd..435d3a3d4a017 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -57,7 +57,7 @@ function Start-UserSyncTimer { $Upn = $Upn.Trim().ToLower() if (-not $UserRoleMap.ContainsKey($Upn)) { - $UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) } foreach ($Role in $RolesForGroup) { [void]$UserRoleMap[$Upn].Add($Role) @@ -79,54 +79,56 @@ function Start-UserSyncTimer { $UsersTable = Get-CippTable -tablename 'allowedUsers' $ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') }) - # Build lookup of existing users + # Group existing rows by lowercased UPN so case-variant duplicate rows + # are reconciled into one canonical row. $ExistingLookup = @{} foreach ($Existing in $ExistingUsers) { - $ExistingLookup[$Existing.RowKey.ToLower()] = $Existing + $Key = $Existing.RowKey.ToLower() + if (-not $ExistingLookup.ContainsKey($Key)) { + $ExistingLookup[$Key] = [System.Collections.Generic.List[object]]::new() + } + $ExistingLookup[$Key].Add($Existing) } $Now = (Get-Date).ToUniversalTime().ToString('o') $UpsertCount = 0 $RemoveCount = 0 $EntitiesToUpsert = [System.Collections.Generic.List[object]]::new() + $EntitiesToRemove = [System.Collections.Generic.List[object]]::new() - # Upsert users from Graph + # Upsert users that are members of a mapped role group foreach ($Upn in $UserRoleMap.Keys) { $AutoRoles = @($UserRoleMap[$Upn] | Sort-Object) - $ManualRoles = @() - $Source = 'Auto' - + # Merge manual roles from every case-variant of this user (case-sensitive dedupe) + $ManualRoles = [System.Collections.Generic.List[string]]::new() if ($ExistingLookup.ContainsKey($Upn)) { - $Existing = $ExistingLookup[$Upn] - - # Preserve manual roles if they exist - if ($Existing.ManualRoles) { - try { - $ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $ManualRoles = @() + foreach ($Existing in $ExistingLookup[$Upn]) { + if ($Existing.ManualRoles) { + try { + foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) } + } + } catch {} } - } - - # If user was previously manual-only and now also auto, mark as Both - if ($ManualRoles.Count -gt 0) { - $Source = 'Both' + # Any row that isn't the canonical lowercase key is a duplicate to remove + if ($Existing.RowKey -cne $Upn) { $EntitiesToRemove.Add($Existing) } } } + $Source = if ($ManualRoles.Count -gt 0) { 'Both' } else { 'Auto' } - # Compute effective roles = union of auto + manual - $EffectiveRoles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Role in $AutoRoles) { [void]$EffectiveRoles.Add($Role) } - foreach ($Role in $ManualRoles) { [void]$EffectiveRoles.Add($Role) } + # Compute effective roles = auto ∪ manual (case-sensitive dedupe) + $EffectiveRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Role in $AutoRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } } + foreach ($Role in $ManualRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } } $EffectiveRolesArray = @($EffectiveRoles | Sort-Object) $Entity = @{ PartitionKey = 'User' RowKey = $Upn Roles = [string]($EffectiveRolesArray | ConvertTo-Json -Compress -AsArray) - AutoRoles = [string]($AutoRoles | ConvertTo-Json -Compress -AsArray) - ManualRoles = [string](($ManualRoles.Count -gt 0 ? $ManualRoles : @()) | ConvertTo-Json -Compress -AsArray) + AutoRoles = [string](@($AutoRoles) | ConvertTo-Json -Compress -AsArray) + ManualRoles = [string]((($ManualRoles.Count -gt 0) ? @($ManualRoles) : @()) | ConvertTo-Json -Compress -AsArray) Source = $Source LastSync = $Now } @@ -135,57 +137,87 @@ function Start-UserSyncTimer { $UpsertCount++ } - # Handle users that were auto-provisioned but are no longer in any role group - foreach ($Existing in $ExistingUsers) { - $ExistingUpn = $Existing.RowKey.ToLower() - if ($UserRoleMap.ContainsKey($ExistingUpn)) { continue } # Still in a group, already handled - - if ($Existing.Source -eq 'Auto') { - # Purely auto-provisioned user no longer in any group — remove - Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing - $RemoveCount++ - } elseif ($Existing.Source -eq 'Both') { - # Was both auto + manual — clear auto roles, keep manual only - $ManualRoles = @() + # Reconcile existing users that are NOT in any mapped role group + foreach ($Key in $ExistingLookup.Keys) { + if ($UserRoleMap.ContainsKey($Key)) { continue } # Still in a group, already handled + + $Variants = $ExistingLookup[$Key] + $NeedsNormalize = ($Variants.Count -gt 1) -or ($Variants[0].RowKey -cne $Key) + + # Merge manual roles across all case-variants (case-sensitive dedupe) + $ManualRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Existing in $Variants) { if ($Existing.ManualRoles) { try { - $ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $ManualRoles = @() - } + foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) } + } + } catch {} } + } - if ($ManualRoles.Count -gt 0) { - $Entity = @{ - PartitionKey = 'User' - RowKey = $Existing.RowKey - Roles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray) - AutoRoles = '[]' - ManualRoles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray) - Source = 'Manual' - LastSync = $Now + if (-not $NeedsNormalize) { + # Single clean lowercase row — apply the original cleanup rules + $Existing = $Variants[0] + if ($Existing.Source -eq 'Auto') { + # Purely auto-provisioned user no longer in any group — remove + $EntitiesToRemove.Add($Existing) + } elseif ($Existing.Source -eq 'Both') { + if ($ManualRoles.Count -gt 0) { + # Was both auto + manual — clear auto roles, keep manual only + $ManualArray = @($ManualRoles | Sort-Object) + $EntitiesToUpsert.Add(@{ + PartitionKey = 'User' + RowKey = $Key + Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + AutoRoles = '[]' + ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + Source = 'Manual' + LastSync = $Now + }) + } else { + $EntitiesToRemove.Add($Existing) } - $EntitiesToUpsert.Add($Entity) - } else { - # No manual roles either — remove - Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing - $RemoveCount++ } + # Source = 'Manual' (or unset) — leave untouched, these are purely manual entries + continue } - # Source = 'Manual' (or unset) — leave untouched, these are purely manual entries - } - # Batch upsert - if ($EntitiesToUpsert.Count -gt 0) { - foreach ($Entity in $EntitiesToUpsert) { - Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force + # Duplicates or non-lowercase casing present — collapse to one canonical lowercase row + if ($ManualRoles.Count -gt 0) { + $ManualArray = @($ManualRoles | Sort-Object) + $EntitiesToUpsert.Add(@{ + PartitionKey = 'User' + RowKey = $Key + Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + AutoRoles = '[]' + ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + Source = 'Manual' + LastSync = $Now + }) + # Remove every case-variant except the canonical one (overwritten by the upsert) + foreach ($Existing in $Variants) { + if ($Existing.RowKey -cne $Key) { $EntitiesToRemove.Add($Existing) } + } + } else { + # No manual roles anywhere — purely auto-provisioned; remove all variants + foreach ($Existing in $Variants) { $EntitiesToRemove.Add($Existing) } } } + # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows) + foreach ($Entity in $EntitiesToUpsert) { + Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force + } + foreach ($Entity in $EntitiesToRemove) { + Remove-AzDataTableEntity -Force @UsersTable -Entity $Entity + $RemoveCount++ + } + # Invalidate CRAFT's in-memory user cache so changes apply try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount auto-only users removed." -sev Info + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info } catch { $ErrorData = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 index dfc78cd1a5421..2e1ca1931df5c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 @@ -13,6 +13,18 @@ function Invoke-ExecCIPPUsers { $Action = $Request.Query.Action ?? $Request.Body.Action $Table = Get-CippTable -tablename 'allowedUsers' + # Returns $true if a row carries a manually-assigned 'superadmin' role. + # Superadmin granted via Entra group sync (AutoRoles) does NOT count — group + # membership can change, so it must never be the sole source of superadmin. + # Match is case-sensitive: the built-in role is exactly 'superadmin'; a custom + # role like 'SuperAdmin' is a different role and must not trip this protection. + $HasManualSuperAdmin = { + param($Entity) + if (-not $Entity.ManualRoles) { return $false } + try { return (@($Entity.ManualRoles | ConvertFrom-Json -ErrorAction Stop) -ccontains 'superadmin') } + catch { return $false } + } + switch ($Action) { 'AddUpdate' { try { @@ -20,7 +32,8 @@ function Invoke-ExecCIPPUsers { if ([string]::IsNullOrWhiteSpace($UPN)) { throw 'UPN (email) is required' } - $UPN = $UPN.Trim() + # Squash casing so the RowKey is canonical and case-variant duplicates can't form + $UPN = $UPN.Trim().ToLower() $Roles = @($Request.Body.Roles) if ($Roles.Count -eq 0) { @@ -45,26 +58,41 @@ function Invoke-ExecCIPPUsers { } } - # Check if user already exists to preserve auto-synced roles - $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$UPN'" - $AutoRoles = @() - $Source = 'Manual' - - if ($ExistingEntity -and $ExistingEntity.AutoRoles) { - try { - $AutoRoles = @($ExistingEntity.AutoRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $AutoRoles = @() + # Find every existing row for this user (case-insensitive) so auto-synced + # roles are preserved and any case-variant duplicates collapse into one + # canonical lowercase row. + $AllUsers = @(Get-CIPPAzDataTableEntity @Table | Where-Object { -not $_.RowKey.StartsWith('_') }) + $MatchingEntities = @($AllUsers | Where-Object { $_.RowKey -and $_.RowKey.ToLower() -eq $UPN }) + + # Invariant: at least one user must always keep a manually-assigned superadmin. + # Block an update that would strip the last manual superadmin. + if (@($Roles) -cnotcontains 'superadmin') { + $TargetHadManualSuperAdmin = @($MatchingEntities | Where-Object { & $HasManualSuperAdmin $_ }).Count -gt 0 + if ($TargetHadManualSuperAdmin) { + $OtherManualSuperAdmins = @($AllUsers | Where-Object { $_.RowKey.ToLower() -ne $UPN -and (& $HasManualSuperAdmin $_) }) + if ($OtherManualSuperAdmins.Count -eq 0) { + throw 'Cannot remove the superadmin role from the last user that has it manually assigned. Grant superadmin manually to another user first (superadmin from Entra group sync does not count).' + } } - if ($AutoRoles.Count -gt 0) { - $Source = 'Both' + } + + # Preserve + merge auto roles across all case-variants (case-sensitive dedupe) + $AutoRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Existing in $MatchingEntities) { + if ($Existing.AutoRoles) { + try { + foreach ($R in @($Existing.AutoRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $AutoRoles.Contains($R)) { $AutoRoles.Add($R) } + } + } catch {} } } + $Source = if ($AutoRoles.Count -gt 0) { 'Both' } else { 'Manual' } - # Compute effective roles = union of manual + auto - $EffectiveRoles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($R in $Roles) { [void]$EffectiveRoles.Add($R) } - foreach ($R in $AutoRoles) { [void]$EffectiveRoles.Add($R) } + # Compute effective roles = manual ∪ auto (case-sensitive dedupe) + $EffectiveRoles = [System.Collections.Generic.List[string]]::new() + foreach ($R in $Roles) { if (-not $EffectiveRoles.Contains($R)) { $EffectiveRoles.Add($R) } } + foreach ($R in $AutoRoles) { if (-not $EffectiveRoles.Contains($R)) { $EffectiveRoles.Add($R) } } $EffectiveRolesArray = @($EffectiveRoles | Sort-Object) $Entity = @{ @@ -72,11 +100,18 @@ function Invoke-ExecCIPPUsers { RowKey = $UPN Roles = [string]($EffectiveRolesArray | ConvertTo-Json -Compress -AsArray) ManualRoles = [string](@($Roles) | ConvertTo-Json -Compress -AsArray) - AutoRoles = [string]($AutoRoles | ConvertTo-Json -Compress -AsArray) + AutoRoles = [string](@($AutoRoles) | ConvertTo-Json -Compress -AsArray) Source = $Source } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + # Remove any case-variant duplicate rows now merged into the canonical row + foreach ($Existing in $MatchingEntities) { + if ($Existing.RowKey -cne $UPN) { + Remove-AzDataTableEntity -Force @Table -Entity $Existing + } + } + # Trigger a user sync to reconcile auto + manual roles try { Start-UserSyncTimer } catch {} @@ -100,7 +135,7 @@ function Invoke-ExecCIPPUsers { if ([string]::IsNullOrWhiteSpace($UPN)) { throw 'UPN (email) is required' } - $UPN = $UPN.Trim() + $UPN = $UPN.Trim().ToLower() # Self-lockout protection: prevent removing yourself $CurrentUser = $Request.Headers.'x-ms-client-principal-name' @@ -108,12 +143,28 @@ function Invoke-ExecCIPPUsers { throw 'Cannot remove your own user account. This would lock you out.' } - $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$UPN'" - if (-not $ExistingEntity) { + # Fetch all users once so we can locate the target (case-insensitively) + # and enforce the "at least one manual superadmin" invariant. + $AllUsers = @(Get-CIPPAzDataTableEntity @Table | Where-Object { -not $_.RowKey.StartsWith('_') }) + $MatchingEntities = @($AllUsers | Where-Object { $_.RowKey -and $_.RowKey.ToLower() -eq $UPN }) + if ($MatchingEntities.Count -eq 0) { throw "User $UPN not found in the allowed users table" } - Remove-AzDataTableEntity -Force @Table -Entity $ExistingEntity + # Invariant: don't remove the last user holding a manually-assigned superadmin. + # (Superadmin granted via Entra group sync does not count — it can disappear + # when group membership changes.) + $TargetHasManualSuperAdmin = @($MatchingEntities | Where-Object { & $HasManualSuperAdmin $_ }).Count -gt 0 + if ($TargetHasManualSuperAdmin) { + $OtherManualSuperAdmins = @($AllUsers | Where-Object { $_.RowKey.ToLower() -ne $UPN -and (& $HasManualSuperAdmin $_) }) + if ($OtherManualSuperAdmins.Count -eq 0) { + throw 'Cannot remove the last user with a manually assigned superadmin role. Grant superadmin manually to another user first (superadmin from Entra group sync does not count).' + } + } + + foreach ($Existing in $MatchingEntities) { + Remove-AzDataTableEntity -Force @Table -Entity $Existing + } try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} $Result = "Successfully removed user $UPN" From 5e635412ce47af1969ed8e181bed387e04c7fe99 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:02:14 +0800 Subject: [PATCH 037/150] Update standards.json --- Config/standards.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/standards.json b/Config/standards.json index a698010ce1413..fda66153955e4 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -4930,7 +4930,7 @@ "name": "standards.SPFileRequests", "cat": "SharePoint Standards", "tag": [], - "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.", + "helpText": "*Requires 'Sharing Level for OneDrive and SharePoint' to be set to Anyone* Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.", "docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.", "executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.", "addedComponent": [ From 151ef464993a15b5223dadd52d82420d356631bb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:06:27 +0800 Subject: [PATCH 038/150] SPO version cleanup job check --- Config/standards.json | 10 ++- .../Get-CIPPSiteVersionCleanupStatus.ps1 | 83 +++++++++++++++++++ .../Public/Start-CIPPSiteVersionCleanup.ps1 | 14 +++- .../Invoke-ListSPOVersionCleanup.ps1 | 31 +++++++ .../Invoke-CIPPStandardSPOVersionControl.ps1 | 11 ++- 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 diff --git a/Config/standards.json b/Config/standards.json index fda66153955e4..3d27a41de8322 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -4930,7 +4930,7 @@ "name": "standards.SPFileRequests", "cat": "SharePoint Standards", "tag": [], - "helpText": "*Requires 'Sharing Level for OneDrive and SharePoint' to be set to Anyone* Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.", + "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.", "docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.", "executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.", "addedComponent": [ @@ -8085,8 +8085,12 @@ { "type": "number", "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", - "label": "Expire Versions After Days (0 = never, when auto trim is off)", - "default": 0 + "label": "Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)", + "default": 0, + "validators": { + "min": { "value": 0, "message": "Use 0 for never, or 30 or more days" }, + "max": { "value": 36500, "message": "Maximum value is 36500" } + } }, { "type": "switch", diff --git a/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 b/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 new file mode 100644 index 0000000000000..f8bf40cae0015 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 @@ -0,0 +1,83 @@ +function Get-CIPPSiteVersionCleanupStatus { + <# + .SYNOPSIS + Get the progress of a file version batch delete (trim) job for a SharePoint site + + .DESCRIPTION + Queries the progress of the file version batch delete job for a SharePoint site via the + CSOM GetFileVersionBatchDeleteJobProgress method on the Tenant object, using the same + ProcessQuery channel as Start-CIPPSiteVersionCleanup. Reports the status of a cleanup + previously started with Start-CIPPSiteVersionCleanup. + + Unlike NewFileVersionBatchDeleteJob / RemoveFileVersionBatchDeleteJob (which return an + SpoOperation object that the client serialises with a ), + GetFileVersionBatchDeleteJobProgress returns a plain String whose content is a JSON blob. + It is therefore invoked as a bare inside with no wrapper - asking + for SelectAllProperties on a String fails server-side with "Cannot find stub for type + System.String". The ProcessQuery response is an array whose only String element is the JSON + progress payload, which this function parses and returns. (Confirmed against a captured + Get-SPOSiteFileVersionBatchDeleteJobProgress request.) + + .PARAMETER TenantFilter + Tenant to query + + .PARAMETER SiteUrl + Full URL of the SharePoint site to query + + .EXAMPLE + Get-CIPPSiteVersionCleanupStatus -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/MySite' + + .FUNCTIONALITY + Internal + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$SiteUrl + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $EscapedSiteUrl = [System.Security.SecurityElement]::Escape($SiteUrl) + + # CSOM pattern: Tenant Constructor -> GetFileVersionBatchDeleteJobProgress(siteUrl). + # The method returns a String (JSON), so it is called directly in with no . + $XML = @" +$EscapedSiteUrl +"@ + + $AdditionalHeaders = @{ + 'Accept' = 'application/json;odata=verbose' + } + + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + # ProcessQuery returns a JSON array; if it came back as raw text, parse it first. + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + # The first array element carries ErrorInfo for the whole request. + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint returned an error querying version cleanup status for $SiteUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + # GetFileVersionBatchDeleteJobProgress returns its payload as the only String element. + $ProgressJson = $Response | Where-Object { $_ -is [string] } | Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($ProgressJson)) { + return [PSCustomObject]@{ + SiteUrl = $SiteUrl + Status = 'NoJob' + Message = 'No file version batch delete job found for this site.' + } + } + + $Progress = $ProgressJson | ConvertFrom-Json + Add-Member -InputObject $Progress -MemberType NoteProperty -Name 'SiteUrl' -Value $SiteUrl -Force + return $Progress +} diff --git a/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 b/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 index ef9e032aa9f69..0f48ad09c656b 100644 --- a/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 @@ -75,6 +75,18 @@ function Start-CIPPSiteVersionCleanup { } if ($PSCmdlet.ShouldProcess($SiteUrl, 'Start file version batch delete job')) { - return New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + # CSOM reports validation failures as HTTP 200 with a populated ErrorInfo on the first + # array element. Surface it instead of returning a silent "success" the caller can't see. + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint rejected the version cleanup job for $SiteUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + return $Response } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 new file mode 100644 index 0000000000000..fc8713f8abeb5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 @@ -0,0 +1,31 @@ +function Invoke-ListSPOVersionCleanup { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + try { + $Result = Get-CIPPSiteVersionCleanupStatus -TenantFilter $TenantFilter -SiteUrl $SiteUrl + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Retrieved version cleanup status for $SiteUrl" -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to retrieve version cleanup status for $SiteUrl : $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Result = "Failed to retrieve version cleanup status: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 index 54cffe7801647..b1f147d96ce6b 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 @@ -18,7 +18,7 @@ function Invoke-CIPPStandardSPOVersionControl { ADDEDCOMPONENT {"type":"switch","name":"standards.SPOVersionControl.EnableAutoTrim","label":"Enable Automatic Version Trimming (Microsoft managed)"} {"type":"number","name":"standards.SPOVersionControl.MajorVersionLimit","label":"Maximum Major Versions (when auto trim is off)","default":50} - {"type":"number","name":"standards.SPOVersionControl.ExpireVersionsAfterDays","label":"Expire Versions After Days (0 = never, when auto trim is off)","default":0} + {"type":"number","name":"standards.SPOVersionControl.ExpireVersionsAfterDays","label":"Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)","default":0,"validators":{"min":{"value":0,"message":"Use 0 for never, or 30 or more days"},"max":{"value":36500,"message":"Maximum value is 36500"}}} {"type":"switch","name":"standards.SPOVersionControl.ApplyToExistingSites","label":"Apply to all existing sites and document libraries"} IMPACT Medium Impact @@ -53,6 +53,15 @@ function Invoke-CIPPStandardSPOVersionControl { $DesiredMajorVersionLimit = [int]($Settings.MajorVersionLimit ?? 50) $DesiredExpireVersionsAfterDays = [int]($Settings.ExpireVersionsAfterDays ?? 0) + # SharePoint only accepts 0 (never expire) or 30-36500 days for version expiration. Reject + # anything in the 1-29 gap (or above the max) up front so we never send a value the tenant + # will refuse. This is the same 30-day floor the version cleanup (trim) job enforces. + if (-not $DesiredAutoTrim -and $DesiredExpireVersionsAfterDays -ne 0 -and + ($DesiredExpireVersionsAfterDays -lt 30 -or $DesiredExpireVersionsAfterDays -gt 36500)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "SPOVersionControl: ExpireVersionsAfterDays must be 0 (never) or between 30 and 36500 days. Received '$DesiredExpireVersionsAfterDays'. Skipping standard." -sev Error + return + } + try { $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object -Property _ObjectIdentity_, TenantFilter, EnableAutoExpirationVersionTrim, MajorVersionLimit, ExpireVersionsAfterDays } catch { From 1a8b71647321428ad9021fdbe600c26034e50aa6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:25:16 +0800 Subject: [PATCH 039/150] fixes for missing state on templates --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 4 ++-- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 478bd5e79bf95..f2b236fdf9b54 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -112,7 +112,7 @@ function New-CIPPCAPolicy { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') } if ($State -and $State -ne 'donotchange') { - $JSONobj.state = $State + $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $State -Force } } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -543,7 +543,7 @@ function New-CIPPCAPolicy { return $false } else { if ($State -eq 'donotchange') { - $JSONobj.state = $CheckExisting.state + $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $CheckExisting.state -Force $RawJSON = ConvertTo-Json -InputObject $JSONobj -Depth 10 -Compress } # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 047ddd19fc4ea..f78a23ead3e70 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -109,7 +109,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { # This ensures drift detection compares against the desired state, not the original template state if ($Settings.state -and $Settings.state -ne 'donotchange') { Write-Information "Overriding template state from '$($Policy.state)' to '$($Settings.state)' for drift comparison" - $Policy.state = $Settings.state + $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label From 5392acf40cee4c1f055ffb0e60ee50425d606a94 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:47:02 +0800 Subject: [PATCH 040/150] Sensitivity label fixes --- .../ConvertTo-CIPPSensitivityLabelParams.ps1 | 110 ++++++++++++++++++ .../Public/Get-CIPPSensitivityLabelField.ps1 | 61 ++++++++++ .../Public/Set-CIPPSensitivityLabel.ps1 | 48 ++++---- .../Invoke-AddSensitivityLabelTemplate.ps1 | 45 +++---- 4 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 new file mode 100644 index 0000000000000..7c3894d872e4a --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 @@ -0,0 +1,110 @@ +function ConvertTo-CIPPSensitivityLabelParams { + <# + .SYNOPSIS + Normalize a sensitivity label template/object into the flat parameter shape that New-Label/Set-Label expect. + .DESCRIPTION + Get-Label (the read shape) does not expose flat Encryption*/Apply* properties. Instead it encodes + encryption, content marking and watermarking inside the 'LabelActions' array, e.g. + + { "Type":"encrypt", "SubType":null, "Settings":[ {"Key":"protectiontype","Value":"userdefined"}, ... ] } + { "Type":"applycontentmarking", "SubType":"footer", "Settings":[ {"Key":"text","Value":"..."}, ... ] } + + New-Label/Set-Label (the write shape) instead take flat 'Apply*'/'Encryption*' parameters. This + function bridges the two: when a label object carries 'LabelActions' it expands those actions into + the flat parameters and drops the read-only 'LabelActions'/'Settings'/'LocaleSettings'/'Conditions' + arrays (which are not valid input in their read form). A flat object (manual JSON authored against + the deploy schema) has no 'LabelActions' and passes through unchanged. + + Deploy-time validation/allowlisting still happens in Set-CIPPSensitivityLabel via + Get-CIPPSensitivityLabelField; this function only reshapes. + .PARAMETER Label + The label template/object to normalize (a Get-Label object, a stored template, or flat manual JSON). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Label + ) + + # A captured Get-Label object always has a LabelActions property (even if empty); flat manual JSON does not. + $HasActions = [bool]$Label.PSObject.Properties['LabelActions'] + # Read-shape arrays that are not valid New-/Set-Label input - dropped when reshaping a captured label. + $ReadShapeArrays = @('LabelActions', 'Settings', 'LocaleSettings', 'Conditions') + + $Flat = [ordered]@{} + foreach ($Prop in $Label.PSObject.Properties) { + if ($HasActions -and $Prop.Name -in $ReadShapeArrays) { continue } + $Flat[$Prop.Name] = $Prop.Value + } + + if (-not $HasActions) { + return [pscustomobject]$Flat + } + + foreach ($Raw in @($Label.LabelActions)) { + if ($null -eq $Raw) { continue } + $Action = if ($Raw -is [string]) { $Raw | ConvertFrom-Json } else { $Raw } + + $Set = @{} + foreach ($KV in $Action.Settings) { $Set[$KV.Key] = $KV.Value } + $Enabled = ($Set['disabled'] -ne 'true') + + switch ($Action.Type) { + 'encrypt' { + $Flat['EncryptionEnabled'] = $Enabled + if (-not $Enabled) { break } + + $ProtectionType = "$($Set['protectiontype'])".ToLower() + if ($ProtectionType -eq 'template') { + $Flat['EncryptionProtectionType'] = 'Template' + if ($Set['templateid']) { $Flat['EncryptionTemplateId'] = $Set['templateid'] } + if ($Set.ContainsKey('contentexpiredondateindaysornever')) { $Flat['EncryptionContentExpiredOnDateInDaysOrNever'] = $Set['contentexpiredondateindaysornever'] } + if ($Set.ContainsKey('offlineaccessdays')) { $Flat['EncryptionOfflineAccessDays'] = [int]$Set['offlineaccessdays'] } + } else { + $Flat['EncryptionProtectionType'] = 'UserDefined' + if ($Set.ContainsKey('donotforward')) { $Flat['EncryptionDoNotForward'] = ($Set['donotforward'] -eq 'true') } + if ($Set.ContainsKey('encryptonly')) { $Flat['EncryptionEncryptOnly'] = ($Set['encryptonly'] -eq 'true') } + if ($Set.ContainsKey('promptuser')) { $Flat['EncryptionPromptUser'] = ($Set['promptuser'] -eq 'true') } + } + } + 'applycontentmarking' { + $Prefix = switch ("$($Action.SubType)".ToLower()) { + 'header' { 'ApplyContentMarkingHeader' } + 'footer' { 'ApplyContentMarkingFooter' } + 'watermark' { 'ApplyWaterMarking' } + default { $null } + } + if (-not $Prefix) { break } + + $Flat["${Prefix}Enabled"] = $Enabled + if ($Set['text']) { $Flat["${Prefix}Text"] = $Set['text'] } + if ($Set['fontcolor']) { $Flat["${Prefix}FontColor"] = $Set['fontcolor'] } + if ($Set['fontname']) { $Flat["${Prefix}FontName"] = $Set['fontname'] } + if ($Set.ContainsKey('fontsize') -and "$($Set['fontsize'])".Trim()) { $Flat["${Prefix}FontSize"] = [int]$Set['fontsize'] } + if ($Prefix -eq 'ApplyWaterMarking') { + if ($Set['layout']) { $Flat['ApplyWaterMarkingLayout'] = $Set['layout'] } + } else { + if ($Set['alignment']) { $Flat["${Prefix}Alignment"] = $Set['alignment'] } + if ($Action.SubType -eq 'footer' -and $Set.ContainsKey('margin') -and "$($Set['margin'])".Trim()) { $Flat["${Prefix}Margin"] = [int]$Set['margin'] } + } + } + 'applywatermarking' { + $Flat['ApplyWaterMarkingEnabled'] = $Enabled + if ($Set['text']) { $Flat['ApplyWaterMarkingText'] = $Set['text'] } + if ($Set['fontcolor']) { $Flat['ApplyWaterMarkingFontColor'] = $Set['fontcolor'] } + if ($Set['fontname']) { $Flat['ApplyWaterMarkingFontName'] = $Set['fontname'] } + if ($Set.ContainsKey('fontsize') -and "$($Set['fontsize'])".Trim()) { $Flat['ApplyWaterMarkingFontSize'] = [int]$Set['fontsize'] } + if ($Set['layout']) { $Flat['ApplyWaterMarkingLayout'] = $Set['layout'] } + } + 'protectgroup' { + $Flat['SiteAndGroupProtectionEnabled'] = $Enabled + if ($Set['privacy']) { $Flat['SiteAndGroupProtectionPrivacy'] = $Set['privacy'] } + if ($Set.ContainsKey('allowaccesstoguestusers')) { $Flat['SiteAndGroupProtectionAllowAccessToGuestUsers'] = ($Set['allowaccesstoguestusers'] -eq 'true') } + if ($Set.ContainsKey('allowemailfromguestusers')) { $Flat['SiteAndGroupProtectionAllowEmailFromGuestUsers'] = ($Set['allowemailfromguestusers'] -eq 'true') } + } + } + } + + return [pscustomobject]$Flat +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 b/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 new file mode 100644 index 0000000000000..61f280d3429fe --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPSensitivityLabelField { + <# + .SYNOPSIS + Returns the valid New-Label / Set-Label parameter names CIPP supports for sensitivity label deployment. + .DESCRIPTION + Single source of truth for the sensitivity label field allowlist, shared by Set-CIPPSensitivityLabel + (deploy) and Invoke-AddSensitivityLabelTemplate (capture keep-list) so the two cannot drift. + + Names match the Microsoft Purview New-Label/Set-Label cmdlet parameters exactly. Note the content + marking and watermark parameters are all 'Apply'-prefixed (ApplyContentMarkingHeaderText, + ApplyWaterMarkingText, ...) - the bare 'ContentMarking*' names do not exist and cause an + AmbiguousParameterSetException. + + 'Priority' is included here but is only valid on Set-Label, not New-Label - Set-CIPPSensitivityLabel + applies it via a dedicated Set-Label call. 'Disabled' is intentionally absent because it is not a + valid parameter on either cmdlet. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + return @( + # Core + 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', 'ContentType', 'Priority', + 'Conditions', 'LocaleSettings', 'Settings', 'AdvancedSettings', + + # Encryption + 'EncryptionEnabled', 'EncryptionProtectionType', + 'EncryptionTemplateId', 'EncryptionLinkedTemplateId', 'EncryptionAipTemplateScopes', + 'EncryptionRightsDefinitions', 'EncryptionContentExpiredOnDateInDaysOrNever', + 'EncryptionDoNotForward', 'EncryptionEncryptOnly', 'EncryptionPromptUser', + 'EncryptionOfflineAccessDays', + + # Content marking - header + 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingHeaderText', + 'ApplyContentMarkingHeaderFontSize', 'ApplyContentMarkingHeaderFontColor', + 'ApplyContentMarkingHeaderFontName', 'ApplyContentMarkingHeaderAlignment', + 'ApplyContentMarkingHeaderMargin', + + # Content marking - footer + 'ApplyContentMarkingFooterEnabled', 'ApplyContentMarkingFooterText', + 'ApplyContentMarkingFooterFontSize', 'ApplyContentMarkingFooterFontColor', + 'ApplyContentMarkingFooterFontName', 'ApplyContentMarkingFooterAlignment', + 'ApplyContentMarkingFooterMargin', + + # Watermark + 'ApplyWaterMarkingEnabled', 'ApplyWaterMarkingText', + 'ApplyWaterMarkingFontSize', 'ApplyWaterMarkingFontColor', + 'ApplyWaterMarkingFontName', 'ApplyWaterMarkingLayout', + + # Site & group protection + 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', + 'SiteAndGroupProtectionLevel', + 'SiteAndGroupProtectionAllowAccessToGuestUsers', + 'SiteAndGroupProtectionAllowEmailFromGuestUsers', + 'SiteAndGroupProtectionAllowFullAccess', + 'SiteAndGroupProtectionAllowLimitedAccess', + 'SiteAndGroupProtectionBlockAccess' + ) +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 index fdad1639753d4..1fb407a28228b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 @@ -16,29 +16,8 @@ function Set-CIPPSensitivityLabel { $Headers ) - $LabelAllowedFields = @( - 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', - 'Disabled', 'ContentType', 'Priority', - 'EncryptionEnabled', 'EncryptionProtectionType', 'EncryptionRightsDefinitions', - 'EncryptionContentExpiredOnDateInDaysOrNever', 'EncryptionDoNotForward', - 'EncryptionEncryptOnly', 'EncryptionOfflineAccessDays', - 'EncryptionPromptUser', 'EncryptionAESKeySize', - 'ContentMarkingHeaderEnabled', 'ContentMarkingHeaderText', - 'ContentMarkingHeaderFontSize', 'ContentMarkingHeaderFontColor', 'ContentMarkingHeaderAlignment', - 'ContentMarkingFooterEnabled', 'ContentMarkingFooterText', - 'ContentMarkingFooterFontSize', 'ContentMarkingFooterFontColor', 'ContentMarkingFooterAlignment', - 'ContentMarkingFooterMargin', - 'ContentMarkingWatermarkEnabled', 'ContentMarkingWatermarkText', - 'ContentMarkingWatermarkFontSize', 'ContentMarkingWatermarkFontColor', 'ContentMarkingWatermarkLayout', - 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingFooterEnabled', 'ApplyWaterMarkingEnabled', - 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', - 'SiteAndGroupProtectionAllowAccessToGuestUsers', - 'SiteAndGroupProtectionAllowEmailFromGuestUsers', - 'SiteAndGroupProtectionAllowFullAccess', - 'SiteAndGroupProtectionAllowLimitedAccess', - 'SiteAndGroupProtectionBlockAccess', - 'Conditions', 'AdvancedSettings', 'Settings', 'LocaleSettings' - ) + # Valid New-Label/Set-Label parameter names (single source of truth, shared with the template endpoint). + $LabelAllowedFields = Get-CIPPSensitivityLabelField $PolicyAllowedFields = @( 'Name', 'Comment', 'Labels', 'AdvancedSettings', 'Settings', 'ExchangeLocation', 'ExchangeLocationException', @@ -50,10 +29,20 @@ function Set-CIPPSensitivityLabel { $PolicyLocationFields = $PolicyAllowedFields | Where-Object { $_ -like '*Location*' } $LabelPolicyAddPrefixed = @('Labels') + $PolicyLocationFields - $LabelParams = Format-CIPPCompliancePolicyParams -Source $Template -AllowedFields $LabelAllowedFields + # Normalize the read shape (Get-Label LabelActions) into the flat New-/Set-Label parameter shape. + # Flat manual JSON authored against the deploy schema passes through unchanged. + $NormalizedLabel = ConvertTo-CIPPSensitivityLabelParams -Label $Template + $LabelParams = Format-CIPPCompliancePolicyParams -Source $NormalizedLabel -AllowedFields $LabelAllowedFields $PolicySource = $Template.PolicyParams $LabelName = $LabelParams.Name + # Priority is valid on Set-Label but not New-Label, so it is applied via a dedicated Set-Label call below. + $LabelPriority = $null + if ($LabelParams.ContainsKey('Priority')) { + $LabelPriority = $LabelParams['Priority'] + $LabelParams.Remove('Priority') + } + try { $ExistingLabels = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Label' -Compliance | Select-Object Name, DisplayName } catch { @() } $ExistingLabelPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-LabelPolicy' -Compliance | Select-Object Name } catch { @() } @@ -69,6 +58,17 @@ function Set-CIPPSensitivityLabel { $LabelAction = "Created sensitivity label '$LabelName' in $TenantFilter." } + # Priority is Set-Label only (not a New-Label parameter) and is tenant-relative: a value valid in the + # source tenant can be out of range in the target. Apply it best-effort so an invalid priority never + # masks an otherwise successful label deployment. + if ($null -ne $LabelPriority) { + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams @{ Identity = $LabelName; Priority = $LabelPriority } -Compliance -useSystemMailbox $true + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deployed sensitivity label '$LabelName' but could not set priority $LabelPriority in $($TenantFilter): $($_.Exception.Message)" -sev Warning + } + } + if ($PolicySource) { $PolicyHash = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $PolicyAllowedFields if (-not $PolicyHash.ContainsKey('Labels') -or -not $PolicyHash['Labels']) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 index 868e317a3a2e8..9db57279cd686 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 @@ -11,30 +11,10 @@ Function Invoke-AddSensitivityLabelTemplate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - $AllowedFields = @( - 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', - 'Disabled', 'ContentType', 'Priority', - 'EncryptionEnabled', 'EncryptionProtectionType', 'EncryptionRightsDefinitions', - 'EncryptionContentExpiredOnDateInDaysOrNever', 'EncryptionDoNotForward', - 'EncryptionEncryptOnly', 'EncryptionOfflineAccessDays', - 'EncryptionPromptUser', 'EncryptionAESKeySize', - 'ContentMarkingHeaderEnabled', 'ContentMarkingHeaderText', - 'ContentMarkingHeaderFontSize', 'ContentMarkingHeaderFontColor', 'ContentMarkingHeaderAlignment', - 'ContentMarkingFooterEnabled', 'ContentMarkingFooterText', - 'ContentMarkingFooterFontSize', 'ContentMarkingFooterFontColor', 'ContentMarkingFooterAlignment', - 'ContentMarkingFooterMargin', - 'ContentMarkingWatermarkEnabled', 'ContentMarkingWatermarkText', - 'ContentMarkingWatermarkFontSize', 'ContentMarkingWatermarkFontColor', 'ContentMarkingWatermarkLayout', - 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingFooterEnabled', 'ApplyWaterMarkingEnabled', - 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', - 'SiteAndGroupProtectionAllowAccessToGuestUsers', - 'SiteAndGroupProtectionAllowEmailFromGuestUsers', - 'SiteAndGroupProtectionAllowFullAccess', - 'SiteAndGroupProtectionAllowLimitedAccess', - 'SiteAndGroupProtectionBlockAccess', - 'Conditions', 'AdvancedSettings', 'Settings', 'LocaleSettings', - 'PolicyParams' - ) + # Captured labels (Get-Label output) and manual JSON are stored as-is; the read shape (LabelActions etc.) + # is normalized to deploy parameters at deploy time by Set-CIPPSensitivityLabel. We only keep the fields + # that matter for re-deployment and drop read-only Get-Label metadata (Guid, ImmutableId, WhenCreated...). + $KeepFields = @(Get-CIPPSensitivityLabelField) + @('LabelActions', 'PolicyParams', 'Disabled', 'comments') try { $GUID = (New-Guid).GUID @@ -45,15 +25,16 @@ Function Invoke-AddSensitivityLabelTemplate { [pscustomobject]$Request.Body } - $Clean = Format-CIPPCompliancePolicyParams -Source $Source -AllowedFields $AllowedFields - + $DisplayName = $Source.DisplayName ?? $Source.Name ?? $Source.name $Ordered = [ordered]@{ - name = $Clean['Name'] ?? $Source.Name ?? $Source.name - comments = $Source.Comment ?? $Source.comments + DisplayName = $DisplayName + Name = $Source.Name ?? $Source.name + Comment = $Source.Comment ?? $Source.comments } - foreach ($k in $Clean.Keys) { - if ($Ordered.Contains($k)) { continue } - $Ordered[$k] = $Clean[$k] + foreach ($Prop in $Source.PSObject.Properties) { + if ($Prop.Name -notin $KeepFields) { continue } + if ($Ordered.Contains($Prop.Name)) { continue } + $Ordered[$Prop.Name] = $Prop.Value } $JSON = ([pscustomobject]$Ordered | ConvertTo-Json -Depth 10) @@ -64,7 +45,7 @@ Function Invoke-AddSensitivityLabelTemplate { RowKey = "$GUID" PartitionKey = 'SensitivityLabelTemplate' } - $Result = "Successfully created Sensitivity Label Template: $($Ordered['name']) with GUID $GUID" + $Result = "Successfully created Sensitivity Label Template: $DisplayName with GUID $GUID" Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' $StatusCode = [HttpStatusCode]::OK } catch { From ee92936ef12e161b3e095209e576869cd8301a49 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:47:29 +0800 Subject: [PATCH 041/150] more drift logging and type casting --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 183325edfe0dd..f877a2a4af800 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -409,12 +409,22 @@ function Get-CIPPDrift { # Persist newly detected deviations to the tenantDrift table so the summary page can count them $NewDriftEntities = [System.Collections.Generic.List[object]]::new() foreach ($Deviation in $AllDeviations) { - if (-not $ExistingDriftStates.ContainsKey($Deviation.standardName)) { - $RowKey = $Deviation.standardName -replace '\.', '_' + # Diagnostic: standardName must be a scalar string. Azure Tables cannot store a PSObject, + # so a non-string here is what causes "Unsupported property types found: StandardName". + # Log the offending value (with tenant) so the producing standard can be identified. + if ($Deviation.standardName -isnot [string]) { + Write-Warning "Drift deviation for tenant '$TenantFilter' has a non-string standardName (type $($Deviation.standardName.GetType().FullName)): $(ConvertTo-Json -InputObject $Deviation.standardName -Depth 5 -Compress -ErrorAction SilentlyContinue)" + } + # Coerce to string so the table write never fails on this property. RowKey already + # coerces via -replace; this makes the stored StandardName column match. + $StandardNameValue = [string]$Deviation.standardName + if ([string]::IsNullOrWhiteSpace($StandardNameValue)) { continue } + if (-not $ExistingDriftStates.ContainsKey($StandardNameValue)) { + $RowKey = $StandardNameValue -replace '\.', '_' $NewDriftEntities.Add(@{ PartitionKey = $TenantFilter RowKey = $RowKey - StandardName = $Deviation.standardName + StandardName = $StandardNameValue Status = 'New' LastModified = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }) From 66ff8f5082a73c39b8bb9590eaca147ae1ddce17 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:47:55 +0800 Subject: [PATCH 042/150] ca template deployment logging for packages --- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index f78a23ead3e70..bb7c1d6d1d2ca 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -105,6 +105,12 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 + if ($null -eq $Policy) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + return + } + # Override the template's state with the Drift Standard's state if specified # This ensures drift detection compares against the desired state, not the original template state if ($Settings.state -and $Settings.state -ne 'donotchange') { From 4160b516030366107c7b7fc013ddb36ad2961e6c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 18 Jun 2026 11:45:33 -0400 Subject: [PATCH 043/150] chore: add feature flag for copilot pages --- Config/FeatureFlags.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index ba991b67302a8..59ffac3d4b914 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -44,6 +44,31 @@ ], "Hidden": true }, + { + "Id": "CopilotAI", + "Name": "Copilot & AI", + "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", + "Enabled": false, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ListCopilotSettings", + "ExecCopilotSettings", + "ListCopilotUsage", + "ListAgent365Packages", + "ListAgent365PackageDetail", + "ListShadowAI" + ], + "Pages": [ + "/copilot/settings", + "/copilot/shadow-ai", + "/copilot/agent365/packages", + "/copilot/reports/copilot-adoption", + "/copilot/reports/copilot-usage", + "/copilot/reports/copilot-trend" + ], + "Hidden": true + }, { "Id": "MCPServer", "Name": "MCP Server", @@ -57,4 +82,4 @@ "Pages": [], "Hidden": false } -] +] \ No newline at end of file From e4bd4bedf424def87276ba7fa50f10d51f55a8bb Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 18 Jun 2026 11:46:18 -0400 Subject: [PATCH 044/150] chore: bump version to 10.5.3 --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 7648204274b0d..964ab6d101ea2 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.2", + "defaultVersion": "10.5.3", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index a39233be07ad0..1e9c35fac8568 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.2 +10.5.3 From edc7acbfac64339b868831f5d9063e5ab43bd825 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:20:38 +0800 Subject: [PATCH 045/150] Trail claim better info on error --- .../Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index 84c0817b84127..08781f6a7140d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -81,6 +81,11 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Trial Autoclaim' + productId = 'autoclaim' + policyValue = 'Failed to retrieve current state, check the logs for details' + }) Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy: $($_.Exception.Message)" -sev Error } } @@ -181,6 +186,11 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Trial Autoclaim' + productId = 'autoclaim' + policyValue = 'Failed to retrieve current state, check the logs for details' + }) Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy after remediation: $($_.Exception.Message)" -sev Error } } From 83c77358f50153c9259a5f652f8211cf37adbbb2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:20:50 +0800 Subject: [PATCH 046/150] enable copilot after hotfix --- Config/FeatureFlags.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index 59ffac3d4b914..4dd7bbd1132ff 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -48,7 +48,7 @@ "Id": "CopilotAI", "Name": "Copilot & AI", "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", - "Enabled": false, + "Enabled": true, "AllowUserToggle": false, "Timers": [], "Endpoints": [ @@ -67,7 +67,7 @@ "/copilot/reports/copilot-usage", "/copilot/reports/copilot-trend" ], - "Hidden": true + "Hidden": false }, { "Id": "MCPServer", @@ -82,4 +82,4 @@ "Pages": [], "Hidden": false } -] \ No newline at end of file +] From 10f24789ce14f37cd0d5c15d25866173b5eb81eb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:49:31 +0800 Subject: [PATCH 047/150] intune device app collection --- .../Push-IntuneReportExportSubmit.ps1 | 7 + .../Get-CIPPAlertIntunePolicyConflicts.ps1 | 96 +++++++------ .../Start-IntuneReportExportOrchestrator.ps1 | 18 ++- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../Set-CIPPDBCacheIntuneAppInstallStatus.ps1 | 122 ++++++++++++++++ .../DBCache/Set-CIPPDBCacheIntunePolicies.ps1 | 5 +- ...t-CIPPAlertIntunePolicyConflicts.Tests.ps1 | 135 ++++++++---------- 7 files changed, 259 insertions(+), 125 deletions(-) create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 index bee6da0696457..be40e229a3307 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 @@ -25,6 +25,13 @@ function Push-IntuneReportExportSubmit { 'UserId', 'UserName', 'EmailAddress' ) } + 'AppInstallStatusAggregate' { + @( + 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', + 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', + 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' + ) + } default { throw "Unknown Intune report '$ReportName'" } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index 99d2fb1ad5fee..bc672c406a38f 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -49,11 +49,11 @@ function Get-CIPPAlertIntunePolicyConflicts { } $AlertableStatuses = @( - if ($Config.AlertErrors) { 'error'; 'failed' } + if ($Config.AlertErrors) { 'error' } if ($Config.AlertConflicts) { 'conflict' } ) - if (-not $AlertableStatuses) { + if (-not $AlertableStatuses -and -not ($Config.IncludeApplications -and $Config.AlertErrors)) { return } @@ -64,56 +64,66 @@ function Get-CIPPAlertIntunePolicyConflicts { $Issues = [System.Collections.Generic.List[object]]::new() - if ($Config.IncludePolicies) { - try { - $ManagedDevices = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,userPrincipalName&`$expand=deviceConfigurationStates(`$select=displayName,state,settingStates)" -tenantid $TenantFilter - - foreach ($Device in $ManagedDevices) { - $PolicyStates = $Device.deviceConfigurationStates | Where-Object { $_.state -and ($AlertableStatuses -contains $_.state) } - foreach ($State in $PolicyStates) { - $Issues.Add([PSCustomObject]@{ - Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Policy' - PolicyName = $State.displayName - IssueStatus = $State.state - DeviceName = $Device.deviceName - UserPrincipalName = $Device.userPrincipalName - DeviceId = $Device.id - }) + if ($Config.IncludePolicies -and $AlertableStatuses) { + $PolicySources = @( + @{ Type = 'IntuneDeviceCompliancePolicies'; Kind = 'Compliance' } + @{ Type = 'IntuneDeviceConfigurations'; Kind = 'Configuration' } + ) + + foreach ($Source in $PolicySources) { + try { + $PolicyItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Source.Type | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($PolicyItem in $PolicyItems) { + $Policy = try { $PolicyItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $Policy.id) { continue } + + $StatusItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type "$($Source.Type)_$($Policy.id)" | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($StatusItem in $StatusItems) { + $State = try { $StatusItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $State.status -or ($AlertableStatuses -notcontains $State.status.ToLowerInvariant())) { continue } + + $Issues.Add([PSCustomObject]@{ + Message = "$($Source.Kind) policy '$($Policy.displayName)' is $($State.status) on device '$($State.deviceDisplayName)' for $($State.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Policy' + PolicyType = $Source.Kind + PolicyName = $Policy.displayName + IssueStatus = $State.status + DeviceName = $State.deviceDisplayName + UserPrincipalName = $State.userPrincipalName + DeviceId = $State.id + }) + } } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached $($Source.Kind) policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } - if ($Config.IncludeApplications) { + if ($Config.IncludeApplications -and $Config.AlertErrors) { try { - $Applications = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName&`$expand=deviceStatuses(`$select=installState,deviceName,userPrincipalName,deviceId)" -tenantid $TenantFilter - - foreach ($App in $Applications) { - $BadStatuses = $App.deviceStatuses | Where-Object { - $_.installState -and ($AlertableStatuses -contains $_.installState.ToLowerInvariant()) - } - - foreach ($Status in $BadStatuses) { - $Issues.Add([PSCustomObject]@{ - Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Application' - AppName = $App.displayName - IssueStatus = $Status.installState - DeviceName = $Status.deviceName - UserPrincipalName = $Status.userPrincipalName - DeviceId = $Status.deviceId - }) - } + $AppItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppInstallStatusAggregate' | Where-Object { $_.RowKey -notlike '*-Count' } + foreach ($AppItem in $AppItems) { + $App = try { $AppItem.Data | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $App -or [int]($App.failedDeviceCount) -le 0) { continue } + + $Issues.Add([PSCustomObject]@{ + Message = "App '$($App.displayName)' failed to install on $($App.failedDeviceCount) device(s) ($($App.failedDevicePercentage)%)." + Tenant = $TenantFilter + Type = 'Application' + AppName = $App.displayName + IssueStatus = 'failed' + FailedDeviceCount = [int]$App.failedDeviceCount + FailedUserCount = [int]$App.failedUserCount + FailedPercentage = $App.failedDevicePercentage + Platform = $App.platform + }) } } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached Intune app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 index c772192523cdf..242e0657ba343 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-IntuneReportExportOrchestrator.ps1 @@ -31,15 +31,19 @@ function Start-IntuneReportExportOrchestrator { return } - $Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks $LicensedTenants.Count + $ReportNames = @('AppInvRawData', 'AppInstallStatusAggregate') + + $Queue = New-CippQueueEntry -Name 'Intune Report Export Submission' -TotalTasks ($LicensedTenants.Count * $ReportNames.Count) $Batch = foreach ($Tenant in $LicensedTenants) { - [PSCustomObject]@{ - FunctionName = 'IntuneReportExportSubmit' - TenantFilter = $Tenant.defaultDomainName - ReportName = 'AppInvRawData' - QueueId = $Queue.RowKey - QueueName = "Intune Export Submit - $($Tenant.defaultDomainName)" + foreach ($ReportName in $ReportNames) { + [PSCustomObject]@{ + FunctionName = 'IntuneReportExportSubmit' + TenantFilter = $Tenant.defaultDomainName + ReportName = $ReportName + QueueId = $Queue.RowKey + QueueName = "Intune Export Submit ($ReportName) - $($Tenant.defaultDomainName)" + } } } diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 06f258ec80f45..fda21748c5147 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -124,6 +124,7 @@ function Invoke-CIPPDBCacheCollection { 'IntuneScripts' 'IntuneReusableSettings' 'DetectedApps' + 'IntuneAppInstallStatus' 'MDEOnboarding' ) Compliance = @( diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 new file mode 100644 index 0000000000000..329145fd4b641 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 @@ -0,0 +1,122 @@ +function Set-CIPPDBCacheIntuneAppInstallStatus { + <# + .SYNOPSIS + Caches per-application install status counts from the AppInstallStatusAggregate + export submitted earlier. + + .DESCRIPTION + The AppInstallStatusAggregate report is the only tenant-wide app install report Intune + exposes without a per-app filter, so it carries rollup counts (FailedDeviceCount etc.) + rather than per-device detail. Get-CIPPAlertIntunePolicyConflicts reads the cached rows + to flag applications that are failing to install. + + .PARAMETER TenantFilter + The tenant to cache app install status for. + + .PARAMETER QueueId + Optional queue ID for progress tracking. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + $ReportName = 'AppInstallStatusAggregate' + + try { + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" + + if (-not $JobRow) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping app install status cache" -sev Info + return + } + + $JobId = $JobRow.JobId + if (-not $JobId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'IntuneReportJobs row missing JobId - removing' -sev Warning + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + switch ($Job.status) { + 'completed' { } + 'failed' { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + default { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + return + } + } + + if (-not $Job.url) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId completed but no url returned" -sev Error + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + $ZipBytes = (Invoke-WebRequest -Uri $Job.url -UseBasicParsing -ErrorAction Stop).Content + if ($ZipBytes -isnot [byte[]]) { throw "Expected binary content from $ReportName download" } + + $JsonText = $null + $ZipStream = [System.IO.MemoryStream]::new($ZipBytes, $false) + try { + $Archive = [System.IO.Compression.ZipArchive]::new($ZipStream, [System.IO.Compression.ZipArchiveMode]::Read) + try { + $Entry = $Archive.Entries | Where-Object { $_.Name -like '*.json' } | Select-Object -First 1 + if (-not $Entry) { throw "No JSON entry in $ReportName archive" } + $EntryStream = $Entry.Open() + try { + $Reader = [System.IO.StreamReader]::new($EntryStream) + try { $JsonText = $Reader.ReadToEnd() } finally { $Reader.Dispose() } + } finally { $EntryStream.Dispose() } + } finally { $Archive.Dispose() } + } finally { + $ZipStream.Dispose() + $ZipBytes = $null + } + + $ExportRows = @(($JsonText | ConvertFrom-Json).values) + $JsonText = $null + + $AppStatuses = foreach ($Row in $ExportRows) { + if (-not $Row.ApplicationId) { continue } + [pscustomobject]@{ + id = $Row.ApplicationId + displayName = $Row.DisplayName + publisher = $Row.Publisher + platform = $Row.AppPlatform ?? $Row.Platform + appVersion = $Row.AppVersion + installedDeviceCount = [int]($Row.InstalledDeviceCount ?? 0) + failedDeviceCount = [int]($Row.FailedDeviceCount ?? 0) + failedUserCount = [int]($Row.FailedUserCount ?? 0) + pendingInstallDeviceCount = [int]($Row.PendingInstallDeviceCount ?? 0) + notInstalledDeviceCount = [int]($Row.NotInstalledDeviceCount ?? 0) + failedDevicePercentage = [double]($Row.FailedDevicePercentage ?? 0) + } + } + $AppStatuses = @($AppStatuses) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppInstallStatusAggregate' -Data $AppStatuses -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AppStatuses.Count) app install status rows from export $JobId" -sev Info + + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 index 4acc51cee07a9..850092d9183b4 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 @@ -28,7 +28,7 @@ function Set-CIPPDBCacheIntunePolicies { $PolicyTypes = @( @{ Type = 'DeviceCompliancePolicies'; Uri = '/deviceManagement/deviceCompliancePolicies?$top=999&$expand=assignments'; FetchDeviceStatuses = $true } - @{ Type = 'DeviceConfigurations'; Uri = '/deviceManagement/deviceConfigurations?$top=999&$expand=assignments' } + @{ Type = 'DeviceConfigurations'; Uri = '/deviceManagement/deviceConfigurations?$top=999&$expand=assignments'; FetchDeviceStatuses = $true } @{ Type = 'ConfigurationPolicies'; Uri = '/deviceManagement/configurationPolicies?$top=999&$expand=assignments,settings' } @{ Type = 'GroupPolicyConfigurations'; Uri = '/deviceManagement/groupPolicyConfigurations?$top=999&$expand=assignments' } @{ Type = 'MobileAppConfigurations'; Uri = '/deviceManagement/mobileAppConfigurations?$top=999&$expand=assignments' } @@ -107,9 +107,8 @@ function Set-CIPPDBCacheIntunePolicies { Add-CIPPDbItem -TenantFilter $TenantFilter -Type "Intune$($PolicyType.Type)" -Data $Policies -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Policies.Count) $($PolicyType.Type)" -sev Debug - # Fetch device statuses for compliance policies using bulk requests if ($PolicyType.FetchDeviceStatuses -and ($Policies | Measure-Object).Count -gt 0) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching device statuses for $($Policies.Count) compliance policies using bulk request" -sev Debug + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching device statuses for $($Policies.Count) $($PolicyType.Type) using bulk request" -sev Debug $BaseUri = ($PolicyType.Uri -split '\?')[0] # Build bulk request array diff --git a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 index e37958e938141..f44bb399798e0 100644 --- a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 @@ -1,15 +1,27 @@ # Pester tests for Get-CIPPAlertIntunePolicyConflicts -# Verifies aggregation defaults, toggles, and error handling +# The alert reads pre-collected data from the CIPP reporting cache (Get-CIPPDbItem): +# - Intune_ -> per-device compliance/config states (error/conflict) +# - IntuneAppInstallStatusAggregate -> per-app install failure counts +# These tests mock the cache reads and verify aggregation, toggles, and error handling. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $AlertPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1' + $AlertPath = Join-Path $RepoRoot 'Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1' - function New-GraphGetRequest { param($uri, $tenantid) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } - function Write-AlertMessage { param($tenant, $message) } - function Get-NormalizedError { param($message) $message } - function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset) } + + # Build a cache row the way Add-CIPPDbItem stores it: RowKey "-", Data = compressed JSON. + function New-DbItem { + param($Type, $Id, $Object) + [pscustomobject]@{ + RowKey = "$Type-$Id" + Data = ($Object | ConvertTo-Json -Compress -Depth 10) + } + } . $AlertPath } @@ -28,54 +40,42 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $script:CapturedTenant = $tenantFilter } - Mock -CommandName Write-AlertMessage -MockWith { - param($tenant, $message) + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) $script:CapturedErrorMessage = $message } - Mock -CommandName New-GraphGetRequest -MockWith { - param($uri, $tenantid) - if ($uri -like '*deviceManagement/managedDevices*') { - @( - [pscustomobject]@{ - deviceName = 'PC-01' - userPrincipalName = 'user1@contoso.com' - id = 'device-1' - deviceConfigurationStates = @( - [pscustomobject]@{ displayName = 'Policy A'; state = 'conflict' } - ) - } - ) - } elseif ($uri -like '*deviceAppManagement/mobileApps*') { - @( - [pscustomobject]@{ - displayName = 'App A' - deviceStatuses = @( - [pscustomobject]@{ installState = 'error'; deviceName = 'PC-01'; userPrincipalName = 'user1@contoso.com'; deviceId = 'device-1' } - ) - } - ) + # Default cache: one compliance policy in conflict, one config profile in error, one failing app. + Mock -CommandName Get-CIPPDbItem -MockWith { + param($TenantFilter, $Type, [switch]$CountsOnly) + switch ($Type) { + 'IntuneDeviceCompliancePolicies' { New-DbItem $Type 'comp-1' @{ id = 'comp-1'; displayName = 'Compliance A' } } + 'IntuneDeviceCompliancePolicies_comp-1' { New-DbItem $Type 'd1' @{ id = 'd1'; status = 'conflict'; deviceDisplayName = 'PC-01'; userPrincipalName = 'user1@contoso.com' } } + 'IntuneDeviceConfigurations' { New-DbItem $Type 'cfg-1' @{ id = 'cfg-1'; displayName = 'Config A' } } + 'IntuneDeviceConfigurations_cfg-1' { New-DbItem $Type 'd2' @{ id = 'd2'; status = 'error'; deviceDisplayName = 'PC-02'; userPrincipalName = 'user2@contoso.com' } } + 'IntuneAppInstallStatusAggregate' { New-DbItem $Type 'app-1' @{ displayName = 'App A'; failedDeviceCount = 3; failedUserCount = 2; failedDevicePercentage = 12; platform = 'Windows' } } + default { @() } } } } - It 'defaults to aggregated alerting with all mechanisms and statuses' { + It 'defaults to aggregated alerting across compliance, config and app sources' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' $CapturedTenant | Should -Be 'contoso.onmicrosoft.com' $CapturedData | Should -Not -BeNullOrEmpty $CapturedData.Count | Should -Be 1 - $CapturedData[0].PolicyIssues | Should -Be 1 + $CapturedData[0].PolicyIssues | Should -Be 2 # compliance conflict + config error $CapturedData[0].AppIssues | Should -Be 1 - $CapturedData[0].Issues.Count | Should -Be 2 + $CapturedData[0].Issues.Count | Should -Be 3 } It 'emits per-issue alerts when AlertEachIssue is true' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertEachIssue = $true } $CapturedData | Should -Not -BeNullOrEmpty - $CapturedData.Count | Should -Be 2 - ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 1 + $CapturedData.Count | Should -Be 3 + ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 2 ($CapturedData | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 1 } @@ -83,9 +83,7 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ Aggregate = $false } $CapturedData | Should -Not -BeNullOrEmpty - $CapturedData.Count | Should -Be 2 - ($CapturedData | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 1 - ($CapturedData | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 1 + $CapturedData.Count | Should -Be 3 } It 'honors IncludePolicies toggle' { @@ -96,42 +94,35 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $CapturedData[0].PolicyIssues | Should -Be 0 $CapturedData[0].AppIssues | Should -Be 1 $CapturedData[0].Issues.Count | Should -Be 1 - ($CapturedData[0].Issues | Where-Object { $_.Type -eq 'Policy' }).Count | Should -Be 0 } - It 'suppresses conflict-only alerts when AlertConflicts is false' { - # conflict for policy, error for app; expect only app when conflicts suppressed - Mock -CommandName New-GraphGetRequest -MockWith { - param($uri, $tenantid) - if ($uri -like '*deviceManagement/managedDevices*') { - @( - [pscustomobject]@{ - deviceName = 'PC-02' - userPrincipalName = 'user2@contoso.com' - id = 'device-2' - deviceConfigurationStates = @( - [pscustomobject]@{ displayName = 'Policy B'; state = 'conflict' } - ) - } - ) - } elseif ($uri -like '*deviceAppManagement/mobileApps*') { - @( - [pscustomobject]@{ - displayName = 'App B' - deviceStatuses = @( - [pscustomobject]@{ installState = 'error'; deviceName = 'PC-02'; userPrincipalName = 'user2@contoso.com'; deviceId = 'device-2' } - ) - } - ) - } - } + It 'honors IncludeApplications toggle' { + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ IncludeApplications = $false } - Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertConflicts = $false; Aggregate = $false } + $CapturedData | Should -Not -BeNullOrEmpty + $CapturedData[0].PolicyIssues | Should -Be 2 + $CapturedData[0].AppIssues | Should -Be 0 + ($CapturedData[0].Issues | Where-Object { $_.Type -eq 'Application' }).Count | Should -Be 0 + } + + It 'suppresses conflict states (and apps) when only AlertConflicts is requested' { + # Only conflicts requested: config 'error' state and app failures are both suppressed, + # leaving just the compliance conflict. + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertErrors = $false; Aggregate = $false } $CapturedData | Should -Not -BeNullOrEmpty $CapturedData.Count | Should -Be 1 - $CapturedData[0].Type | Should -Be 'Application' - $CapturedData[0].IssueStatus | Should -Be 'error' + $CapturedData[0].Type | Should -Be 'Policy' + $CapturedData[0].IssueStatus | Should -Be 'conflict' + $CapturedData[0].PolicyType | Should -Be 'Compliance' + } + + It 'reports aggregate app failure detail' { + Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' -InputValue @{ AlertEachIssue = $true; IncludePolicies = $false } + + $AppIssue = $CapturedData | Where-Object { $_.Type -eq 'Application' } + $AppIssue.FailedDeviceCount | Should -Be 3 + $AppIssue.Message | Should -Match "failed to install on 3 device" } It 'skips processing when license check fails' { @@ -143,13 +134,13 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $CapturedTenant | Should -BeNullOrEmpty } - It 'writes alert message when Graph call fails' { - Mock -CommandName New-GraphGetRequest -MockWith { throw 'Graph failure' } -Verifiable + It 'writes alert message when a cache read fails' { + Mock -CommandName Get-CIPPDbItem -MockWith { throw 'DB failure' } -Verifiable Get-CIPPAlertIntunePolicyConflicts -TenantFilter 'contoso.onmicrosoft.com' $CapturedData | Should -BeNullOrEmpty - $CapturedErrorMessage | Should -Match 'Failed to query Intune (policy|application) states' - $CapturedErrorMessage | Should -Match 'Graph failure' + $CapturedErrorMessage | Should -Match 'Failed to read cached' + $CapturedErrorMessage | Should -Match 'DB failure' } } From f44ff88ade8f608415930c37271f457959df8e11 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:08:16 +0200 Subject: [PATCH 048/150] feat: add allTenants support for shared mailbox enabled report --- ...-CIPPSharedMailboxAccountEnabledReport.ps1 | 100 +++++++++++++ ...Invoke-ListSharedMailboxAccountEnabled.ps1 | 18 +++ ...haredMailboxAccountEnabledReport.Tests.ps1 | 141 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 create mode 100644 Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 new file mode 100644 index 0000000000000..72c961f2a8cb5 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 @@ -0,0 +1,100 @@ +function Get-CIPPSharedMailboxAccountEnabledReport { + <# + .SYNOPSIS + Generates the "shared mailbox with enabled account" report from the CIPP Reporting database + + .DESCRIPTION + Reproduces the live Invoke-ListSharedMailboxAccountEnabled payload entirely from cached data, + joining the cached 'Mailboxes' dataset (to identify SharedMailbox recipients) with the cached + 'Users' dataset (for accountEnabled / assignedLicenses / onPremisesSyncEnabled) by UPN. Only + shared mailboxes whose user account is enabled are returned. No dedicated cache writer is needed — + both source datasets are already populated on the scheduled cache cycle. + + .PARAMETER TenantFilter + The tenant to generate the report for. 'AllTenants' fans out across every tenant present in the + Mailboxes cache. + + .EXAMPLE + Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'contoso.onmicrosoft.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Handle AllTenants by recursing per tenant present in the Mailboxes cache + if ($TenantFilter -eq 'AllTenants') { + $AllMailboxItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Mailboxes' + $Tenants = @($AllMailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-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-CIPPSharedMailboxAccountEnabledReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Mailboxes cache identifies which mailboxes are shared (recipientTypeDetails) and the join key (UPN) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + if (-not $MailboxItems) { + throw 'No mailbox data found in reporting database. Sync the report data first.' + } + + # Users cache carries the account/license fields the live endpoint pulls from Graph /users + $UserItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_.RowKey -ne 'Users-Count' } + + # Most-recent cache timestamp across both source datasets + $CacheTimestamp = (@($MailboxItems) + @($UserItems) | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Build a UPN -> user lookup (hashtable string keys are case-insensitive, matching UPN semantics) + $UserByUPN = @{} + foreach ($Item in $UserItems) { + $User = $Item.Data | ConvertFrom-Json + if ($User.userPrincipalName) { + $UserByUPN[$User.userPrincipalName] = $User + } + } + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.recipientTypeDetails -ne 'SharedMailbox') { continue } + + $User = $UserByUPN[$Mailbox.UPN] + if (-not $User -or -not $User.accountEnabled) { continue } + + # Match the live Invoke-ListSharedMailboxAccountEnabled shape exactly. 'id' must be the user's + # object id — the page's "Block Sign In" action posts it to ExecDisableUser. + $Results.Add([PSCustomObject]@{ + UserPrincipalName = $User.userPrincipalName + displayName = $User.displayName + givenName = $User.givenName + surname = $User.surname + accountEnabled = $User.accountEnabled + assignedLicenses = $User.assignedLicenses + id = $User.id + onPremisesSyncEnabled = $User.onPremisesSyncEnabled + CacheTimestamp = $CacheTimestamp + }) + } + + return $Results + + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $TenantFilter -message "Failed to generate shared mailbox account enabled report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 index 6daf756987619..55d8f547ed001 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 @@ -6,15 +6,33 @@ function Invoke-ListSharedMailboxAccountEnabled { Exchange.Mailbox.Read .DESCRIPTION Lists shared mailboxes that have direct sign-in enabled (account not disabled), which is a security concern. + Supports UseReportDB=true to read cached data from the reporting database (required for AllTenants). #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB # Get Shared Mailbox Stuff try { + # If UseReportDB is specified, retrieve from the report database (cached Mailboxes + Users join) + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + $SharedMailboxList = (New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$($TenantFilter)/Mailbox?`$filter=RecipientTypeDetails eq 'SharedMailbox'" -Tenantid $TenantFilter -scope ExchangeOnline) $AllUsersInfo = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$select=id,userPrincipalName,accountEnabled,displayName,givenName,surname,onPremisesSyncEnabled,assignedLicenses' -tenantid $TenantFilter $SharedMailboxDetails = foreach ($SharedMailbox in $SharedMailboxList) { diff --git a/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 new file mode 100644 index 0000000000000..991df44fd8206 --- /dev/null +++ b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 @@ -0,0 +1,141 @@ +# Pester tests for Get-CIPPSharedMailboxAccountEnabledReport +# Verifies the cached Mailboxes + Users join, accountEnabled filtering, payload shape, and AllTenants fan-out + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ReportPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + + . $ReportPath + + function New-DbItem { + param($PartitionKey, $RowKey, $Data, $Timestamp) + [pscustomobject]@{ + PartitionKey = $PartitionKey + RowKey = $RowKey + Timestamp = $Timestamp + Data = ($Data | ConvertTo-Json -Depth 5 -Compress) + } + } +} + +Describe 'Get-CIPPSharedMailboxAccountEnabledReport' { + BeforeEach { + $script:Tenant = 'contoso.onmicrosoft.com' + + $script:SharedMailbox = @{ UPN = 'shared@contoso.com'; recipientTypeDetails = 'SharedMailbox' } + $script:RegularMailbox = @{ UPN = 'user@contoso.com'; recipientTypeDetails = 'UserMailbox' } + + $script:EnabledUser = @{ + userPrincipalName = 'shared@contoso.com' + displayName = 'Shared Mailbox' + givenName = 'Shared' + surname = 'Mailbox' + accountEnabled = $true + assignedLicenses = @(@{ skuId = 'sku-1' }) + id = 'user-id-shared' + onPremisesSyncEnabled = $false + } + $script:RegularUser = @{ + userPrincipalName = 'user@contoso.com' + displayName = 'Regular User' + accountEnabled = $true + id = 'user-id-regular' + onPremisesSyncEnabled = $false + } + + $script:Now = Get-Date + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Tenants -MockWith { @([pscustomobject]@{ defaultDomainName = 'contoso.onmicrosoft.com' }) } + } + + It 'joins a shared mailbox to its user and returns the live payload shape' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '2' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Users-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now + ) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 1 + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + $Result[0].id | Should -Be 'user-id-shared' + $Result[0].accountEnabled | Should -BeTrue + $Result[0].onPremisesSyncEnabled | Should -BeFalse + $Result[0].CacheTimestamp | Should -Not -BeNullOrEmpty + # Must not leak the regular (non-shared) mailbox + $Result.UserPrincipalName | Should -Not -Contain 'user@contoso.com' + } + + It 'excludes shared mailboxes whose user account is disabled' { + $script:EnabledUser.accountEnabled = $false + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 0 + } + + It 'returns an empty result (no throw) when the cache holds no enabled shared mailboxes' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now) + } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Not -Throw + @(Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant).Count | Should -Be 0 + } + + It 'throws when no mailbox data is cached' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { @() } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { @() } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Throw '*Sync the report data first*' + } + + It 'adds a Tenant column for AllTenants' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'AllTenants' + + @($Result).Count | Should -Be 1 + $Result[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + } +} From 69394ec1f0fe080f7b4ae3f9311374bb4f774b60 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:31:41 +0200 Subject: [PATCH 049/150] feat: Support room calendar processing options --- .../Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 | 5 +++-- .../Email-Exchange/Resources/Invoke-ListRooms.ps1 | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 9ccfa72a5efb1..0d002433ffd6e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -59,8 +59,9 @@ Function Invoke-EditRoomMailbox { $CalendarProperties = @( 'AllowConflicts', 'AllowRecurringMeetings', 'BookingWindowInDays', 'MaximumDurationInMinutes', 'ProcessExternalMeetingMessages', 'EnforceCapacity', - 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours ', 'AutomateProcessing', - 'AddOrganizerToSubject', 'DeleteSubject', 'RemoveCanceledMeetings' + 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours', 'AutomateProcessing', + 'AddOrganizerToSubject', 'DeleteComments', 'DeleteSubject', 'RemovePrivateProperty', + 'RemoveCanceledMeetings', 'RemoveOldMeetingMessages' ) foreach ($prop in $CalendarProperties) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index 127b5d4006fc1..86943bf85efac 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -81,8 +81,11 @@ function Invoke-ListRooms { ScheduleOnlyDuringWorkHours = $CalendarProperties.ScheduleOnlyDuringWorkHours AutomateProcessing = $CalendarProperties.AutomateProcessing AddOrganizerToSubject = $CalendarProperties.AddOrganizerToSubject + DeleteComments = $CalendarProperties.DeleteComments DeleteSubject = $CalendarProperties.DeleteSubject + RemovePrivateProperty = $CalendarProperties.RemovePrivateProperty RemoveCanceledMeetings = $CalendarProperties.RemoveCanceledMeetings + RemoveOldMeetingMessages = $CalendarProperties.RemoveOldMeetingMessages # Calendar Configuration Properties WorkDays = if ([string]::IsNullOrWhiteSpace($CalendarConfigurationProperties.WorkDays)) { $null } else { $CalendarConfigurationProperties.WorkDays } From 05096dcf6b23b506d17f4fa12d894f130ca94de7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:15:49 +0200 Subject: [PATCH 050/150] feat: Add default calendar permission handling for rooms --- .../Resources/Invoke-EditRoomMailbox.ps1 | 25 +++++++++++++++++ .../Resources/Invoke-ListRooms.ps1 | 27 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 0d002433ffd6e..8c638985853e1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -14,6 +14,7 @@ Function Invoke-EditRoomMailbox { $Results = [System.Collections.Generic.List[Object]]::new() $MailboxObject = $Request.Body + $DefaultCalendarPermission = $MailboxObject.DefaultCalendarPermission.value ?? $MailboxObject.DefaultCalendarPermission # First update the mailbox properties $UpdateMailboxParams = @{ @@ -99,6 +100,30 @@ Function Invoke-EditRoomMailbox { $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxCalendarConfiguration' -cmdParams $UpdateCalendarConfigParams -Anchor $MailboxObject.roomId $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Calendar Configuration)") + if (![string]::IsNullOrWhiteSpace($DefaultCalendarPermission)) { + $CalendarFolder = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $MailboxObject.roomId + FolderScope = 'Calendar' + } -Anchor $MailboxObject.roomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + $CalendarFolderIdentity = if ($CalendarFolder -and $CalendarFolder.FolderId) { + "$($MailboxObject.roomId):$($CalendarFolder.FolderId)" + } elseif ($CalendarFolder -and $CalendarFolder.Name) { + "$($MailboxObject.roomId):\$($CalendarFolder.Name)" + } else { + "$($MailboxObject.roomId):\Calendar" + } + + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + User = 'Default' + AccessRights = $DefaultCalendarPermission + } -Anchor $MailboxObject.roomId + + Sync-CIPPCalendarPermissionCache -TenantFilter $Tenant -MailboxIdentity $MailboxObject.roomId -FolderName 'Calendar' -User 'Default' -Permissions $DefaultCalendarPermission -Action 'Add' + $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Default Calendar Permission)") + } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Tenant -message "Updated room $($MailboxObject.DisplayName)" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index 86943bf85efac..b7ee98fb8848e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -30,6 +30,32 @@ function Invoke-ListRooms { # Get-MailboxCalendarConfiguration requires anchor to the room mailbox $CalendarConfigurationProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxCalendarConfiguration' -cmdParams @{ Identity = $RoomId } -Anchor $RoomId | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermission = $null + + try { + $CalendarFolder = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $RoomId + FolderScope = 'Calendar' + } -Anchor $RoomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + if ($CalendarFolder) { + $CalendarFolderIdentity = if ($CalendarFolder.FolderId) { "$($RoomId):$($CalendarFolder.FolderId)" } else { "$($RoomId):\$($CalendarFolder.Name)" } + $CalendarFolderPermissions = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + } -Anchor $RoomId -UseSystemMailbox $true | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermissionEntry = $CalendarFolderPermissions | Where-Object { $_.User -eq 'Default' } | Select-Object -First 1 + + if ($DefaultCalendarPermissionEntry) { + $DefaultCalendarPermission = if ($DefaultCalendarPermissionEntry.AccessRights -is [array]) { + $DefaultCalendarPermissionEntry.AccessRights -join ',' + } else { + $DefaultCalendarPermissionEntry.AccessRights + } + } + } + } catch { + Write-Warning "Could not retrieve default calendar permission for room $RoomId. $($_.Exception.Message)" + } if ($RoomMailbox -and $PlaceDetails -and $CalendarProperties -and $CalendarConfigurationProperties) { $GraphRequest = @( @@ -86,6 +112,7 @@ function Invoke-ListRooms { RemovePrivateProperty = $CalendarProperties.RemovePrivateProperty RemoveCanceledMeetings = $CalendarProperties.RemoveCanceledMeetings RemoveOldMeetingMessages = $CalendarProperties.RemoveOldMeetingMessages + DefaultCalendarPermission = $DefaultCalendarPermission # Calendar Configuration Properties WorkDays = if ([string]::IsNullOrWhiteSpace($CalendarConfigurationProperties.WorkDays)) { $null } else { $CalendarConfigurationProperties.WorkDays } From 612f2f8ac13a8935a1bcd4109deb5acfe4a1449c Mon Sep 17 00:00:00 2001 From: Chris Dewey <142454021+chris-dewey-1991@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:41:59 +0100 Subject: [PATCH 051/150] Update Get-CIPPStandards.ps1 Updated "Get-CIPPStadnards.ps1" to resolve Custom Quarantine Policy not reporting correctly and always showing as non compliant due to Expected and Current not returning information. Fix now reports Expected and Current resulting in Compliant if complete and non-compliants if missing. Signed-off-by: Chris Dewey <142454021+chris-dewey-1991@users.noreply.github.com> --- Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 index 6a86545741f54..1cdafbdaff969 100644 --- a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 +++ b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 @@ -319,7 +319,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { # Key by StandardName + TemplateList.value (if present) - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" $ComputedStandards[$Key] = $CurrentStandard @@ -388,7 +388,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" if ($ComputedStandards.ContainsKey($Key)) { @@ -468,7 +468,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" if ($ComputedStandards.ContainsKey($Key)) { From 62ff1c6e40b796e9a6f1dca233ac55375f4a6528 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:42:41 +0800 Subject: [PATCH 052/150] Update Start-UserTasksOrchestrator.ps1 --- .../Start-UserTasksOrchestrator.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index da1a515e285b5..b4110b8b18ba0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -28,10 +28,10 @@ function Start-UserTasksOrchestrator { } } else { $4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $24HoursAgo = (Get-Date).AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - # Pending = orchestrator queued, Running = actively executing - # Pick up: Planned, Failed-Planned, stuck Pending (>24hr), or stuck Running (>4hr for large AllTenants tasks) - $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$24HoursAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" + $1HourAgo = (Get-Date).AddHours(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + # Pending = orchestrator claimed but executor not yet started, Running = actively executing + # Pick up: Planned, Failed-Planned, stuck Pending (>1hr - orphaned claim), or stuck Running/Processing (>4hr for large AllTenants tasks) + $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$1HourAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter } From 176a05f26fb54b479ae894e5bdc654b73f97a2a7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:11:03 +0800 Subject: [PATCH 053/150] mailbox rule removal hardening for multiple mailboxes with the same alias --- Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 | 13 ++++++++++--- .../Administration/Invoke-ExecRemoveMailboxRule.ps1 | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 index f7fde93d58a8e..e60b4ea632e4c 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 @@ -8,6 +8,7 @@ function Remove-CIPPMailboxRule { $Headers, $RuleId, $RuleName, + $MailboxObjectId, [switch]$RemoveAllRules ) @@ -38,9 +39,15 @@ function Remove-CIPPMailboxRule { } else { # Only delete 1 rule try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $RuleId } - $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" - Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $RuleId } + $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" + Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + } catch { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $MailboxObjectId -cmdParams @{Identity = $RuleId } + $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" + Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + } # Remove from cache if it exists try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 index 006e1bbe0a1b6..34d6d5fc81be5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecRemoveMailboxRule.ps1 @@ -14,11 +14,12 @@ Function Invoke-ExecRemoveMailboxRule { $RuleName = $Request.Query.ruleName ?? $Request.Body.ruleName $RuleId = $Request.Query.ruleId ?? $Request.Body.ruleId $Username = $Request.Query.userPrincipalName ?? $Request.Body.userPrincipalName + $MailboxObjectId = $RuleId.Split('\\')[0] Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message 'Accessed this API' -Sev 'Debug' try { # Remove the rule - $Results = Remove-CIPPMailboxRule -username $Username -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers -RuleId $RuleId -RuleName $RuleName + $Results = Remove-CIPPMailboxRule -username $Username -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers -RuleId $RuleId -RuleName $RuleName -MailboxObjectId $MailboxObjectId $StatusCode = [HttpStatusCode]::OK } catch { $Results = $_.Exception.Message From 4f918494128023464f50aa807fc16b63e8b4ea00 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:22:39 +0800 Subject: [PATCH 054/150] Update CIPPCore.psd1 --- Modules/CIPPCore/CIPPCore.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/CIPPCore.psd1 b/Modules/CIPPCore/CIPPCore.psd1 index 15305aaf91a50..f995ce80f6edf 100644 --- a/Modules/CIPPCore/CIPPCore.psd1 +++ b/Modules/CIPPCore/CIPPCore.psd1 @@ -45,7 +45,7 @@ # RequiredModules = @() # Assemblies that must be loaded prior to importing this module - # RequiredAssemblies = @() + RequiredAssemblies = @('..\..\Shared\CIPPSharp\bin\CIPPSharp.dll') # Script files (.ps1) that are run in the caller's environment prior to importing this module. # ScriptsToProcess = @() From c750267f3981045ca810139f7ed7184b87efe9a5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:17:03 +0800 Subject: [PATCH 055/150] Custom Test Engine converted to a CLM runtime --- .../Get-CippCustomScriptAllowedCommand.ps1 | 33 +++++ .../CIPPCore/Public/Get-CippSandboxData.ps1 | 78 ++++++++++++ .../Public/Invoke-CippSandboxScript.ps1 | 114 ++++++++++++++++++ .../Public/New-CippCustomScriptExecution.ps1 | 65 ++++++---- .../New-CippSandboxInitialSessionState.ps1 | 52 ++++++++ .../Public/Test-CustomScriptSecurity.ps1 | 84 +++++++++++-- .../Custom-Scripts/Invoke-AddCustomScript.ps1 | 4 +- 7 files changed, 389 insertions(+), 41 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CippSandboxData.ps1 create mode 100644 Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 create mode 100644 Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 diff --git a/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 b/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 new file mode 100644 index 0000000000000..5db262728b0c0 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippCustomScriptAllowedCommand.ps1 @@ -0,0 +1,33 @@ +function Get-CippCustomScriptAllowedCommand { + <# + .SYNOPSIS + Single source of truth for the custom-test command allowlist. + + .DESCRIPTION + Used by both Test-CustomScriptSecurity (static pre-check) and + New-CippSandboxInitialSessionState (the ConstrainedLanguage runspace) so the + validator and the sandbox can never drift apart. + + Notes: + - New-Object is intentionally NOT allowed — it is the primary sandbox-escape + vector and is blocked by ConstrainedLanguage anyway. + - Data access is limited to Get-CIPPTestData. The lower-level New-CIPPDbRequest / + Get-CIPPDbItem are not exposed: the sandbox serves pre-fetched, tenant-locked + cache data only. + #> + [CmdletBinding()] + param() + + @( + # Data shaping + 'ForEach-Object', 'Where-Object', 'Select-Object', 'Sort-Object', 'Group-Object', + 'Measure-Object', 'Compare-Object', 'Get-Unique', 'Get-Member', 'Select-String', + + # Conversion / utility + 'ConvertTo-Json', 'ConvertFrom-Json', 'Get-Date', 'Get-Random', 'New-TimeSpan', + 'New-Guid', 'Write-Output', + + # CIPP read-only data access (provided as a CLM-safe proxy in the sandbox) + 'Get-CIPPTestData' + ) +} diff --git a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 new file mode 100644 index 0000000000000..daa7b4a32e143 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 @@ -0,0 +1,78 @@ +function Get-CippSandboxData { + <# + .SYNOPSIS + Pre-fetches the tenant-locked cache data a custom test requests. + + .DESCRIPTION + Runs on the trusted (FullLanguage) side before the script enters the sandbox. + Inspects the script AST for Get-CIPPTestData calls, resolves each requested -Type, + and fetches that data for the supplied tenant via the real Get-CIPPTestData. The + result is a hashtable keyed by Type that the sandbox proxy serves. + + Because only the requested types for THIS tenant are fetched and injected, the + sandbox is structurally unable to read any other tenant's data. + + -Type must be a string literal. Dynamic type names cannot be pre-fetched and are + rejected with a clear error (rather than silently returning empty data). + + .PARAMETER ScriptContent + The (already text-replaced, already validated) script content. + + .PARAMETER TenantFilter + The tenant to fetch data for. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptContent, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $Ast = [System.Management.Automation.Language.Parser]::ParseInput($ScriptContent, [ref]$null, [ref]$null) + + $Calls = $Ast.FindAll({ + param($Node) + $Node -is [System.Management.Automation.Language.CommandAst] -and + $Node.GetCommandName() -eq 'Get-CIPPTestData' + }, $true) + + $Data = @{} + + foreach ($Call in $Calls) { + $Type = $null + $HasType = $false + $TypeIsLiteral = $true + + for ($i = 0; $i -lt $Call.CommandElements.Count; $i++) { + $Element = $Call.CommandElements[$i] + if ($Element -is [System.Management.Automation.Language.CommandParameterAst] -and $Element.ParameterName -ieq 'Type') { + $HasType = $true + $Value = if ($Element.Argument) { + $Element.Argument + } elseif ($i + 1 -lt $Call.CommandElements.Count) { + $Call.CommandElements[$i + 1] + } else { + $null + } + if ($Value -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $Type = $Value.Value + } else { + $TypeIsLiteral = $false + } + } + } + + if ($HasType -and -not $TypeIsLiteral) { + throw "Custom test sandbox requires a literal -Type for Get-CIPPTestData (for example: Get-CIPPTestData -Type 'Users'). Dynamic or computed type names are not supported." + } + + $Key = if ($Type) { $Type } else { '' } + if (-not $Data.ContainsKey($Key)) { + $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type) + } + } + + return $Data +} diff --git a/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 b/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 new file mode 100644 index 0000000000000..1ab201e708382 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CippSandboxScript.ps1 @@ -0,0 +1,114 @@ +function Invoke-CippSandboxScript { + <# + .SYNOPSIS + Executes custom-test script content inside a ConstrainedLanguage sandbox runspace. + + .DESCRIPTION + Compiles and runs the script in a fresh runspace built from the cached sandbox + InitialSessionState (ConstrainedLanguage + command allowlist + Get-CIPPTestData + proxy). The script is compiled via AddScript on the trusted side — never via + [scriptblock]::Create inside the runspace (which CLM blocks) — so it executes + constrained. + + Pre-fetched, tenant-locked cache data is injected as $CIPPSandboxData for the proxy. + Parameters are bound by name; passing a parameter the script does not declare is + harmless (ignored), matching how the test runner supplies -TenantFilter. + + The sandbox imports no CIPP modules — it only needs the proxy and injected data — so + runspace creation is cheap. (A runspace pool can be layered on later if profiling + shows creation is hot; per-call creation keeps it concurrency-safe for now.) + + .PARAMETER ScriptContent + The validated, text-replaced script to run. + + .PARAMETER SandboxData + Hashtable of pre-fetched cache data keyed by Type (from Get-CippSandboxData). + + .PARAMETER ScriptParameters + Named parameters to bind to the script (e.g. TenantFilter and custom params). + + .PARAMETER TimeoutSeconds + Wall-clock execution limit. A script that exceeds it (e.g. an infinite loop) has its + pipeline stopped and is reported as a terminating timeout. + + .OUTPUTS + PSCustomObject with Output, Errors, HadErrors, Terminating, TimedOut. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptContent, + + [Parameter(Mandatory = $false)] + [hashtable]$SandboxData = @{}, + + [Parameter(Mandatory = $false)] + [hashtable]$ScriptParameters = @{}, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 600)] + [int]$TimeoutSeconds = 60 + ) + + # Cache the (reusable) ISS template for the lifetime of the worker process. + if (-not $script:CippSandboxInitialSessionState) { + $script:CippSandboxInitialSessionState = New-CippSandboxInitialSessionState + } + + $Runspace = [runspacefactory]::CreateRunspace($script:CippSandboxInitialSessionState) + $Runspace.Open() + try { + # Trusted host (FullLanguage) seeds the locked tenant's data for the proxy. + $Runspace.SessionStateProxy.SetVariable('CIPPSandboxData', $SandboxData) + + $PowerShell = [powershell]::Create() + $PowerShell.Runspace = $Runspace + try { + $null = $PowerShell.AddScript($ScriptContent) + foreach ($Key in $ScriptParameters.Keys) { + $null = $PowerShell.AddParameter($Key, $ScriptParameters[$Key]) + } + + # Run asynchronously so a runaway script can be cancelled on timeout. + $AsyncResult = $PowerShell.BeginInvoke() + $Completed = $AsyncResult.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + + if (-not $Completed) { + # Exceeded the wall-clock limit (e.g. infinite loop). Stop the pipeline. + try { $PowerShell.Stop() } catch {} + return [PSCustomObject]@{ + Output = @() + Errors = @("Script exceeded the ${TimeoutSeconds}s execution limit and was cancelled.") + HadErrors = $true + Terminating = $true + TimedOut = $true + } + } + + try { + $Output = $PowerShell.EndInvoke($AsyncResult) + } catch { + # Terminating error inside the script. + return [PSCustomObject]@{ + Output = @() + Errors = @($_) + HadErrors = $true + Terminating = $true + TimedOut = $false + } + } + + return [PSCustomObject]@{ + Output = $Output + Errors = @($PowerShell.Streams.Error) + HadErrors = $PowerShell.HadErrors + Terminating = $false + TimedOut = $false + } + } finally { + $PowerShell.Dispose() + } + } finally { + $Runspace.Dispose() + } +} diff --git a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 index 8b86e90cecebd..fb995c5e2986c 100644 --- a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 +++ b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 @@ -4,12 +4,15 @@ function New-CippCustomScriptExecution { Executes a custom PowerShell script in a restricted environment .DESCRIPTION - Runs user-provided PowerShell scripts with strict security constraints: - - Only data manipulation cmdlets allowed - - Read-only access to CIPPDB via Get-CIPPTestData - - No file system, network, or write operations - - PowerShell 7.4 syntax supported - - Script output can be produced via pipeline output or explicit return + Runs user-provided PowerShell scripts inside an isolated ConstrainedLanguage + sandbox runspace: + - LanguageMode = ConstrainedLanguage blocks New-Object on arbitrary types and all + .NET method/reflection access (the real containment boundary). + - A command allowlist (Get-CippCustomScriptAllowedCommand) hides everything else. + - Read-only data access via a Get-CIPPTestData proxy that serves only pre-fetched, + tenant-locked cache data — the script cannot reach storage or other tenants. + - No file system, network, or write operations. + - Script output can be produced via pipeline output or explicit return. .PARAMETER ScriptGuid The GUID of the script to execute from the database @@ -69,24 +72,22 @@ function New-CippCustomScriptExecution { $Parameters = @{} } - # Validate script security constraints using AST parsing - Test-CustomScriptSecurity -ScriptContent $ScriptContent - - # Replace %variable% placeholders with tenant/custom values + # Replace %variable% placeholders FIRST, then validate the final text. Validating + # before replacement would let substituted content bypass the check. $ScriptContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $ScriptContent - # Create script block from user content - $ScriptBlock = [scriptblock]::Create($ScriptContent) + # Fast static pre-check (friendly errors). ConstrainedLanguage is the real boundary. + Test-CustomScriptSecurity -ScriptContent $ScriptContent - # Lock tenant for data access functions — scripts cannot query other tenants - # Module-scoped variable: isolated per runspace (no cross-invocation interference) - # and inaccessible to user scripts (AST blocks $script: access) - $script:CIPPLockedTenant = $TenantFilter + # Pre-fetch the tenant-locked cache data the script asks for (trusted side), so the + # sandbox proxy can serve it. The sandbox itself has no storage/tenant access. + $SandboxData = Get-CippSandboxData -ScriptContent $ScriptContent -TenantFilter $TenantFilter + + # Build script parameters (TenantFilter + custom). TenantFilter is supplied for + # scripts that declare it; data access is tenant-locked regardless. $ScriptParams = @{ TenantFilter = $TenantFilter } - - # Add custom parameters if any foreach ($key in $Parameters.Keys) { if ($key -ne 'TenantFilter' -and $key -ne 'tenantFilter') { $ScriptParams[$key] = $Parameters[$key] @@ -95,13 +96,26 @@ function New-CippCustomScriptExecution { Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Executing script with parameters: $($ScriptParams.Keys -join ', ')" -sev 'Debug' - # Execute the script in current session (already has CIPP functions loaded) - # The AST validation ensures only safe commands are used - # Use splatting to pass named parameters - try { - $Result = & $ScriptBlock @ScriptParams - } finally { - $script:CIPPLockedTenant = $null + # Execute inside the ConstrainedLanguage sandbox. + $Execution = Invoke-CippSandboxScript -ScriptContent $ScriptContent -SandboxData $SandboxData -ScriptParameters $ScriptParams + + # Deduplicate errors: a single bad expression in a pipeline (e.g. [pscustomobject] + # inside ForEach-Object) emits the same error once per item, which is just noise. + $ErrorText = (@($Execution.Errors | ForEach-Object { $_.ToString() }) | Select-Object -Unique) -join '; ' + + $Result = $Execution.Output + # Treat a null-only result as "no output" — a failed expression (e.g. [type]::new() + # under CLM) emits a single $null, which must not mask the error as a real result. + $HasOutput = @($Result | Where-Object { $null -ne $_ }).Count -gt 0 + + # Surface failures to the caller (e.g. the Run Test UI) instead of returning null and + # leaving the error only in the logbook. Terminating errors always fail; non-terminating + # errors fail only when they left no usable output (the typical CLM-rejection case). + if ($Execution.Terminating -or ($Execution.HadErrors -and -not $HasOutput)) { + throw "Custom script execution failed: $ErrorText" + } + if ($Execution.HadErrors) { + Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Custom script produced non-terminating errors: $ErrorText" -sev 'Warning' } # Convert result to array if it's not already @@ -114,7 +128,6 @@ function New-CippCustomScriptExecution { } } catch { - $script:CIPPLockedTenant = $null Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Failed to execute custom script: $($_.Exception.Message)" -sev 'Error' throw } diff --git a/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 b/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 new file mode 100644 index 0000000000000..2211055f9bac9 --- /dev/null +++ b/Modules/CIPPCore/Public/New-CippSandboxInitialSessionState.ps1 @@ -0,0 +1,52 @@ +function New-CippSandboxInitialSessionState { + <# + .SYNOPSIS + Builds the ConstrainedLanguage InitialSessionState used to run custom tests. + + .DESCRIPTION + - LanguageMode = ConstrainedLanguage. This is what actually contains user scripts: + it blocks New-Object on arbitrary types and all .NET method/reflection access, + which the previous AST allowlist could not. + - Command allowlist: every command from CreateDefault() that is NOT in + Get-CippCustomScriptAllowedCommand is set Private (invisible to the user script). + - Get-CIPPTestData is added as a CLM-safe proxy that serves only the host-injected, + tenant-locked cache data ($CIPPSandboxData). It is Constant so a test cannot shadow + it to feed bogus data to a later test in the same suite. + + The ISS is a reusable template; callers create a fresh runspace from it per execution. + #> + [CmdletBinding()] + param() + + $Allowed = Get-CippCustomScriptAllowedCommand + + $InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $InitialSessionState.LanguageMode = [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage + + foreach ($Entry in @($InitialSessionState.Commands)) { + if ($Entry.Name -notin $Allowed) { + $Entry.Visibility = [System.Management.Automation.SessionStateEntryVisibility]::Private + } + } + + # CLM-safe data proxy. No script-level .NET — indexes the injected hashtable only. + $ProxyBody = @' +param([string]$TenantFilter, [string]$Type) +$Key = if ($Type) { $Type } else { '' } +if ($CIPPSandboxData -and $CIPPSandboxData.ContainsKey($Key)) { + return $CIPPSandboxData[$Key] +} +return @() +'@ + + $ProxyEntry = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new( + 'Get-CIPPTestData', + $ProxyBody, + [System.Management.Automation.ScopedItemOptions]::Constant, + $null + ) + $ProxyEntry.Visibility = [System.Management.Automation.SessionStateEntryVisibility]::Public + $InitialSessionState.Commands.Add($ProxyEntry) + + return $InitialSessionState +} diff --git a/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 b/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 index 240f7fb4456ed..c47d54dc0d3e7 100644 --- a/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 +++ b/Modules/CIPPCore/Public/Test-CustomScriptSecurity.ps1 @@ -54,19 +54,8 @@ function Test-CustomScriptSecurity { } } - # ALLOWLIST: Only these commands are permitted - $AllowedCommands = @( - # Data manipulation cmdlets - 'ForEach-Object', 'Where-Object', 'Select-Object', 'Group-Object', - 'Measure-Object', 'Sort-Object', 'Compare-Object', 'Get-Member', - - # Utility cmdlets - 'Get-Date', 'Get-Random', 'New-Object', 'New-Guid', 'New-TimeSpan', - 'ConvertTo-Json', 'ConvertFrom-Json', 'Write-Output', 'Write-Host', - - # CIPP data access (read-only) - 'New-CIPPDbRequest', 'Get-CIPPDbItem', 'Get-CIPPTestData' - ) + # ALLOWLIST: shared with the sandbox runspace so validator and execution never drift. + $AllowedCommands = Get-CippCustomScriptAllowedCommand # Find all command invocations (exclude hashtable key assignments and property access) $Commands = $Ast.FindAll({ @@ -122,4 +111,73 @@ function Test-CustomScriptSecurity { throw "Security violation: .NET type '$typeName' is not allowed. Only these types are permitted: $($AllowedTypes -join ', ')" } } + + # The checks below are not a security boundary (ConstrainedLanguage is) — they catch the + # most common patterns that pass validation but fail under CLM at run time, so the user + # gets a helpful message at save time instead of a confusing error during Run Test. + + # Block [pscustomobject]/[psobject] conversions: hashtable-to-object conversion is not + # supported under ConstrainedLanguage. + $ConvertExpressions = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.ConvertExpressionAst] + }, $true) + + $BlockedConvertTypes = @('pscustomobject', 'psobject', + 'System.Management.Automation.PSObject', 'System.Management.Automation.PSCustomObject') + + foreach ($convert in $ConvertExpressions) { + if ($convert.Type.TypeName.FullName -in $BlockedConvertTypes) { + $lineNumber = $convert.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: [pscustomobject]/[psobject] conversions are not supported (custom tests run in ConstrainedLanguage). Build result rows with Select-Object @{Name='X'; Expression={ ... }} and return a hashtable, e.g. @{ CIPPStatus = 'Info'; CIPPResults = `$rows }." + } + } + + # Block reflection / .NET member access reachable from allowed type literals + # (e.g. [System.String].Assembly.GetType(...)). CLM blocks these at run time anyway. + $ReflectionMembers = @( + 'Assembly', 'Module', 'BaseType', 'DeclaringType', 'GetType', + 'GetMethod', 'GetMethods', 'GetProperty', 'GetProperties', + 'GetField', 'GetFields', 'GetMember', 'GetMembers', + 'GetConstructor', 'GetConstructors', 'InvokeMember' + ) + + $MemberExpressions = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.MemberExpressionAst] + }, $true) + + foreach ($member in $MemberExpressions) { + if ($member.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $member.Member.Value -in $ReflectionMembers) { + $lineNumber = $member.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: reflection / .NET member access ('$($member.Member.Value)') is not allowed in custom tests." + } + } + + # Require a literal -Type on Get-CIPPTestData so the sandbox can pre-fetch its data. + $TestDataCalls = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Get-CIPPTestData' + }, $true) + + foreach ($call in $TestDataCalls) { + for ($i = 0; $i -lt $call.CommandElements.Count; $i++) { + $element = $call.CommandElements[$i] + if ($element -is [System.Management.Automation.Language.CommandParameterAst] -and $element.ParameterName -ieq 'Type') { + $value = if ($element.Argument) { + $element.Argument + } elseif ($i + 1 -lt $call.CommandElements.Count) { + $call.CommandElements[$i + 1] + } else { + $null + } + if ($value -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { + $lineNumber = $call.Extent.StartLineNumber + throw "Security violation at line $lineNumber`: Get-CIPPTestData -Type must be a literal value (for example: -Type 'Users'). Dynamic or computed type names are not supported." + } + } + } + } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 index 6e01d3113d331..bbf7e50cc628f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 @@ -58,7 +58,7 @@ function Invoke-AddCustomScript { } 'SetResultMode' { $RequestedMode = $Request.Body.ResultMode - $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo') + $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo', 'AlwaysInvestigate') if ([string]::IsNullOrWhiteSpace($RequestedMode) -or $RequestedMode -notin $ValidResultModes) { throw "ResultMode must be one of: $($ValidResultModes -join ', ')" } @@ -157,7 +157,7 @@ function Invoke-AddCustomScript { throw "ReturnType must be one of: $($ValidReturnTypes -join ', ')" } - $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo') + $ValidResultModes = @('Auto', 'AlwaysPass', 'AlwaysInfo', 'AlwaysInvestigate') if ($ResultMode -notin $ValidResultModes) { throw "ResultMode must be one of: $($ValidResultModes -join ', ')" } From f51793790eb1a45e21e77370195e618b78fb17a9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:49:03 +0800 Subject: [PATCH 056/150] API client RBAC checks before creation --- .../Test-CippApiClientRoleGrant.ps1 | 113 ++++++++++++++++++ .../CIPP/Settings/Invoke-ExecApiClient.ps1 | 49 ++++++++ 2 files changed, 162 insertions(+) create mode 100644 Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 new file mode 100644 index 0000000000000..eeb49beeb8687 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 @@ -0,0 +1,113 @@ +function Test-CippApiClientRoleGrant { + <# + .SYNOPSIS + Validates that the caller of an API client management action is permitted to + create, modify, reset, or delete an API client holding the supplied role(s). + + .DESCRIPTION + Prevents privilege escalation through the ApiClients table. The ExecApiClient + endpoint is gated at CIPP.Extension.ReadWrite (editor-grantable), but the role + assigned to an API client becomes that client's effective privilege at request + time (see Test-CIPPAccess). Without this check an editor could mint a client + with the 'superadmin' role, or reset the secret of an existing superadmin + client, and escalate. + + A caller may only manage a client whose effective permissions are a subset of + the caller's own effective permissions. Superadmins may grant any role. Roles + are compared by computed permission set (built-in and custom), matching exactly + how Test-CIPPAccess evaluates an API client (single role, no base-role ceiling). + + .PARAMETER Request + The HTTP request, used to resolve the caller's roles. Handles both interactive + user principals and API-client principals. + + .PARAMETER Role + One or more roles to validate, e.g. the requested new role and the existing + client's current role. An empty/missing role is treated as the runtime + 'cipp-api' fallback that Test-CIPPAccess applies to roleless clients. + + .OUTPUTS + [pscustomobject] with Allowed [bool] and Message [string]. Fails closed. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Request, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Role + ) + + function New-Denial { + param([string]$Message) + [pscustomobject]@{ Allowed = $false; Message = $Message } + } + + # Resolve the caller's roles. Mirror Test-CIPPAccess's principal detection so this + # works whether the caller is an interactive user or an API client. + try { + if ($Request.Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Request.Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $CallerClient = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name' + if ($CallerClient.Role) { + $CallerRoles = @($CallerClient.Role) + } else { + $CallerRoles = @('cipp-api') + } + } else { + $CallerRoles = @(Get-CIPPAccessRole -Request $Request) + } + } catch { + return (New-Denial "Unable to resolve your roles for authorization: $($_.Exception.Message)") + } + + if (-not $CallerRoles -or $CallerRoles.Count -eq 0) { + return (New-Denial 'Unable to determine your roles; cannot authorize this API client operation.') + } + + # Superadmin may grant or manage any role. + if ($CallerRoles -contains 'superadmin') { + return [pscustomobject]@{ Allowed = $true; Message = $null } + } + + $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly') + $CallerPermissions = @(Get-CippAllowedPermissions -UserRoles $CallerRoles) + + # Normalize: a roleless client resolves to the 'cipp-api' fallback at request time, + # so validate against that to mirror real client evaluation and stay future-proof. + $TargetRoles = @($Role | ForEach-Object { + if ([string]::IsNullOrWhiteSpace($_)) { 'cipp-api' } else { $_.Trim() } + } | Sort-Object -Unique) + + foreach ($TargetRole in $TargetRoles) { + # anonymous/authenticated are SWA placeholder roles, never valid client roles. + if (@('anonymous', 'authenticated') -contains $TargetRole) { + return (New-Denial "The role '$TargetRole' cannot be assigned to an API client.") + } + + # Confirm the role exists. 'cipp-api' is an implicit runtime fallback and may + # legitimately not be present in the CustomRoles table, so it is exempt. + if ($DefaultRoles -notcontains $TargetRole -and $TargetRole -ne 'cipp-api') { + try { + $null = Get-CIPPRolePermissions -RoleName $TargetRole + } catch { + return (New-Denial "The role '$TargetRole' does not exist.") + } + } + + # Effective permissions a client holding this role would receive, computed the + # same way Test-CIPPAccess evaluates an API client (single role, no base ceiling). + $RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole)) + $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ }) + + if ($Escalation.Count -gt 0) { + return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.") + } + } + + return [pscustomobject]@{ Allowed = $true; Message = $null } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 index 4e54df51a9dbf..2f6652f7bd7a0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 @@ -30,6 +30,33 @@ function Invoke-ExecApiClient { } 'AddUpdate' { $Results = [System.Collections.Generic.List[object]]::new() + + # Authorize the role assignment BEFORE any side effects (app registration / + # secret creation). A caller may only assign a role whose effective + # permissions are a subset of their own, and may only modify an existing + # client whose current role is likewise within their grant. This blocks + # privilege escalation via the ApiClients table (e.g. editor -> superadmin). + $RequestedRole = [string]$Request.Body.Role.value + $RolesToAuthorize = [System.Collections.Generic.List[string]]::new() + $RolesToAuthorize.Add($RequestedRole) + $ExistingClientForAuth = $null + $AuthClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId + if ($AuthClientId) { + $ExistingClientForAuth = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$($AuthClientId)'" + if ($ExistingClientForAuth) { + $RolesToAuthorize.Add([string]$ExistingClientForAuth.Role) + } + } + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role $RolesToAuthorize + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client role assignment: $($RoleGrant.Message)" -Sev 'Warning' + $Body = @(@{ + resultText = $RoleGrant.Message + state = 'error' + }) + break + } + if ($Request.Body.ClientId -or $Request.Body.AppName) { $ClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId $AddUpdateSuccess = $false @@ -239,6 +266,18 @@ function Invoke-ExecApiClient { state = 'error' } } else { + # Block resetting the secret of a client whose role outranks the caller; + # otherwise an editor could harvest a working superadmin secret. + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role ([string]$Client.Role) + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client secret reset for $($Request.Body.ClientId): $($RoleGrant.Message)" -Sev 'Warning' + $Results = @{ + resultText = $RoleGrant.Message + state = 'error' + } + $Body = @($Results) + break + } $ApiConfig = New-CIPPAPIConfig -ResetSecret -AppId $Request.Body.ClientId -Headers $Request.Headers if ($ApiConfig.ApplicationSecret) { @@ -294,6 +333,16 @@ function Invoke-ExecApiClient { try { if ($Request.Body.ClientId) { $ClientId = $Request.Body.ClientId.value ?? $Request.Body.ClientId + # Block deleting a client whose role outranks the caller (tamper/DoS). + $ExistingClientForAuth = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$($ClientId)'" + if ($ExistingClientForAuth) { + $RoleGrant = Test-CippApiClientRoleGrant -Request $Request -Role ([string]$ExistingClientForAuth.Role) + if (-not $RoleGrant.Allowed) { + Write-LogMessage -headers $Request.Headers -API 'ExecApiClient' -message "Blocked API client deletion for $($ClientId): $($RoleGrant.Message)" -Sev 'Warning' + $Body = @{ Results = $RoleGrant.Message } + break + } + } if ($Request.Body.RemoveAppReg -eq $true) { Write-Information "Deleting API Client: $ClientId from Entra" $App = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$($ClientId)'&`$select=id,appId,web" -NoAuthCheck $true -asapp $true From 0a3409a5fb37c846f4b005ee6f24fd4cfd9a3be9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:50:01 +0800 Subject: [PATCH 057/150] Update FeatureFlags.json --- Config/FeatureFlags.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index 4dd7bbd1132ff..bd8fe152ce5e8 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -48,7 +48,7 @@ "Id": "CopilotAI", "Name": "Copilot & AI", "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", - "Enabled": true, + "Enabled": false, "AllowUserToggle": false, "Timers": [], "Endpoints": [ @@ -67,7 +67,7 @@ "/copilot/reports/copilot-usage", "/copilot/reports/copilot-trend" ], - "Hidden": false + "Hidden": true }, { "Id": "MCPServer", From a2dec4281bf78f9c9d4eb0c583397d742ff5c704 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:59:10 +0800 Subject: [PATCH 058/150] Intune Policy sync fixes --- .../Public/New-CIPPIntuneTemplate.ps1 | 10 +- .../CIPPCore/Public/New-CIPPTemplateRun.ps1 | 190 +++++++++--------- 2 files changed, 109 insertions(+), 91 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 index fbce430ac03c3..ecbb09963557f 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneTemplate.ps1 @@ -38,7 +38,15 @@ function New-CIPPIntuneTemplate { } 'managedAppPolicies' { $Type = 'AppProtection' - $Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($urlname)('$($ID)')" -tenantid $TenantFilter + $AppProtectionUrl = switch (($ODataType -replace '#microsoft.graph.', '')) { + 'androidManagedAppProtection' { 'androidManagedAppProtections' } + 'iosManagedAppProtection' { 'iosManagedAppProtections' } + 'windowsManagedAppProtection' { 'windowsManagedAppProtections' } + 'mdmWindowsInformationProtectionPolicy' { 'mdmWindowsInformationProtectionPolicies' } + 'targetedManagedAppConfiguration' { 'targetedManagedAppConfigurations' } + default { 'managedAppPolicies' } + } + $Template = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/$($AppProtectionUrl)('$($ID)')" -tenantid $TenantFilter $DisplayName = $Template.displayName $TemplateJson = ConvertTo-Json -InputObject $Template -Depth 100 -Compress } diff --git a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 index 530a45c29fe5f..88b2a0bcf6619 100644 --- a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 @@ -248,108 +248,118 @@ function New-CIPPTemplateRun { } 'intunecompliance' { Write-Information "Create Intune Compliance Policy Templates for $TenantFilter" - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$top=999' -tenantid $TenantFilter | ForEach-Object { - $Policy = $_ - $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $_) - $ExistingPolicy = $ExistingTemplates | Where-Object { $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 - if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { - "Intune Compliance Policy $($_.DisplayName) found, SHA matches, skipping template creation" - continue - } + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$top=999' -tenantid $TenantFilter + foreach ($Policy in $Policies) { + try { + $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { + "Intune Compliance Policy $($Policy.displayName) found, SHA matches, skipping template creation" + continue + } - $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'deviceCompliancePolicies' -ID $Policy.id - if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { - "Intune Compliance Policy $($Template.DisplayName) found, updating template" - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $ExistingPolicy.GUID - } | ConvertTo-Json -Compress + $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'deviceCompliancePolicies' -ID $Policy.id + if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { + "Intune Compliance Policy $($Template.DisplayName) found, updating template" + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $ExistingPolicy.GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = $ExistingPolicy.GUID - PartitionKey = 'IntuneTemplate' - Package = $ExistingPolicy.Package - GUID = $ExistingPolicy.GUID - SHA = $Hash - Source = $ExistingPolicy.Source - } -Force - } else { - "Intune Compliance Policy $($Template.DisplayName) not found in existing templates, creating new template" - $GUID = (New-Guid).GUID - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $GUID - } | ConvertTo-Json -Compress + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = $ExistingPolicy.GUID + PartitionKey = 'IntuneTemplate' + Package = $ExistingPolicy.Package + GUID = $ExistingPolicy.GUID + SHA = $Hash + Source = $ExistingPolicy.Source + } -Force + } else { + "Intune Compliance Policy $($Template.DisplayName) not found in existing templates, creating new template" + $GUID = (New-Guid).GUID + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = "$GUID" - PartitionKey = 'IntuneTemplate' - SHA = $Hash - GUID = "$GUID" - Source = $TenantFilter - } -Force + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = "$GUID" + PartitionKey = 'IntuneTemplate' + SHA = $Hash + GUID = "$GUID" + Source = $TenantFilter + } -Force + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + "Failed to create a template of the Intune Compliance Policy with ID: $($Policy.id). Error: $ErrorMessage" } } } 'intuneprotection' { Write-Information "Create Intune Protection Policy Templates for $TenantFilter" - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies?$top=999' -tenantid $TenantFilter | ForEach-Object { - $Policy = $_ - $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $_) - $ExistingPolicy = $ExistingTemplates | Where-Object { $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 - if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { - "Intune Protection Policy $($_.DisplayName) found, SHA matches, skipping template creation" - continue - } + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies?$top=999' -tenantid $TenantFilter + foreach ($Policy in $Policies) { + try { + $Hash = Get-StringHash -String (ConvertTo-Json -Depth 100 -Compress -InputObject $Policy) + $ExistingPolicy = $ExistingTemplates | Where-Object { $_.PartitionKey -eq 'IntuneTemplate' -and $Policy.displayName -eq $_.DisplayName -and $_.Source -eq $TenantFilter } | Select-Object -First 1 + if ($ExistingPolicy -and $ExistingPolicy.SHA -eq $Hash) { + "Intune Protection Policy $($Policy.displayName) found, SHA matches, skipping template creation" + continue + } - $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'managedAppPolicies' -ID $Policy.id - if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { - "Intune Protection Policy $($Template.DisplayName) found, updating template" - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $ExistingPolicy.GUID - } | ConvertTo-Json -Compress + $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName 'managedAppPolicies' -ID $Policy.id -ODataType $Policy.'@odata.type' + if ($ExistingPolicy -and $ExistingPolicy.PartitionKey -eq 'IntuneTemplate') { + "Intune Protection Policy $($Template.DisplayName) found, updating template" + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $ExistingPolicy.GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = $ExistingPolicy.GUID - PartitionKey = 'IntuneTemplate' - Package = $ExistingPolicy.Package - SHA = $Hash - GUID = $ExistingPolicy.GUID - Source = $ExistingPolicy.Source - } -Force - } else { - "Intune Protection Policy $($Template.DisplayName) not found in existing templates, creating new template" - $GUID = (New-Guid).GUID - $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $GUID - } | ConvertTo-Json -Compress + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = $ExistingPolicy.GUID + PartitionKey = 'IntuneTemplate' + Package = $ExistingPolicy.Package + SHA = $Hash + GUID = $ExistingPolicy.GUID + Source = $ExistingPolicy.Source + } -Force + } else { + "Intune Protection Policy $($Template.DisplayName) not found in existing templates, creating new template" + $GUID = (New-Guid).GUID + $object = [PSCustomObject]@{ + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $GUID + } | ConvertTo-Json -Compress - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = "$GUID" - PartitionKey = 'IntuneTemplate' - SHA = $Hash - GUID = "$GUID" - Source = $TenantFilter - } -Force + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$object" + RowKey = "$GUID" + PartitionKey = 'IntuneTemplate' + SHA = $Hash + GUID = "$GUID" + Source = $TenantFilter + } -Force + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + "Failed to create a template of the Intune Protection Policy with ID: $($Policy.id). Error: $ErrorMessage" } } } From 351c7f316e26797b1ee1eb6ac1fa453e9a30aed5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 22 Jun 2026 10:01:27 -0400 Subject: [PATCH 059/150] fix: hardcode owner/repo in release notes api --- .../Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 index 8769763e64351..ad9a21118d497 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 @@ -13,8 +13,8 @@ [CmdletBinding()] param($Request, $TriggerMetadata) - $Owner = $Request.Query.Owner - $Repository = $Request.Query.Repository + $Owner = 'KelvinTegelaar' + $Repository = 'CIPP' if (-not $Owner) { throw 'Owner parameter is required to retrieve release notes.' @@ -35,7 +35,7 @@ $Latest = $false if ($Rows) { $Releases = ConvertFrom-Json -InputObject $Rows.GitHubReleases -Depth 10 - $CurrentVersion = [semver]$global:CippVersion + $CurrentVersion = [semver]($env:CippVersion ?? $env:APP_VERSION) $CurrentMajorMinor = "$($CurrentVersion.Major).$($CurrentVersion.Minor)" foreach ($Release in $Releases) { From 9002cca6576e4ed12049bb689e98f1e34259656c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 22 Jun 2026 10:18:08 -0400 Subject: [PATCH 060/150] fix: throw on invalid nextLink url --- .../Public/GraphRequests/Get-GraphRequestList.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 index 134f8aeeeb79a..1e27e7b39b312 100644 --- a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 +++ b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 @@ -352,7 +352,17 @@ function Get-GraphRequestList { if (!$QueueThresholdExceeded) { #nextLink should ONLY be used in direct calls with manual pagination. It should not be used in queueing - if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') { $GraphRequest.uri = $nextLink } + if ($ManualPagination.IsPresent -and $nextLink -match '^https://.+') { + try { + $ParsedNextLink = [System.Uri]$nextLink + if ($ParsedNextLink.Host -ne 'graph.microsoft.com') { + throw "Invalid nextLink host: $($ParsedNextLink.Host)" + } + } catch { + throw "Invalid nextLink URL: $nextLink" + } + $GraphRequest.uri = $nextLink + } $GraphRequestResults = New-GraphGetRequest @GraphRequest -Caller $Caller -ErrorAction Stop $GraphRequestResults = $GraphRequestResults | Select-Object *, @{n = 'Tenant'; e = { $TenantFilter } }, @{n = 'CippStatus'; e = { 'Good' } } From aefefdac075d792931f76f52e84da3a3f0d1a9e1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 22 Jun 2026 13:27:16 -0400 Subject: [PATCH 061/150] chore: bump version to 10.5.4 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 964ab6d101ea2..3d53421632b03 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.3", + "defaultVersion": "10.5.4", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index 1e9c35fac8568..927fa80836fbe 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.3 +10.5.4 From 5549d62be0f3f9263069ffc8546261eb4fc5c338 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:52:36 +0800 Subject: [PATCH 062/150] Table cleanup fixes --- .../Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 index 3f7ceb6009e5e..2391968a94f7a 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 @@ -16,7 +16,7 @@ function Push-TableCleanupTask { if ($Table) { Write-Information "Deleting table $($Table.Context.TableName)" try { - Remove-AzDataTable -Context $Table.Context -Force + Remove-AzDataTable -Context $Table.Context } catch { #Write-LogMessage -API 'TableCleanup' -message "Failed to delete table $($Table.Context.TableName)" -sev Error -LogData (Get-CippException -Exception $_) } From eac806c72ac6ec4520b272898c27c5e22bb9eb01 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:44:26 +0800 Subject: [PATCH 063/150] IP lookup and Audit Log Processing improvements --- .../New-CIPPAuditLogSearchResultsCache.ps1 | 15 ++ .../Public/Get-CIPPGeoIPLocationBatch.ps1 | 149 ++++++++++++++++++ .../Webhooks/Test-CIPPAuditLogRules.ps1 | 148 +++++++++-------- .../HTTP Functions/Invoke-ListKnownIPDb.ps1 | 9 +- 4 files changed, 236 insertions(+), 85 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 index 3422175d1233f..4a457e98b240b 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 @@ -72,6 +72,21 @@ function New-CIPPAuditLogSearchResultsCache { Add-CIPPAzDataTableEntity @CacheWebhooksTable -Entity $cacheEntity -Force } Write-Information "Successfully cached search ID: $($SearchId) for tenant: $TenantFilter" + + try { + $PrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($sr in $searchResults) { + $cip = $sr.auditData.clientip + if (![string]::IsNullOrWhiteSpace($cip)) { $null = $PrefetchIPs.Add(([string]$cip).Trim()) } + } + if ($PrefetchIPs.Count -gt 0) { + $null = Get-CIPPGeoIPLocationBatch -IPs @($PrefetchIPs) + Write-Information "Geo prefetch: warmed cache for $($PrefetchIPs.Count) distinct IP(s) (search $SearchId)" + } + } catch { + Write-Information "Geo prefetch during ingestion failed for search ${SearchId}: $($_.Exception.Message)" + } + try { $FailedDownloadsTable = Get-CippTable -TableName 'FailedAuditLogDownloads' $failedEntities = Get-CIPPAzDataTableEntity @FailedDownloadsTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" diff --git a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 new file mode 100644 index 0000000000000..7b46b9292b9a0 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 @@ -0,0 +1,149 @@ +function Get-CIPPGeoIPLocationBatch { + <# + .SYNOPSIS + Resolve many IPs to geo-location in one pass, warming the knownlocationdbv2 cache. + .DESCRIPTION + Normalizes + de-dupes the input IPs and drops redacted / reserved / private / link-local + addresses (never geolocatable). Remaining IPs are seeded from knownlocationdbv2 (fresh + entries only); cache misses are resolved in bulk via the geoipdb /GetIPInfoBatch endpoint + (which proxies ip-api's batch API, 100 IPs per upstream request). Successful results are + written back to knownlocationdbv2 and cachegeoip so later processing is a cache hit. + + Returns a hashtable keyed by normalized IP -> flattened location object + @{ CountryOrRegion; City; Proxy; Hosting; ASName }. Failed/unknown lookups are NOT cached + (no poisoning) and are absent from the returned hashtable. + + Used both at ingestion (warm the cache up front) and as a per-batch prefetch in the audit + log processor (so the per-record loop is a pure in-memory lookup). + .PARAMETER IPs + IP addresses to resolve. Duplicates, reserved IPs and ports are handled automatically. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [string[]]$IPs + ) + + # 20s timeout, up to 3 attempts for the geoip HTTP calls. The short timeout stops a single + # hung IP from stalling the whole batch; the retries ride out transient blips before we give up. + function Invoke-GeoRetry { + param([string]$Uri, [string]$Method = 'GET', $Body, [string]$ContentType, [int]$Retries = 3, [int]$TimeoutSec = 20) + $lastErr = $null + for ($attempt = 1; $attempt -le $Retries; $attempt++) { + try { + if ($PSBoundParameters.ContainsKey('Body')) { + return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -Body $Body -ContentType $ContentType -TimeoutSec $TimeoutSec + } else { + return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -TimeoutSec $TimeoutSec + } + } catch { + $lastErr = $_ + if ($attempt -lt $Retries) { Start-Sleep -Milliseconds (300 * $attempt) } + } + } + throw $lastErr + } + + $ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' + $ReservedIpRegex = [regex]::new( + '^(?:10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|100\.(?:6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.|::1?$|fe[89ab]|f[cd]|ff)', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + + $Result = @{} + + # Normalize (strip :port / brackets), drop redacted + reserved, de-dupe + $Distinct = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($ip in $IPs) { + if ([string]::IsNullOrWhiteSpace($ip)) { continue } + $clean = $ClientIpRegex.Replace(([string]$ip).Trim(), '$1') -replace '[\[\]]', '' + if ([string]::IsNullOrWhiteSpace($clean) -or $clean -match '[X]+') { continue } + if ($ReservedIpRegex.IsMatch($clean)) { continue } + $null = $Distinct.Add($clean) + } + if ($Distinct.Count -eq 0) { return $Result } + + $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' + $ValidAfter = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + # 1) Seed from knownlocationdbv2 (fresh, non-Unknown entries); collect the misses + $ToResolve = [System.Collections.Generic.List[string]]::new() + foreach ($ip in $Distinct) { + $cached = Get-CIPPAzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$ip' and Timestamp ge datetime'$ValidAfter'" + if ($cached -and $cached.CountryOrRegion -and $cached.CountryOrRegion -ne 'Unknown') { + $Result[$ip] = [pscustomobject]@{ + CountryOrRegion = $cached.CountryOrRegion + City = $cached.City + Proxy = $cached.Proxy + Hosting = $cached.Hosting + ASName = $cached.ASName + } + } else { + $ToResolve.Add($ip) + } + } + if ($ToResolve.Count -eq 0) { return $Result } + + # 2) Bulk-resolve the misses via geoipdb /GetIPInfoBatch (chunk to 100 to bound payloads) + $CacheGeoIPTable = Get-CippTable -TableName 'cachegeoip' + $KnownEntities = [System.Collections.Generic.List[object]]::new() + $CacheGeoEntities = [System.Collections.Generic.List[object]]::new() + + for ($i = 0; $i -lt $ToResolve.Count; $i += 100) { + $chunk = @($ToResolve[$i..([Math]::Min($i + 99, $ToResolve.Count - 1))]) + $payload = '[' + (($chunk | ForEach-Object { $_ | ConvertTo-Json }) -join ',') + ']' + $resp = $null + try { + $resp = Invoke-GeoRetry -Uri 'https://geoipdb.azurewebsites.net/api/GetIPInfoBatch' -Method POST -Body $payload -ContentType 'application/json' + } catch { + #Write-LogMessage -API GeoIPLocation -message "Bulk geoip lookup failed, falling back to single lookups for $($chunk.Count) IP(s): $($_.Exception.Message)" -sev Warning + $fb = [System.Collections.Generic.List[object]]::new() + foreach ($ip in $chunk) { + try { + $s = Invoke-GeoRetry -Uri "https://geoipdb.azurewebsites.net/api/GetIPInfo?IP=$ip" + if ($s -and $s.status -ne 'fail') { $fb.Add([pscustomobject]@{ query = $ip; status = 'success'; countryCode = $s.countryCode; city = $s.city; proxy = $s.proxy; hosting = $s.hosting; asname = $s.asname }) } + } catch { } + } + $resp = $fb + } + foreach ($r in $resp) { + $ip = [string]$r.query + if ([string]::IsNullOrWhiteSpace($ip) -or $r.status -ne 'success') { continue } + $loc = [pscustomobject]@{ + CountryOrRegion = if ($r.countryCode) { $r.countryCode } else { 'Unknown' } + City = if ($r.city) { $r.city } else { 'Unknown' } + Proxy = if ($null -ne $r.proxy) { $r.proxy } else { 'Unknown' } + Hosting = if ($null -ne $r.hosting) { $r.hosting } else { 'Unknown' } + ASName = if ($r.asname) { $r.asname } else { 'Unknown' } + } + $Result[$ip] = $loc + # Only cache real results - never persist Unknown (no poisoning, matches single path) + if ($loc.CountryOrRegion -ne 'Unknown') { + $KnownEntities.Add(@{ + PartitionKey = 'ip' + RowKey = $ip + CountryOrRegion = "$($loc.CountryOrRegion)" + City = "$($loc.City)" + Proxy = "$($loc.Proxy)" + Hosting = "$($loc.Hosting)" + ASName = "$($loc.ASName)" + }) + $CacheGeoEntities.Add(@{ + PartitionKey = 'IP' + RowKey = $ip + Data = [string]($r | ConvertTo-Json -Compress) + }) + } + } + } + + # 3) Batch-write the caches + if ($KnownEntities.Count -gt 0) { + try { $null = Add-CIPPAzDataTableEntity @LocationTable -Entity @($KnownEntities) -Force } + catch { Write-LogMessage -API GeoIPLocation -message "Failed to cache $($KnownEntities.Count) bulk geo results: $($_.Exception.Message)" -sev Warning } + } + if ($CacheGeoEntities.Count -gt 0) { + try { $null = Add-AzDataTableEntity @CacheGeoIPTable -Entity @($CacheGeoEntities) -Force } catch {} + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index de2282b91f521..8e16d05cc436b 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -10,6 +10,11 @@ function Test-CIPPAuditLogRules { try { # 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:ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' + $script:ReservedIpRegex = [regex]::new( + '^(?:10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|100\.(?:6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.|::1?$|fe[89ab]|f[cd]|ff)', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) $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})' @@ -419,7 +424,32 @@ function Test-CIPPAuditLogRules { $ExcludedUsers = Get-CIPPAzDataTableEntity @AuditLogUserExclusions -Filter "PartitionKey eq '$TenantFilter'" if ($LogCount -gt 0) { - $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' + $TrustedIPEntries = Get-CIPPAzDataTableEntity @TrustedIPTable -Filter "((PartitionKey eq '$TenantFilter') or (PartitionKey eq 'AllTenants')) and state eq 'Trusted'" + $TrustedIPLookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($TrustedEntry in $TrustedIPEntries) { + if (![string]::IsNullOrEmpty($TrustedEntry.RowKey)) { + $null = $TrustedIPLookup.Add([string]$TrustedEntry.RowKey) + } + } + + $GeoPrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($AuditRecord in $SearchResults) { + $cip = $AuditRecord.auditData.clientip + if ([string]::IsNullOrEmpty($cip) -or $cip -match '[X]+') { continue } + $cip = $script:ClientIpRegex.Replace([string]$cip, '$1') -replace '[\[\]]', '' + if ($TrustedIPLookup.Contains($cip) -or $script:ReservedIpRegex.IsMatch($cip)) { continue } + $null = $GeoPrefetchIPs.Add($cip) + } + $GeoLookup = @{} + if ($GeoPrefetchIPs.Count -gt 0) { + try { + $GeoLookup = Get-CIPPGeoIPLocationBatch -IPs @($GeoPrefetchIPs) + Write-Information "Geo prefetch: $($GeoLookup.Count)/$($GeoPrefetchIPs.Count) distinct IPs resolved" + } catch { + #Write-Warning "Geo prefetch failed, falling back to per-record lookup: $($_.Exception.Message)" + } + } + $ProcessedData = foreach ($AuditRecord in $SearchResults) { $RecordStartTime = Get-Date Write-Information "Processing RowKey $($AuditRecord.id) - $($TenantFilter)." @@ -471,68 +501,35 @@ function Test-CIPPAuditLogRules { if (![string]::IsNullOrEmpty($Data.clientip) -and $Data.clientip -notmatch '[X]+') { # Ignore IP addresses that have been redacted - $IPRegex = '^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' - $Data.clientip = $Data.clientip -replace $IPRegex, '$1' -replace '[\[\]]', '' - - # Check if IP is on trusted IP list - $TrustedIP = Get-CIPPAzDataTableEntity @TrustedIPTable -Filter "((PartitionKey eq '$TenantFilter') or (PartitionKey eq 'AllTenants')) and RowKey eq '$($Data.clientip)' and state eq 'Trusted'" - if ($TrustedIP) { - #write-warning "IP $($Data.clientip) is trusted" - $Trusted = $true - } + $Data.clientip = $script:ClientIpRegex.Replace([string]$Data.clientip, '$1') -replace '[\[\]]', '' + $Trusted = $TrustedIPLookup.Contains([string]$Data.clientip) + $IsReserved = $script:ReservedIpRegex.IsMatch([string]$Data.clientip) if (!$Trusted) { - $CacheLookupStartTime = Get-Date - $Location = Get-AzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$($Data.clientIp)'" | Select-Object -ExcludeProperty Tenant - $CacheLookupEndTime = Get-Date - $CacheLookupSeconds = ($CacheLookupEndTime - $CacheLookupStartTime).TotalSeconds - Write-Warning "Cache lookup for IP $($Data.clientip) took $CacheLookupSeconds seconds" - - if ($Location) { - $Country = $Location.CountryOrRegion - $City = $Location.City - $Proxy = $Location.Proxy - $hosting = $Location.Hosting - $ASName = $Location.ASName + if ($IsReserved) { + $Data.CIPPGeoLocation = 'Unknown' + $Data.CIPPBadRepIP = 'Unknown' + $Data.CIPPHostedIP = 'Unknown' + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = $null + $HasLocationData = $true } else { - try { - $IPLookupStartTime = Get-Date - $Location = Get-CIPPGeoIPLocation -IP $Data.clientip - $IPLookupEndTime = Get-Date - $IPLookupSeconds = ($IPLookupEndTime - $IPLookupStartTime).TotalSeconds - Write-Warning "IP lookup for $($Data.clientip) took $IPLookupSeconds seconds" - } catch { - #write-warning "Unable to get IP location for $($Data.clientip): $($_.Exception.Message)" - } - $Country = if ($Location.countryCode) { $Location.countryCode } else { 'Unknown' } - $City = if ($Location.city) { $Location.city } else { 'Unknown' } - $Proxy = if ($Location.proxy -ne $null) { $Location.proxy } else { 'Unknown' } - $hosting = if ($Location.hosting -ne $null) { $Location.hosting } else { 'Unknown' } - $ASName = if ($Location.asname) { $Location.asname } else { 'Unknown' } - $IP = $Data.ClientIP - $LocationInfo = @{ - RowKey = [string]$Data.clientip - PartitionKey = 'ip' - Tenant = [string]$TenantFilter - CountryOrRegion = "$Country" - City = "$City" - Proxy = "$Proxy" - Hosting = "$hosting" - ASName = "$ASName" - } - - try { - $null = Add-CIPPAzDataTableEntity @LocationTable -Entity $LocationInfo -Force - } catch { - #write-warning "Failed to add location info for $($Data.clientip) to cache: $($_.Exception.Message)" - + $Loc = $GeoLookup[[string]$Data.clientip] + if ($Loc) { + $Data.CIPPGeoLocation = $Loc.CountryOrRegion + $Data.CIPPBadRepIP = $Loc.Proxy + $Data.CIPPHostedIP = $Loc.Hosting + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = ($Loc | ConvertTo-Json -Compress -Depth 10) + $HasLocationData = $true + } else { + $Data.CIPPGeoLocation = 'Unknown' + $Data.CIPPBadRepIP = 'Unknown' + $Data.CIPPHostedIP = 'Unknown' + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = $null + $HasLocationData = $false } } - $Data.CIPPGeoLocation = $Country - $Data.CIPPBadRepIP = $Proxy - $Data.CIPPHostedIP = $hosting - $Data.CIPPIPDetected = $IP - $Data.CIPPLocationInfo = ($Location | ConvertTo-Json -Compress -Depth 10) - $HasLocationData = $true } } $Data.AuditRecord = [string]($RootProperties | ConvertTo-Json -Compress -Depth 10) @@ -543,19 +540,17 @@ function Test-CIPPAuditLogRules { Write-LogMessage -API 'Webhooks' -message 'Error Processing Audit Log Data' -LogData (Get-CippException -Exception $_) -sev Error -tenant $TenantFilter } - Write-Information "Removing row $($AuditRecord.id) from cache" try { - Write-Information 'Removing processed rows from cache' - $RowEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$($AuditRecord.id)'" - Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowEntity -Force - Write-Information "Removed row $($AuditRecord.id) from cache" + $null = Remove-AzDataTableEntity -Force @CacheWebhooksTable -Entity ([pscustomobject]@{ + PartitionKey = $TenantFilter + RowKey = [string]$AuditRecord.id + }) } catch { - Write-Information "Error removing rows from cache: $($_.Exception.Message)" - } finally { - $RecordEndTime = Get-Date - $RecordSeconds = ($RecordEndTime - $RecordStartTime).TotalSeconds - Write-Warning "Task took $RecordSeconds seconds for RowKey $($AuditRecord.id)" + Write-Information "Error removing row $($AuditRecord.id) from cache: $($_.Exception.Message)" } + $RecordEndTime = Get-Date + $RecordSeconds = ($RecordEndTime - $RecordStartTime).TotalSeconds + Write-Warning "Task took $RecordSeconds seconds for RowKey $($AuditRecord.id)" } #write-warning "Processed Data: $(($ProcessedData | Measure-Object).Count) - This should be higher than 0 in many cases, because the where object has not run yet." #write-warning "Creating filters - $(($ProcessedData.operation | Sort-Object -Unique) -join ',') - $($TenantFilter)" @@ -688,14 +683,13 @@ function Test-CIPPAuditLogRules { } try { - Write-Information 'Removing processed rows from cache' - foreach ($Row in $Rows) { - if ($Row.id) { - $RowEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$($Row.id)'" - if ($RowEntity) { - Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowEntity -Force - Write-Information "Removed row $($Row.id) from cache at final pass." - } + $RowIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Rows.id | Where-Object { $_ })) + if ($RowIds.Count -gt 0) { + $CachedRows = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter'" + $RowsToRemove = @($CachedRows | Where-Object { $RowIds.Contains([string]$_.RowKey) }) + if ($RowsToRemove.Count -gt 0) { + Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowsToRemove -Force + Write-Information "Removed $($RowsToRemove.Count) processed rows from cache" } } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 index acb6deb0c7038..12ac2b9900f76 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 @@ -9,16 +9,9 @@ function Invoke-ListKnownIPDb { #> [CmdletBinding()] param($Request, $TriggerMetadata) - # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter - if (-not [string]::IsNullOrEmpty($TenantFilter)) { - $TenantFilter = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String - } - $Table = Get-CIPPTable -TableName 'knownlocationdbv2' - $Filter = "Tenant eq '$($TenantFilter)'" - $KnownIPDb = Get-CIPPAzDataTableEntity @Table -Filter $Filter + $KnownIPDb = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ip'" return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK From f690b6df4cf479a44b6793e9e2700675c5bbde8f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:50:31 +0800 Subject: [PATCH 064/150] More reliable RG name resolution --- .../Get-CIPPManagedIdentityResourceId.ps1 | 51 +++++++++++++++++++ .../Start-ContainerUpdateCheck.ps1 | 11 ++-- .../Public/Functions/Request-CIPPRestart.ps1 | 8 +-- .../Public/Get-ApplicationInsightsQuery.ps1 | 6 +-- .../Get-CIPPFunctionAppResourceGroup.ps1 | 50 ++++++++++++++++++ .../Set-CIPPOffloadFunctionTriggers.ps1 | 11 +--- .../CIPP/Settings/Invoke-ExecApiClient.ps1 | 24 +-------- .../CIPP/Settings/Invoke-ExecBackendURLs.ps1 | 15 ++---- .../Invoke-ExecContainerManagement.ps1 | 20 ++++---- 9 files changed, 126 insertions(+), 70 deletions(-) create mode 100644 Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 create mode 100644 Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 new file mode 100644 index 0000000000000..312dbc83f8cd7 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 @@ -0,0 +1,51 @@ +function Get-CIPPManagedIdentityResourceId { + <# + .SYNOPSIS + Get the Azure resource ID that the Function App's managed identity belongs to. + .DESCRIPTION + Reads the 'xms_mirid' claim from a managed identity access token. For a system-assigned + identity (which CIPP uses), this claim is the ARM resource ID of the host resource itself + - i.e. the Function App site, including its resource group: + + /subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Web/sites/{site} + + This is the most reliable in-process source for the site's resource group because it is + present in every managed identity token, requires no extra ARM/Graph call, and - unlike + parsing WEBSITE_OWNER_NAME - always names the site's RG rather than the App Service Plan's + webspace RG. + + Note: for a user-assigned identity, xms_mirid points at the userAssignedIdentities resource + instead, which may live in a different RG. Callers that need the site's RG should validate + the returned ID against the expected site (see Get-CIPPFunctionAppResourceGroup). + .PARAMETER ResourceUrl + The Azure resource URL to request the token for. Defaults to Azure Resource Manager. + .EXAMPLE + Get-CIPPManagedIdentityResourceId + Returns e.g. /subscriptions/.../resourcegroups/CIPP-myinstance/providers/Microsoft.Web/sites/cippabcde + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$ResourceUrl = 'https://management.azure.com/' + ) + + $Token = Get-CIPPAzIdentityToken -ResourceUrl $ResourceUrl + if (-not $Token) { + throw 'Could not acquire a managed identity token to read the xms_mirid claim.' + } + + # JWT payload is the second dot-delimited segment, base64url-encoded. + $Parts = $Token.Split('.') + if ($Parts.Count -lt 2) { + throw 'Managed identity token is not a well-formed JWT.' + } + + $Payload = $Parts[1].Replace('-', '+').Replace('_', '/') + switch ($Payload.Length % 4) { + 2 { $Payload += '==' } + 3 { $Payload += '=' } + } + + $Claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json + return $Claims.xms_mirid +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 index 9a171e6069825..bd3b2a1338a34 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 @@ -86,12 +86,11 @@ function Start-ContainerUpdateCheck { # Resolve ARM site details $Subscription = Get-CIPPAzFunctionAppSubId $SiteName = $env:WEBSITE_SITE_NAME - $RGName = $env:WEBSITE_RESOURCE_GROUP - if (-not $RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } + try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } $ImageTag = $env:IMAGE_TAG ?? 'unknown' diff --git a/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 b/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 index 8a3196b268a36..383c91d656564 100644 --- a/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 +++ b/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 @@ -19,13 +19,7 @@ function Request-CIPPRestart { try { $Subscription = Get-CIPPAzFunctionAppSubId $SiteName = $env:WEBSITE_SITE_NAME - $RGName = $env:WEBSITE_RESOURCE_GROUP - if (-not $RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } - } + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName if (-not ($Subscription -and $RGName -and $SiteName)) { throw 'Azure App Service details could not be determined from environment' } diff --git a/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 b/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 index 3284012a7a4a8..669cf204bcde2 100644 --- a/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 +++ b/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 @@ -10,11 +10,7 @@ function Get-ApplicationInsightsQuery { } $SubscriptionId = Get-CIPPAzFunctionAppSubId - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } + $RGName = Get-CIPPFunctionAppResourceGroup $AppInsightsName = $env:WEBSITE_SITE_NAME $Body = @{ diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 new file mode 100644 index 0000000000000..fbf664a6261b5 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPFunctionAppResourceGroup { + <# + .SYNOPSIS + Resolve the resource group that the CIPP Function App site lives in. + .DESCRIPTION + Returns the resource group of the running Function App, using authoritative sources only: + + 1. WEBSITE_RESOURCE_GROUP - platform-injected, the site's actual RG. Free, no decode. + 2. xms_mirid claim from the managed identity token - the site's own ARM resource ID, + present even when WEBSITE_RESOURCE_GROUP is empty, needs no extra call or permission. + + The legacy approach of parsing WEBSITE_OWNER_NAME is intentionally NOT used: that string + encodes the App Service Plan's webspace RG, which is frequently different from the site's RG + (e.g. it returns 'DefaultResourceGroup-WEU' or '-m01' for sites whose plan was created + in an auto-generated/other resource group). Writing auth settings, restarting, or querying + the wrong RG is worse than failing, so this throws when no reliable source is available. + .PARAMETER SiteName + The Function App site name to resolve. Defaults to WEBSITE_SITE_NAME. + .EXAMPLE + Get-CIPPFunctionAppResourceGroup + Returns e.g. 'CIPP-myinstance' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + # 1. Platform-injected site resource group - authoritative, zero cost. + if ($env:WEBSITE_RESOURCE_GROUP) { + return $env:WEBSITE_RESOURCE_GROUP + } + + # 2. The managed identity's own token names this site's resource ID (incl. RG). Only trust it + # when it actually points at this Microsoft.Web/sites resource, so a user-assigned identity + # (whose xms_mirid is a userAssignedIdentities resource) falls through rather than returning + # the identity's RG. + try { + $MiRid = Get-CIPPManagedIdentityResourceId + if ($SiteName -and $MiRid -match "(?i)/resourcegroups/(?[^/]+)/providers/Microsoft\.Web/sites/$([regex]::Escape($SiteName))(/|$)") { + return $Matches.RG + } + Write-Information "xms_mirid did not match site '$SiteName': $MiRid" + } catch { + Write-Warning "Could not read resource group from managed identity token: $($_.Exception.Message)" + } + + # 3. No reliable source - fail loudly rather than guess from WEBSITE_OWNER_NAME. + throw "Could not determine the function app resource group for site '$SiteName'. WEBSITE_RESOURCE_GROUP is empty and the managed identity resource ID was unavailable." +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 index ad9995541c285..5b3ed653fe58c 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 @@ -38,16 +38,7 @@ function Set-CIPPOffloadFunctionTriggers { } # Determine resource group - if ($env:WEBSITE_RESOURCE_GROUP) { - $ResourceGroupName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $ResourceGroupName = $Matches.RGName - } else { - throw 'Could not determine resource group. Please provide ResourceGroupName parameter.' - } - } + $ResourceGroupName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName # Define the triggers to disable when offloading is enabled $TargetedTriggers = @( diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 index 2f6652f7bd7a0..ddbda5d5132b1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 @@ -193,19 +193,9 @@ function Invoke-ExecApiClient { } } 'GetAzureConfiguration' { - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } - } $FunctionAppName = $env:WEBSITE_SITE_NAME try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName $APIClients = Get-CippApiAuth -RGName $RGName -FunctionAppName $FunctionAppName $Results = $ApiClients } catch { @@ -220,17 +210,6 @@ function Invoke-ExecApiClient { } 'SaveToAzure' { $TenantId = $env:TenantID - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } - } $FunctionAppName = $env:WEBSITE_SITE_NAME $AllClients = Get-CIPPAzDataTableEntity @Table -Filter 'Enabled eq true' | Where-Object { ![string]::IsNullOrEmpty($_.RowKey) } $ClientIds = $AllClients.RowKey @@ -238,6 +217,7 @@ function Invoke-ExecApiClient { $McpClientIds = @($AllClients | Where-Object { "$($_.MCPAllowed)" -eq 'True' } | ForEach-Object { $_.RowKey }) Write-Information "[ExecApiClient] MCP clients resolved for audiences/scope: $($McpClientIds -join ', ')" try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName Set-CippApiAuth -RGName $RGName -FunctionAppName $FunctionAppName -TenantId $TenantId -ClientIds $ClientIds -McpClientIds $McpClientIds # Advertise the MCP resource scope via App Service PRM so the Claude connector requests diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 index aa2f5733533a4..eed62dcc076cb 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 @@ -13,16 +13,11 @@ function Invoke-ExecBackendURLs { # Write to the Azure Functions log stream. Write-Host 'PowerShell HTTP trigger function processed a request.' - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } + try { + $RGName = Get-CIPPFunctionAppResourceGroup + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } $results = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 index 06075ff149983..5d6e347fc6b16 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 @@ -17,18 +17,18 @@ function Invoke-ExecContainerManagement { # Helper: resolve ARM site details function Get-ContainerSiteInfo { - $info = @{ - Subscription = Get-CIPPAzFunctionAppSubId - SiteName = $env:WEBSITE_SITE_NAME - RGName = $env:WEBSITE_RESOURCE_GROUP + $SiteName = $env:WEBSITE_SITE_NAME + try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } - if (-not $info.RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $info.RGName = $Matches.RGName - } + return @{ + Subscription = Get-CIPPAzFunctionAppSubId + SiteName = $SiteName + RGName = $RGName } - return $info } # Helper: query GHCR for the image at $Tag and return its digest + version label. From 739d3785da1abfc906542a78c6d68ac5989589b5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:09:02 +0800 Subject: [PATCH 065/150] 90 Day cache cleanup --- .../Entrypoints/Timer Functions/Start-TableCleanup.ps1 | 10 ++++++++++ Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 60bce20dc1b05..69879bc6bc841 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -105,6 +105,16 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'knownlocationdbv2' + DataTableProps = @{ + Filter = "PartitionKey eq 'ip' and Timestamp lt datetime'$((Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' diff --git a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 index 7b46b9292b9a0..6d60d71fe1aa8 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 @@ -63,7 +63,7 @@ function Get-CIPPGeoIPLocationBatch { if ($Distinct.Count -eq 0) { return $Result } $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' - $ValidAfter = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $ValidAfter = (Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') # 1) Seed from knownlocationdbv2 (fresh, non-Unknown entries); collect the misses $ToResolve = [System.Collections.Generic.List[string]]::new() From b560d158bffc203890f96511f71b6c6b3b77d47c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:41:36 +0800 Subject: [PATCH 066/150] Move APISpec to config folder --- openapi.json => Config/openapi.json | 0 Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 | 14 ++------------ 2 files changed, 2 insertions(+), 12 deletions(-) rename openapi.json => Config/openapi.json (100%) diff --git a/openapi.json b/Config/openapi.json similarity index 100% rename from openapi.json rename to Config/openapi.json diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 index 341f7eb08f9fb..1dddcca1b4cd4 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 @@ -16,19 +16,9 @@ function Get-CippMcpSpec { return $script:CippMcpSpec } - $Root = $env:CIPPRootPath - if (-not $Root -or -not (Test-Path (Join-Path $Root 'openapi.json'))) { - # Fallback: walk up from this module until openapi.json is found. - $Root = $PSScriptRoot - while ($Root -and -not (Test-Path (Join-Path $Root 'openapi.json'))) { - $Parent = Split-Path $Root -Parent - if (-not $Parent -or $Parent -eq $Root) { $Root = $null; break } - $Root = $Parent - } - } + $SpecPath = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config\openapi.json' - $SpecPath = if ($Root) { Join-Path $Root 'openapi.json' } else { $null } - if (-not $SpecPath -or -not (Test-Path $SpecPath)) { + if (-not (Test-Path $SpecPath)) { throw [pscustomobject]@{ code = -32603; message = 'OpenAPI spec (openapi.json) not found; cannot project MCP tools.' } } From 850f6a2927b2de34b9f79cbbbe4349c2decaeefb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:42:46 +0800 Subject: [PATCH 067/150] New Licence Updates --- Config/ConversionTable.csv | 123 +++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/Config/ConversionTable.csv b/Config/ConversionTable.csv index 92775e6da97b1..9c354ea9db054 100644 --- a/Config/ConversionTable.csv +++ b/Config/ConversionTable.csv @@ -5855,3 +5855,126 @@ Windows Store for Business,WINDOWS_STORE,6470687e-a428-4b7a-bef2-8a291ad947c9,WI Windows Store for Business EDU Faculty,WSFB_EDU_FACULTY,c7e9d9e6-1981-4bf3-bb50-a5bdfaa06fb2,Windows Store for Business EDU Store_faculty,aaa2cd24-5519-450f-a1a0-160750710ca1,Windows Store for Business EDU Store_faculty Workload Identities Premium,Workload_Identities_Premium_CN,73fa80b5-689f-4db9-bbe4-bd414bc41e44,AAD_WRKLDID_P2,7dc0e92d-bf15-401d-907e-0884efe7c760,Azure Active Directory workload identities P2 Workload Identities Premium,Workload_Identities_Premium_CN,73fa80b5-689f-4db9-bbe4-bd414bc41e44,AAD_WRKLDID_P1,84c289f0-efcb-486f-8581-07f44fc9efad,Azure Active Directory workload identities P1 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS,27e196a4-8b80-4930-bd65-53fd28581878,Microsoft Entra Network Controls for Assistive Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS,a9e85e05-1687-4958-8a4c-bdacda2943db,Microsoft Entra ID Governance for Assistive Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK_MANAGEMENT_FOR_AGENTS,004ddfc0-c92f-4b0a-90c5-c60646299d71,Microsoft Purview Insider Risk Management for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFORMATION_PROTECTION_FOR_AGENTS,48478b49-91a1-4ded-94f0-066db80035ca,Microsoft Purview Information Protection for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EDISCOVERY_FOR_AGENTS,92cedcb2-3fb2-40b4-9df4-f9a5603d9631,Microsoft Purview eDiscovery for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DEFENDER_FOR_AI,a1c15058-5559-4c1a-ba05-8040847f91bb,Microsoft Defender for AI +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DATA_LOSS_PREVENTION_FOR_AGENTS,46d3c309-0ba7-461f-9d0e-eaca165794c2,Microsoft Purview Data Loss Prevention for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS,30d56d35-9be2-41c1-b4b8-7a8d6f073152,Microsoft Purview Data Lifecycle Management for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMPLIANCE_MANAGER_FOR_AGENTS,d1f65d05-a302-4861-bd35-da8933ba7655,Microsoft Purview Compliance Manager for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMUNICATION_COMPLIANCE_FOR_AGENTS,135fe762-031a-4e79-bd3c-ac376addddec,Microsoft Purview Communication Compliance for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AUDIT_FOR_AGENTS,6d9b0ae5-e6a0-4f04-991a-e5700fd84930,Microsoft Purview Audit for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AGENT_365,d0ce5ebb-9db0-491f-b780-8973a1d815fe,Agent 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Defender_for_Iot_Enterprise,99cd49a9-0e54-4e07-aea1-d8d9f5f704f5,Defender for IoT - Enterprise IoT Security +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DYN365_CDS_O365_P3,28b0fa46-c39a-4188-89e2-58e979a6b014,Common Data Service +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_PREMIUM2,5689bec4-755d-4753-8b61-40975025187c,AZURE INFORMATION PROTECTION PREMIUM P2 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_PREMIUM,6c57d4b6-3b23-47a5-9bc9-69f17b4947b3,Microsoft Entra RIGHTS +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Verifiable_Credentials_Service_Request,aae826b7-14cd-4691-8178-2b312f7072ea,Verifiable Credentials Service Request +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,POWER_VIRTUAL_AGENTS_O365_P3,ded3d325-1bdc-453e-8432-5bac26d7a014,Power Virtual Agents for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_CONNECTORS,89f1c4c8-0878-40f7-804d-869c9128ab5d,Power Platform Connectors in Microsoft 365 Copilot +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,FLOW_O365_P3,07699545-9485-468e-95b6-2fca3738be01,Power Automate for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WORKPLACE_ANALYTICS_INSIGHTS_BACKEND,ff7b261f-d98b-415b-827c-42a3fdf015af,Microsoft Viva Insights Backend +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WORKPLACE_ANALYTICS_INSIGHTS_USER,b622badb-1b45-48d5-920f-4b27a2c0996c,Microsoft Viva Insights +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INTUNE_A,c1ec4a95-1f05-45b3-a911-aa3fa01094f5,Microsoft Intune +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Premium_Private_Access,f057aab1-b184-49b2-85c0-881b02a405c5,Microsoft Entra Private Access +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Premium_Internet_Access,8d23cb83-ab07-418f-8517-d7aca77307dc,Microsoft Entra Internet Access +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AAD_PREMIUM_P2,eec0eb4f-6444-4f95-aba0-50c24d67f998,Microsoft Entra ID P2 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AAD_PREMIUM,41781fb2-bc02-4b7c-bd55-b576c07bb09d,Microsoft Entra ID P1 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ATA,14ab5db5-e6c4-4b20-b4bc-13e36fd2227f,MICROSOFT DEFENDER FOR IDENTITY +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ADALLOM_S_STANDALONE,2e2ddb96-6af9-4b1d-a3f0-d6ecfd22edb2,MICROSOFT CLOUD APP SECURITY +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_BUSINESS_CHAT,3f30311c-6b1e-48a4-ab79-725b469da960,Microsoft Copilot with Graph-grounded chat +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MFA_PREMIUM,8a256a2b-b617-496d-b51b-e76466e88db0,Microsoft Azure Multi-Factor Authentication +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_APPS,a62f8878-de10-42f3-b68f-6149a25ceb97,Microsoft 365 Copilot in Productivity Apps +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_TEAMS,b95945de-b3bd-46db-8437-f2beb6ea2347,Microsoft 365 Copilot in Microsoft Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_SHAREPOINT,0aedf20c-091d-420b-aadf-30c042609612,Microsoft 365 Copilot for SharePoint +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_INTELLIGENT_SEARCH,931e4a88-a67f-48b5-814f-16a5f1e6028d,Intelligent Search +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,GRAPH_CONNECTORS_COPILOT,82d30987-df9b-4486-b146-198b21d164c7,Graph Connectors in Microsoft 365 Copilot +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Identity_Governance,e866a266-3cff-43a3-acca-0c90a7e00c8b,Entra Identity Governance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COPILOT_STUDIO_IN_COPILOT_FOR_M365,fe6c28b3-d468-44ea-bbd0-a10a5167435c,Copilot Studio in Copilot for M365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE,7bf960f6-2cd9-443a-8046-5dbff9558365,Windows Update for Business Deployment Service +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Windows_Autopatch,9a6eeb79-0b4b-4bf0-9808-39d99a2cd5a3,Windows Autopatch +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WIN10_PRO_ENT_SUB,21b439ba-a0ca-424f-a6cc-52f954a5b111,Windows 10/11 Enterprise (Original) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,UNIVERSAL_PRINT_01,795f6fe0-cc4d-4773-b050-5dde4dc704c9,Universal Print +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFTENDPOINTDLP,64bfac92-2b17-4482-b5e5-a0304429de3e,Microsoft Endpoint DLP +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WINDEFATP,871d91ec-ec1a-452b-a83f-bd76c7d770ef,Microsoft Defender for Endpoint +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,YAMMER_ENTERPRISE +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WHITEBOARD_PLAN3,4a51bca5-1eff-43f5-878c-177680f191af,Whiteboard (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,VIVA_LEARNING_SEEDED,b76fb638-6ba6-402a-b9f9-83d28acb3d86,Viva Learning Seeded +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,VIVAENGAGE_CORE,a82fbf69-b4d7-49f4-83a6-915b2cf354f4,Viva Engage Core +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,BPOS_S_TODO_3,3fb82609-8c27-4f7b-bd51-30634711ee67,To-Do (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SWAY,a23b959c-7ce8-4e57-9140-b90eb88a9e97,Sway +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PURVIEW_DISCOVERY,c948ea65-2053-4a5a-8a62-9eaaaf11b522,Purview Discovery +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PROJECT_O365_P3,b21a6b06-1988-436e-a07b-51ec6d9f52ad,Project for Office (Plan E5) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PREMIUM_ENCRYPTION,617b097b-4b93-4ede-83de-5f075bb5fb2f,Premium Encryption in Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,POWERAPPS_O365_P3,9c0dab89-a30c-4117-86e7-97bda240acd2,Power Apps for Office 365 (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PEOPLE_SKILLS_FOUNDATION,13b6da2c-0d84-450e-9f69-a33e221387ca,People Skills - Foundation +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SAFEDOCS,bf6f5520-59e3-4f82-974b-7dbbc4fd27c7,Office 365 SafeDocs +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PAM_ENTERPRISE,b1188c4c-1b36-4018-b48b-ee07604f6feb,Office 365 Privileged Access Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ADALLOM_S_O365,8c098270-9dd4-4350-9b30-ba4703f3b36b,Office 365 Cloud App Security +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EQUIVIO_ANALYTICS,4de31727-a228-4ec3-a5bf-8e45b5ca48cc,Office 365 Advanced eDiscovery +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Nucleus,db4d623d-b514-490b-b7ef-8885eee514de,Nucleus +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INTUNE_O365,882e1d05-acd1-4ccb-8708-6ee03664b117,Mobile Device Management for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,STREAM_O365_E5,6c6042f5-6f01-4d67-b8c1-eb99d36eed3e,Microsoft Stream for Office 365 E5 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,Microsoft StaffHub +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_SEARCH,94065c59-bc8e-4e8b-89e5-5138d471eaff,Microsoft Search +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RECORDS_MANAGEMENT,65cc641f-cccd-4643-97e0-a17e3045e541,Microsoft Records Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PROJECTWORKMANAGEMENT,b737dad2-2f6c-4c65-90e3-ca563267e8b9,Microsoft Planner +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCHANGE_ANALYTICS,34c0d7a0-a70f-4668-9238-47f9fc208882,Microsoft MyAnalytics (Full) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ML_CLASSIFICATION,d2d51368-76c9-4317-ada2-a12c004c432f,Microsoft ML-Based Classification +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_LOOP,c4b8c31a-fb44-4c65-9837-a21f55fcabda,Microsoft Loop +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK_MANAGEMENT,9d0c4ee5-e4a1-4625-ab39-d82b619b1a34,Microsoft Insider Risk Management - Exchange +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK,d587c7a3-bda9-4f99-8776-9bcf59c84f75,Microsoft Insider Risk Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFO_GOVERNANCE,e26c2fcc-ab91-4a61-b35c-03cdc8dddf66,Microsoft Information Governance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,FORMS_PLAN_E5,e212cbc7-0961-4c40-9825-01117710dcb1,Microsoft Forms (Plan E5) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCEL_PREMIUM,531ee2f8-b1cb-453b-9c21-d2180d014ca5,Microsoft Excel Advanced Analytics +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,THREAT_INTELLIGENCE,8e0c0a52-6a6c-4d40-8370-dd62790dcd70,Microsoft Defender for Office 365 (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CUSTOMER_KEY,6db1f1db-2b46-403f-be40-e39395f08dbb,Microsoft Customer Key +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMUNICATIONS_DLP,6dc145d6-95dd-4191-b9c3-185575ee6f6b,Microsoft Communications DLP +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CLIPCHAMP,a1ace008-72f3-4ea0-8dac-33b3a23a2472,Microsoft Clipchamp +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFTBOOKINGS,199a5c09-e0ca-4e37-8f7c-b05d533e1ea2,Microsoft Bookings +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOEV,4828c8ec-dc2e-4779-b502-87ac9ce28ab7,Microsoft 365 Phone System +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_LIGHTHOUSE_CUSTOMER_PLAN1,6f23d6a9-adbf-481c-8538-b4c095654487,Microsoft 365 Lighthouse (Plan 1) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MTP,bf28f719-7844-4079-9c78-c1307898e192,Microsoft 365 Defender +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_COMMUNICATION_COMPLIANCE,a413a9ff-720c-4822-98ef-2f37c2a21f4c,Microsoft 365 Communication Compliance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_AUDIT_PLATFORM,f6de4823-28fa-440b-b886-4783fa86ddba,Microsoft 365 Audit Platform +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOMEETADV,3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40,Microsoft 365 Audio Conferencing +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Microsoft 365 Apps for enterprise +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_ADVANCED_AUDITING,2f442157-a11c-46b9-ae5b-6e39ff4e5849,Microsoft 365 Advanced Auditing +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIGHTS_BY_MYANALYTICS,b088306e-925b-44ab-baa0-63291c629a91,Insights by MyAnalytics Backend +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MYANALYTICS_P2,33c4f319-9bdd-48d6-9c4d-410b750a4a5a,Insights by MyAnalytics +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_CLP1,5136a095-5cf0-4aff-bec3-e84448b38ea5,Information Protection for Office 365 - Standard +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_CLP2,efb0351d-3b08-4503-993d-383af8de41e3,Information Protection for Office 365 - Premium +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ContentExplorer_Standard,2b815d45-56e4-4e3a-b65c-66cb9175b560,Information Protection and Governance Analytics – Standard +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Content_Explorer,d9fa6af4-e046-4c89-9226-729a0786685d,Information Protection and Governance Analytics - Premium +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFORMATION_BARRIERS,c4801e8a-cb58-4c35-aca6-f2dcc106f287,Information Barriers +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_IMMERSIVE_FOR_TEAMS,f0ff6ac6-297d-49cd-be34-6dfef97f0c28,Immersive spaces for Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,GRAPH_CONNECTORS_SEARCH_INDEX,a6520331-d7d4-4276-95f5-15c0933bc757,Graph Connectors Search with Index +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCHANGE_S_ENTERPRISE,efb87545-963c-4e0d-99df-69c6916d9eb0,EXCHANGE ONLINE (PLAN 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMON_DEFENDER_PLATFORM_FOR_OFFICE,a312bdeb-1e21-40d0-84b1-0e73f128144f,Defender Platform for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_Exchange,cd31b152-6326-4d1b-ae1b-997b625182e6,Data Classification in Microsoft 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CustomerLockboxA_Enterprise,3ec18638-bd4c-4d3b-8905-479ed636b83e,Customer Lockbox (A) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,LOCKBOX_ENTERPRISE,9f431833-0334-42de-a7dc-70aa40db46db,Customer Lockbox +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CDS_O365_P3,afa73018-811e-46e9-988f-f75d2b1b8430,Common Data Service for Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Azure Rights Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_AVATARS_ADDITIONAL_FOR_TEAMS,3efbd4ed-8958-4824-8389-1321f8730af8,Avatars for Teams (additional) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_AVATARS_FOR_TEAMS,dcf9d2f4-772e-4434-b757-77a453cfbc02,Avatars for Teams +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,AGENT_365,d0ce5ebb-9db0-491f-b780-8973a1d815fe,Agent 365 +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,AUDIT_FOR_AGENTS,6d9b0ae5-e6a0-4f04-991a-e5700fd84930,Microsoft Purview Audit for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,COMMUNICATION_COMPLIANCE_FOR_AGENTS,135fe762-031a-4e79-bd3c-ac376addddec,Microsoft Purview Communication Compliance for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,COMPLIANCE_MANAGER_FOR_AGENTS,d1f65d05-a302-4861-bd35-da8933ba7655,Microsoft Purview Compliance Manager for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS,30d56d35-9be2-41c1-b4b8-7a8d6f073152,Microsoft Purview Data Lifecycle Management for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DATA_LOSS_PREVENTION_FOR_AGENTS,46d3c309-0ba7-461f-9d0e-eaca165794c2,Microsoft Purview Data Loss Prevention for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DEFENDER_FOR_AI,a1c15058-5559-4c1a-ba05-8040847f91bb,Microsoft Defender for AI +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,EDISCOVERY_FOR_AGENTS,92cedcb2-3fb2-40b4-9df4-f9a5603d9631,Microsoft Purview eDiscovery for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS,a9e85e05-1687-4958-8a4c-bdacda2943db,Microsoft Entra ID Governance for Assistive Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS,27e196a4-8b80-4930-bd65-53fd28581878,Microsoft Entra Network Controls for Assistive Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INFORMATION_PROTECTION_FOR_AGENTS,48478b49-91a1-4ded-94f0-066db80035ca,Microsoft Purview Information Protection for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INSIDER_RISK_MANAGEMENT_FOR_AGENTS,004ddfc0-c92f-4b0a-90c5-c60646299d71,Microsoft Purview Insider Risk Management for Agents From 4d112f56078c2d549b0fc9e19552a40dd7c01635 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:02:38 +0800 Subject: [PATCH 068/150] User Offboarding task validation --- .../Public/Invoke-CIPPOffboardingJob.ps1 | 5 +- .../Public/Test-CIPPOffboardingRequest.ps1 | 101 ++++++++++++++++++ .../Users/Invoke-ExecOffboardUser.ps1 | 13 ++- 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 diff --git a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index 2724877e05576..c4f40e526453a 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -306,8 +306,9 @@ function Invoke-CIPPOffboardingJob { } if ($Batch.Count -eq 0) { - Write-LogMessage -API $APIName -tenant $TenantFilter -message "No offboarding tasks selected for user $Username" -sev Warning - return "No offboarding tasks were selected for $Username" + $NoTasksMessage = "No offboarding tasks were selected for $Username. The offboarding job was not executed - check that at least one action was enabled." + Write-LogMessage -API $APIName -tenant $TenantFilter -message $NoTasksMessage -sev Error + throw $NoTasksMessage } Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username" diff --git a/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 new file mode 100644 index 0000000000000..4938feef3dcac --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 @@ -0,0 +1,101 @@ +function Test-CIPPOffboardingRequest { + <# + .SYNOPSIS + Validates the shape of an ExecOffboardUser request body before a scheduled task is queued. + .DESCRIPTION + Invoke-ExecOffboardUser queues an asynchronous scheduled task and returns 200 the instant the + task is created - it never waits for, or reports on, the actual offboarding result. That means a + malformed payload silently "succeeds": it reports OK, queues nothing useful, runs no actions, and + never appears in the Offboarding view. + + The common failure modes this catches: + - 'user' sent as bare UPN strings instead of { value = '' } objects, so the backend's + $Request.Body.user.value resolves to nothing and no task is created. + - 'tenantFilter' missing or not resolvable to a domain. + - 'Scheduled.enabled' true with a missing/invalid date. + - No offboarding actions selected, which produces an empty batch that completes as a no-op. + + Returns a structured result. The endpoint rejects the request with a 400 when IsValid is false, + and reuses the normalized TenantFilter/Users so the extraction logic matches what was validated. + .PARAMETER Body + The $Request.Body of the ExecOffboardUser call. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Body + ) + + $Errors = [System.Collections.Generic.List[string]]::new() + + # tenantFilter: required, must resolve to a non-empty domain string (accepts a string or { value }) + $TenantFilter = $Body.tenantFilter.value ?? $Body.tenantFilter + if ([string]::IsNullOrWhiteSpace([string]$TenantFilter)) { + $Errors.Add("'tenantFilter' is required and must resolve to a tenant domain (a string, or an object with a non-empty 'value' property).") + } + + # user: required, >= 1 entry, each resolving to a non-empty userPrincipalName. + # Accepts the UI shape ([{ value = '' }]) and bare UPN strings (['']). + # Only string values are accepted - an object without a 'value' must NOT fall back to the object + # itself (its string form is "@{...}", which would otherwise sneak past the UPN '@' check). + $Users = @( + $Body.user | ForEach-Object { + $UserValue = $_.value ?? $_ + if ($UserValue -is [string]) { $UserValue } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if (-not $Body.user -or @($Body.user).Count -eq 0) { + $Errors.Add("'user' is required and must be a non-empty array of users (objects with a 'value' property, or userPrincipalName strings).") + } elseif ($Users.Count -eq 0) { + $Errors.Add("'user' did not resolve to any usable userPrincipalName. Each entry must be a UPN string or an object with a non-empty 'value' property.") + } else { + $InvalidUsers = @($Users | Where-Object { [string]$_ -notmatch '@' }) + if ($InvalidUsers.Count -gt 0) { + $Errors.Add("These user values do not look like userPrincipalNames (missing '@'): $($InvalidUsers -join ', ').") + } + } + + # Scheduled: when enabled, date must be a valid Unix timestamp + if ($Body.Scheduled.enabled) { + $Epoch = [int64]0 + if ($null -eq $Body.Scheduled.date -or -not [int64]::TryParse([string]$Body.Scheduled.date, [ref]$Epoch) -or $Epoch -le 0) { + $Errors.Add("'Scheduled.enabled' is true but 'Scheduled.date' is not a valid Unix timestamp.") + } + } + + # At least one offboarding action must be selected, otherwise the job builds an empty batch and no-ops. + # Keep this list in sync with the conditions in Invoke-CIPPOffboardingJob. + $BooleanActions = @( + 'ConvertToShared', 'HideFromGAL', 'removeCalendarInvites', 'removePermissions', 'removeCalendarPermissions', + 'RemoveRules', 'RemoveMobile', 'RemoveGroups', 'RemoveLicenses', 'RevokeSessions', 'DisableSignIn', + 'ClearImmutableId', 'ResetPass', 'RemoveMFADevices', 'RemoveTeamsPhoneDID', 'DeleteUser', + 'DisableOneDriveSharing', 'disableForwarding' + ) + $CollectionActions = @('AccessNoAutomap', 'AccessAutomap', 'OnedriveAccess') + + $HasAction = $false + foreach ($Key in $BooleanActions) { + if ($Body.$Key -eq $true) { $HasAction = $true; break } + } + if (-not $HasAction) { + foreach ($Key in $CollectionActions) { + if (@($Body.$Key | Where-Object { $null -ne $_ }).Count -gt 0) { $HasAction = $true; break } + } + } + if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]($Body.forward.value ?? $Body.forward))) { + $HasAction = $true + } + if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]$Body.OOO)) { + $HasAction = $true + } + if (-not $HasAction) { + $Errors.Add('No offboarding actions were selected. Enable at least one action (e.g. RemoveLicenses, DisableSignIn, RevokeSessions) before submitting.') + } + + return [PSCustomObject]@{ + IsValid = ($Errors.Count -eq 0) + Errors = @($Errors) + TenantFilter = $TenantFilter + Users = $Users + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 index efbec290e6aa3..068c95ec1c3b9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 @@ -7,8 +7,17 @@ function Invoke-ExecOffboardUser { #> [CmdletBinding()] param($Request, $TriggerMetadata) - $AllUsers = $Request.Body.user.value - $TenantFilter = $request.Body.tenantFilter.value ? $request.Body.tenantFilter.value : $request.Body.tenantFilter + + $Validation = Test-CIPPOffboardingRequest -Body $Request.Body + if (-not $Validation.IsValid) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = [pscustomobject]@{ Results = @($Validation.Errors) } + }) + } + + $AllUsers = $Validation.Users + $TenantFilter = $Validation.TenantFilter $OffboardingOptions = $Request.Body | Select-Object * -ExcludeProperty user, tenantFilter, Scheduled $StatusCode = [HttpStatusCode]::OK From 05f5f1fce46035d82ed84defce28b4f8c062441c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:37:14 +0800 Subject: [PATCH 069/150] alert fixes for custom tests --- .../Custom-Scripts/Invoke-AddCustomScript.ps1 | 9 +-------- ...oke-CIPPStandardColleagueImpersonationAlert.ps1 | 8 ++++---- .../Tests/Custom/Invoke-CippTestCustomScripts.ps1 | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 index bbf7e50cc628f..ce30603163f64 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 @@ -31,12 +31,10 @@ function Invoke-AddCustomScript { $LatestVersion = $ExistingVersions | Sort-Object -Property Version -Descending | Select-Object -First 1 $CurrentEnabled = if ($LatestVersion.PSObject.Properties['Enabled']) { [bool]$LatestVersion.Enabled } else { $true } $CurrentAlertOnFailure = if ($LatestVersion.PSObject.Properties['AlertOnFailure']) { [bool]$LatestVersion.AlertOnFailure } else { $false } - $CurrentAlertStatuses = if ($LatestVersion.PSObject.Properties['AlertStatuses'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.AlertStatuses)) { $LatestVersion.AlertStatuses } else { '[]' } $CurrentResultMode = if ($LatestVersion.PSObject.Properties['ResultMode'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.ResultMode)) { $LatestVersion.ResultMode } else { 'Auto' } $NewEnabled = $CurrentEnabled $NewAlertOnFailure = $CurrentAlertOnFailure - $NewAlertStatuses = $CurrentAlertStatuses $NewResultMode = $CurrentResultMode switch ($Action) { @@ -48,13 +46,9 @@ function Invoke-AddCustomScript { } 'EnableAlerts' { $NewAlertOnFailure = $true - if ($NewAlertStatuses -eq '[]') { - $NewAlertStatuses = @('Failed') | ConvertTo-Json -Compress - } } 'DisableAlerts' { $NewAlertOnFailure = $false - $NewAlertStatuses = '[]' } 'SetResultMode' { $RequestedMode = $Request.Body.ResultMode @@ -71,7 +65,6 @@ function Invoke-AddCustomScript { RowKey = $LatestVersion.RowKey Enabled = $NewEnabled AlertOnFailure = $NewAlertOnFailure - AlertStatuses = $NewAlertStatuses ResultMode = $NewResultMode } @@ -122,7 +115,7 @@ function Invoke-AddCustomScript { $UserImpact = $Request.Body.UserImpact $Enabled = $Request.Body.Enabled $AlertOnFailure = $Request.Body.AlertOnFailure - $AlertStatuses = if ($Request.Body.AlertStatuses) { $Request.Body.AlertStatuses | ConvertTo-Json -Compress } else { '[]' } + $AlertStatuses = $Request.Body.AlertStatuses $ReturnType = $Request.Body.ReturnType $MarkdownTemplate = $Request.Body.MarkdownTemplate $ResultSchema = $Request.Body.ResultSchema diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 index 34ca096ac19a3..2deb77bff0de4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 @@ -46,9 +46,9 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { param($Tenant, $Settings) $TestResult = Test-CIPPStandardLicense -StandardName 'ColleagueImpersonationAlert' -TenantFilter $Tenant -Preset Exchange - + if ($TestResult -eq $false) { - return $true + return $true } #we're done. $ruleHtml = $Settings.disclaimerHtml @@ -135,8 +135,8 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { $range = $entry.Key $pattern = $entry.Value $ruleName = "($range) Colleague Impersonation Alert" - $names = @($displayNames | Where-Object { $_ -match $pattern }) - if ($names.Count -eq 0) { $names = @("($range)") } + $names = @($displayNames | Where-Object { $_ -match $pattern } | ForEach-Object { [regex]::Escape($_) }) + if ($names.Count -eq 0) { $names = @([regex]::Escape("($range)")) } $existing = $Rules | Where-Object { $_.Name -eq $ruleName } | Select-Object -First 1 $namesMatch = $false diff --git a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 index c64a9b1d03ce3..51af2cffc41da 100644 --- a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 +++ b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 @@ -56,9 +56,19 @@ function Invoke-CippTestCustomScripts { $TestId = "CustomScript-$($Script.ScriptGuid)" $ScriptName = if ([string]::IsNullOrWhiteSpace($Script.ScriptName)) { $TestId } else { $Script.ScriptName } + $AllStatuses = @('Passed', 'Failed', 'Info', 'Investigate') $AlertStatuses = @('Failed') if ($AlertStatusesProp -and -not [string]::IsNullOrWhiteSpace($AlertStatusesProp.Value)) { - $AlertStatuses = $AlertStatusesProp.Value | ConvertFrom-Json + $RawAlertStatuses = [string]$AlertStatusesProp.Value + if ($RawAlertStatuses.TrimStart().StartsWith('[')) { + $AlertStatuses = @($RawAlertStatuses | ConvertFrom-Json) + } else { + $AlertStatuses = @($RawAlertStatuses) + } + } + # 'All' alerts on every result status. + if ($AlertStatuses -contains 'All') { + $AlertStatuses = $AllStatuses } try { @@ -105,7 +115,7 @@ function Invoke-CippTestCustomScripts { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultDataJson $ResultDataJson -ResultMarkdown $ResultMarkdown -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) { - Write-AlertMessage -tenant $Tenant -message "Custom script test failed: $ScriptName ($($Script.ScriptGuid))" + Write-AlertMessage -tenant $Tenant -message "Custom script test '$ScriptName' returned status '$FinalStatus' ($($Script.ScriptGuid))" } } catch { $ErrorMessage = Get-CippException -Exception $_ From 0c3d4ba7eb7862697ddf6e875163819c8014944f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:42:58 +0800 Subject: [PATCH 070/150] Custom Test Alerting overhaul --- .../Public/Invoke-CIPPTestCollection.ps1 | 9 ++ .../CIPPCore/Public/New-CIPPAlertTemplate.ps1 | 37 ++++++++ .../Public/New-CippCustomScriptExecution.ps1 | 2 - .../Public/Send-CIPPCustomTestAlert.ps1 | 94 +++++++++++++++++++ .../HTTP Functions/Invoke-ExecTestRun.ps1 | 3 - .../Custom/Invoke-CippTestCustomScripts.ps1 | 33 ++++++- 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index e03b78f77a3fc..e03fd8ee901f7 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -104,6 +104,7 @@ function Invoke-CIPPTestCollection { $Table = Get-CippTable -tablename 'CippTestResults' $ResultBatch = [System.Collections.Generic.List[hashtable]]::new() + $AlertBatch = [System.Collections.Generic.List[object]]::new() foreach ($Guid in $EnabledGuids) { $ItemStopwatch = [System.Diagnostics.Stopwatch]::StartNew() @@ -113,6 +114,8 @@ function Invoke-CIPPTestCollection { foreach ($Entity in $TestOutput) { if ($Entity -is [hashtable] -and $Entity.PartitionKey -and $Entity.RowKey) { $ResultBatch.Add($Entity) + } elseif ($Entity -isnot [hashtable] -and $Entity.PSObject.Properties['CippCustomTestAlert']) { + $AlertBatch.Add($Entity) } } if ($ResultBatch.Count -ge 100) { @@ -141,6 +144,12 @@ function Invoke-CIPPTestCollection { Write-Information " [Custom] Flushed final $($ResultBatch.Count) results to table" } + # Ship a single aggregated alert for the tenant covering all alert-worthy results. + if ($AlertBatch.Count -gt 0) { + Write-Information " [Custom] Shipping $($AlertBatch.Count) custom test alert(s) for $TenantFilter" + Send-CIPPCustomTestAlert -TenantFilter $TenantFilter -Alerts @($AlertBatch) + } + $SuiteStopwatch.Stop() $TotalElapsed = '{0:N3}' -f $SuiteStopwatch.Elapsed.TotalSeconds $Summary = "Custom suite for $TenantFilter completed in ${TotalElapsed}s — $SuccessCount/$($EnabledGuids.Count) ran, $FailedCount errored" diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index db1d5e455da1d..063718e857053 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -87,6 +87,43 @@ function New-CIPPAlertTemplate { $ButtonUrl = "$CIPPURL/standards/list-standards" $ButtonText = 'Check Standards configuration' } + if ($InputObject -eq 'customScript') { + # $Data is an array of custom-test alert records (one per failing test for this tenant). + $Alerts = @($Data) + $Count = $Alerts.Count + $Title = if ($Count -eq 1) { + "$($Tenant) - Custom test '$($Alerts[0].ScriptName)' returned status '$($Alerts[0].Status)'" + } else { + "$($Tenant) - $Count custom tests need attention" + } + + $SummaryRows = foreach ($Alert in $Alerts) { + [PSCustomObject]@{ + Test = $Alert.ScriptName + Status = $Alert.Status + Risk = if ($Alert.Risk) { $Alert.Risk } else { 'Medium' } + } + } + $SummaryHTML = ($SummaryRows | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') + $IntroText = "

You're receiving this because you enabled failure alerts for one or more custom tests. The following custom test(s) on tenant $($Tenant) need attention:

$SummaryHTML" + + foreach ($Alert in $Alerts) { + $IntroText += "

$($Alert.ScriptName) — $($Alert.Status)

" + if (![string]::IsNullOrWhiteSpace($Alert.ErrorMessage)) { + $IntroText += "

The test failed to execute: $($Alert.ErrorMessage)

" + } elseif (![string]::IsNullOrWhiteSpace($Alert.ResultMarkdown)) { + $IntroText += "
$($Alert.ResultMarkdown)
" + } elseif ($Alert.FailedRows) { + # Normalize string rows to objects so ConvertTo-Html renders a message column + # instead of the string's Length property. + $Rows = foreach ($r in @($Alert.FailedRows)) { if ($r -is [string]) { [PSCustomObject]@{ message = $r } } else { $r } } + $DetailHTML = ($Rows | Select-Object * -ExcludeProperty Etag, PartitionKey, TimeStamp | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') + $IntroText += "

Results:

$DetailHTML" + } + } + $ButtonUrl = "$CIPPURL/tools/custom-tests" + $ButtonText = 'View custom test results' + } if ($InputObject -eq 'auditlog') { $ButtonUrl = "$CIPPURL/identity/administration/users/user/bec?userId=$($data.ObjectId)&tenantFilter=$Tenant" $ButtonText = 'User Management' diff --git a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 index fb995c5e2986c..d76b15ade594a 100644 --- a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 +++ b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 @@ -59,8 +59,6 @@ function New-CippCustomScriptExecution { # Get script content $ScriptContent = $Script.ScriptContent - Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Executing custom script: $($Script.ScriptName) (Version: $($Script.Version))" -sev Info - # Convert Parameters to hashtable if it's a PSCustomObject (from JSON) if ($Parameters -is [PSCustomObject]) { $ParamsHash = @{} diff --git a/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 new file mode 100644 index 0000000000000..172fc50111403 --- /dev/null +++ b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 @@ -0,0 +1,94 @@ +function Send-CIPPCustomTestAlert { + <# + .SYNOPSIS + Ship an aggregated notification for one or more custom script test results for a tenant. + + .DESCRIPTION + Builds a single email/PSA HTML body (via New-CIPPAlertTemplate) and a single webhook + JSON payload covering all alert-worthy custom test results for a tenant, then ships them + through Send-CIPPAlert for the email, webhook and PSA channels. Each channel self-gates + inside Send-CIPPAlert on the global CippNotifications configuration, so channels that + aren't configured are simply skipped. + + This is the "post all the tests" shipping action — Invoke-CIPPTestCollection collects the + alert records emitted by Invoke-CippTestCustomScripts across every enabled script for a + tenant and calls this once after the suite has run, so a tenant receives a single + notification per run rather than one per failing script. + + Routing (recipients / webhook URL / PSA) comes entirely from the instance-wide + CippNotifications config, the same source used by the audit-log alert engine. + + .PARAMETER TenantFilter + The tenant the tests ran against. + + .PARAMETER Alerts + One or more custom-test alert records (as emitted by Invoke-CippTestCustomScripts). Each + record carries TestId, ScriptGuid, ScriptName, Status, Risk, Pillar, FailedRows, + ResultMarkdown and (on execution failure) ErrorMessage. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + $Alerts + ) + + try { + $Alerts = @($Alerts) + if ($Alerts.Count -eq 0) { return } + + # UseStandardizedSchema flag comes from the global CippNotifications config, matching + # how Push-SchedulerCIPPNotifications resolves it for webhook delivery. + $ConfigTable = Get-CIPPTable -TableName SchedulerConfig + $Config = [pscustomobject](Get-CIPPAzDataTableEntity @ConfigTable -Filter "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'") + + # CIPP URL for the email button link. + $CippConfigTable = Get-CippTable -tablename Config + $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPURL = 'https://{0}' -f $CippConfig.Value + + # Email / PSA HTML + $Template = New-CIPPAlertTemplate -Format 'html' -InputObject 'customScript' -Data $Alerts -CIPPURL $CIPPURL -Tenant $TenantFilter + $Title = $Template.title + + # Email — Send-CIPPAlert no-ops if no notification email is configured. + $null = Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + + # PSA — Send-CIPPAlert no-ops unless config.sendtoIntegration is set. + $null = Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + + # Webhook — hand-built payload, Send-CIPPAlert no-ops if no webhook is configured. + $WebhookData = [PSCustomObject]@{ + Title = $Title + Tenant = $TenantFilter + AlertCount = $Alerts.Count + Tests = @($Alerts | ForEach-Object { + [PSCustomObject]@{ + TestId = $_.TestId + ScriptGuid = $_.ScriptGuid + ScriptName = $_.ScriptName + Status = $_.Status + Risk = if ($_.Risk) { $_.Risk } else { 'Medium' } + Pillar = $_.Pillar + Category = 'Custom Script' + FailedRowCount = @($_.FailedRows).Count + Results = $_.FailedRows + ResultMarkdown = $_.ResultMarkdown + ErrorMessage = $_.ErrorMessage + } + }) + } | ConvertTo-Json -Depth 10 -Compress + + $null = Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $WebhookData -TenantFilter $TenantFilter ` + -APIName 'CustomTests' -SchemaSource 'Custom Test Notification' -InvokingCommand 'Invoke-CippTestCustomScripts' ` + -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) + } catch { + $Err = Get-CippException -Exception $_ + Write-LogMessage -API 'CustomTests' -tenant $TenantFilter -message "Failed to send custom test alerts: $($Err.NormalizedError)" -sev Error -LogData $Err + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 index 696f307ca9656..c080efaa09359 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 @@ -72,9 +72,6 @@ function Invoke-ExecTestRun { $StatusCode = [HttpStatusCode]::OK $Body = [PSCustomObject]@{ Results = $ResultMessage } - - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Mode '$Mode' orchestration started. Instance ID: $InstanceId" -sev Info - } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to start data collection/test run: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 index 51af2cffc41da..f22c62b234a6a 100644 --- a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 +++ b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 @@ -115,7 +115,24 @@ function Invoke-CippTestCustomScripts { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultDataJson $ResultDataJson -ResultMarkdown $ResultMarkdown -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) { - Write-AlertMessage -tenant $Tenant -message "Custom script test '$ScriptName' returned status '$FinalStatus' ($($Script.ScriptGuid))" + # Logbook entry for the UI/audit trail. Uses API 'CustomTests' + a non-alert + # severity so Push-SchedulerCIPPNotifications does not re-ship it. + Write-LogMessage -API 'CustomTests' -tenant $Tenant -message "Custom script test '$ScriptName' returned status '$FinalStatus' ($($Script.ScriptGuid))" -sev Info + # Emit an alert record. Delivery is batched per-tenant by Invoke-CIPPTestCollection + # after the whole suite runs (Send-CIPPCustomTestAlert). Manual single-test runs + # via Push-CIPPTest discard this, so they intentionally do not ship an alert. + [PSCustomObject]@{ + CippCustomTestAlert = $true + TestId = $TestId + ScriptGuid = $Script.ScriptGuid + ScriptName = $ScriptName + Status = $FinalStatus + Risk = $Script.Risk ?? 'Medium' + Pillar = $Script.Pillar + FailedRows = $FailedRows + ResultMarkdown = $ResultMarkdown + ErrorMessage = $null + } } } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -127,7 +144,19 @@ function Invoke-CippTestCustomScripts { } Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultMarkdown "Custom script execution failed: $($ErrorMessage.NormalizedError)" -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) { - Write-AlertMessage -tenant $Tenant -message "Custom script execution failed: $ScriptName ($($Script.ScriptGuid)) - $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'CustomTests' -tenant $Tenant -message "Custom script execution failed: $ScriptName ($($Script.ScriptGuid)) - $($ErrorMessage.NormalizedError)" -sev Warning + [PSCustomObject]@{ + CippCustomTestAlert = $true + TestId = $TestId + ScriptGuid = $Script.ScriptGuid + ScriptName = $ScriptName + Status = $FinalStatus + Risk = $Script.Risk ?? 'Medium' + Pillar = $Script.Pillar + FailedRows = @() + ResultMarkdown = '' + ErrorMessage = $ErrorMessage.NormalizedError + } } } } From a8143b4b26e8ca7b5aa8ccc1759ba41aa25a6205 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:56:15 +0200 Subject: [PATCH 071/150] feat(defender): add MTD role, realign iOS sync Add grantMobileThreatDefensePartnerRole to the Mobile Threat Defense connector payload in both the deployment handler and the DefenderCompliancePolicy standard, so the new Intune option is deployable and enforceable. Fix the crossed iOS connector mappings: AppSync now drives allowPartnerToCollectIOSApplicationMetadata and ConnectIosCompliance drives iosMobileApplicationManagementEnabled (MAM), and add the personal app-inventory property. This corrects the property each toggle controls, so existing iOS configs change behaviour. Sync Config/standards.json. --- Config/standards.json | 22 ++++++++++++++----- .../Set-CIPPDefenderCompliancePolicy.ps1 | 7 +++--- ...e-CIPPStandardDefenderCompliancePolicy.ps1 | 19 ++++++++++------ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 3d27a41de8322..209e7d29c17da 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -5865,7 +5865,7 @@ { "type": "switch", "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", - "label": "Allow users to communicate with other organizations" + "label": "Allow users to communicate with consumer Teams accounts" }, { "type": "autoComplete", @@ -7255,6 +7255,12 @@ "label": "Block Android if partner data unavailable", "defaultValue": false }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.grantMobileThreatDefensePartnerRole", + "label": "Grant MTD role to MDE on enrolled Android COBO/COPE devices", + "defaultValue": false + }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIos", @@ -7264,13 +7270,19 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIosCompliance", - "label": "Connect iOS 13.0+ (App-based MAM)", + "label": "Connect iOS/iPadOS devices for app protection policy evaluation (MAM)", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.appSync", - "label": "Enable App Sync for iOS", + "label": "Enable App Sync (sending application inventory) for iOS/iPadOS devices", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalApplicationMetadata", + "label": "Send full application inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { @@ -7282,13 +7294,13 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata", - "label": "Collect certificate metadata from iOS", + "label": "Enable Certificate Sync for iOS/iPadOS devices", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata", - "label": "Collect personal certificate metadata from iOS", + "label": "Send full certificate inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 index 9ffa6ab757439..5384b63995be1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 @@ -27,17 +27,18 @@ function Set-CIPPDefenderCompliancePolicy { macEnabled = [bool]$Compliance.ConnectMac partnerUnsupportedOsVersionBlocked = [bool]$Compliance.BlockunsupportedOS partnerUnresponsivenessThresholdInDays = 7 - allowPartnerToCollectIOSApplicationMetadata = [bool]$Compliance.ConnectIosCompliance - allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Compliance.ConnectIosCompliance + allowPartnerToCollectIOSApplicationMetadata = [bool]$Compliance.AppSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Compliance.allowPartnerToCollectIosPersonalApplicationMetadata androidDeviceBlockedOnMissingPartnerData = [bool]$Compliance.androidDeviceBlockedOnMissingPartnerData iosDeviceBlockedOnMissingPartnerData = [bool]$Compliance.iosDeviceBlockedOnMissingPartnerData windowsDeviceBlockedOnMissingPartnerData = [bool]$Compliance.windowsDeviceBlockedOnMissingPartnerData macDeviceBlockedOnMissingPartnerData = [bool]$Compliance.macDeviceBlockedOnMissingPartnerData androidMobileApplicationManagementEnabled = [bool]$Compliance.ConnectAndroidCompliance - iosMobileApplicationManagementEnabled = [bool]$Compliance.appSync + iosMobileApplicationManagementEnabled = [bool]$Compliance.ConnectIosCompliance windowsMobileApplicationManagementEnabled = [bool]$Compliance.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$Compliance.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$Compliance.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$Compliance.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = [bool]$true } $SettingsObj = $SettingsObject | ConvertTo-Json -Compress diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 index 86c475fa28b55..c5f73aab4236b 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 @@ -21,12 +21,14 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectAndroid","label":"Connect Android devices to MDE","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectAndroidCompliance","label":"Connect Android 6.0.0+ (App-based MAM)","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.androidDeviceBlockedOnMissingPartnerData","label":"Block Android if partner data unavailable","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.grantMobileThreatDefensePartnerRole","label":"Grant MTD role to MDE on enrolled Android COBO/COPE devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIos","label":"Connect iOS/iPadOS devices to MDE","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIosCompliance","label":"Connect iOS 13.0+ (App-based MAM)","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.appSync","label":"Enable App Sync for iOS","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIosCompliance","label":"Connect iOS/iPadOS devices for app protection policy evaluation (MAM)","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.appSync","label":"Enable App Sync (sending application inventory) for iOS/iPadOS devices","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalApplicationMetadata","label":"Send full application inventory data on personally-owned iOS/iPadOS devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.iosDeviceBlockedOnMissingPartnerData","label":"Block iOS if partner data unavailable","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata","label":"Collect certificate metadata from iOS","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata","label":"Collect personal certificate metadata from iOS","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata","label":"Enable Certificate Sync for iOS/iPadOS devices","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata","label":"Send full certificate inventory data on personally-owned iOS/iPadOS devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectMac","label":"Connect macOS devices to MDE","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.macDeviceBlockedOnMissingPartnerData","label":"Block macOS if partner data unavailable","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectWindows","label":"Connect Windows 10.0.15063+ to MDE (Note: enabling this forces 'Block Windows if partner data unavailable' to on)","defaultValue":false} @@ -58,17 +60,18 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsEnabled = [bool]$Settings.ConnectWindows macEnabled = [bool]$Settings.ConnectMac partnerUnsupportedOsVersionBlocked = [bool]$Settings.BlockunsupportedOS - allowPartnerToCollectIOSApplicationMetadata = [bool]$Settings.ConnectIosCompliance - allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Settings.ConnectIosCompliance + allowPartnerToCollectIOSApplicationMetadata = [bool]$Settings.appSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Settings.allowPartnerToCollectIosPersonalApplicationMetadata androidDeviceBlockedOnMissingPartnerData = [bool]$Settings.androidDeviceBlockedOnMissingPartnerData iosDeviceBlockedOnMissingPartnerData = [bool]$Settings.iosDeviceBlockedOnMissingPartnerData windowsDeviceBlockedOnMissingPartnerData = if ([bool]$Settings.ConnectWindows) { $true } else { [bool]$Settings.windowsDeviceBlockedOnMissingPartnerData } macDeviceBlockedOnMissingPartnerData = [bool]$Settings.macDeviceBlockedOnMissingPartnerData androidMobileApplicationManagementEnabled = [bool]$Settings.ConnectAndroidCompliance - iosMobileApplicationManagementEnabled = [bool]$Settings.appSync + iosMobileApplicationManagementEnabled = [bool]$Settings.ConnectIosCompliance windowsMobileApplicationManagementEnabled = [bool]$Settings.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$Settings.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$Settings.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$Settings.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = $true } @@ -112,6 +115,7 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsMobileApplicationManagementEnabled = [bool]$CurrentState.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$CurrentState.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$CurrentState.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$CurrentState.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = [bool]$CurrentState.microsoftDefenderForEndpointAttachEnabled } } else { @@ -132,6 +136,7 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsMobileApplicationManagementEnabled = $false allowPartnerToCollectIosCertificateMetadata = $false allowPartnerToCollectIosPersonalCertificateMetadata = $false + grantMobileThreatDefensePartnerRole = $false microsoftDefenderForEndpointAttachEnabled = $false } } From 7dea7f40f19a467085ad446ce6cf3c4111bf0863 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:20:54 +0800 Subject: [PATCH 072/150] Requirements clarification --- Config/standards.json | 4 ++-- .../Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 209e7d29c17da..6df63fcb9c65b 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -378,8 +378,8 @@ "cat": "Global Standards", "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], "appliesToTest": ["CIS_1_3_6"], - "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", - "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", + "helpText": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", + "docsDescription": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", "executiveText": "Requires explicit organizational approval before Microsoft support staff can access company data for service operations. This provides an additional layer of data protection and ensures the organization maintains control over who can access sensitive business information, even during technical support scenarios.", "addedComponent": [], "label": "Enable Customer Lockbox", diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 index 956b9398e12cd..c732564427989 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardEnableCustomerLockbox { .SYNOPSIS (Label) Enable Customer Lockbox .DESCRIPTION - (Helptext) **Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data - (DocsDescription) \*\*Requires Entra ID P2.\*\* Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data. + (Helptext) **Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data + (DocsDescription) \*\*Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)\*\* Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data. .NOTES CAT Global Standards From 68595ccdf281f68dba719c1d556ae1140575ccb2 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:28:29 +0200 Subject: [PATCH 073/150] feat: incident severity + resolving comment Add severity and resolvingComment to the incident PATCH so users can reprioritise and document why an incident was resolved. Build the body as a hashtable/JSON so free-text comments with quotes are escaped, and gate assignee changes behind an explicit AssignToSelf flag so status and severity and status edits no longer clobber the existing owner. --- .../Invoke-ExecSetSecurityIncident.ps1 | 75 ++++++---- .../Invoke-ExecSetSecurityIncident.Tests.ps1 | 129 ++++++++++++++++++ 2 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 index 673e7404d8569..d6f01ab1458c2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecSetSecurityIncident { +function Invoke-ExecSetSecurityIncident { <# .FUNCTIONALITY Entrypoint @@ -12,27 +12,40 @@ Function Invoke-ExecSetSecurityIncident { $Headers = $Request.Headers - $first = '' # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter - $IncidentFilter = $Request.Query.GUID ?? $Request.Body.GUID - $Status = $Request.Query.Status ?? $Request.Body.Status - # $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? $Headers.'x-ms-client-principal' - $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails - $Classification = $Request.Query.Classification ?? $Request.Body.Classification - $Determination = $Request.Query.Determination ?? $Request.Body.Determination - $Redirected = $Request.Query.Redirected -as [int] ?? $Request.Body.Redirected -as [int] - $BodyBuild - $AssignBody = '{' + $TenantFilter = $Request.Body.tenantFilter + $IncidentFilter = $Request.Body.GUID + $Status = $Request.Body.Status + $Classification = $Request.Body.Classification + $Determination = $Request.Body.Determination + # Severity autoComplete submits {label, value} + $Severity = $Request.Body.Severity.value + $Comment = $Request.Body.Comment + $Redirected = $Request.Body.Redirected -as [int] + + $AssignToSelf = [System.Convert]::ToBoolean($Request.Body.AssignToSelf) + # Assign-to-self resolves to the caller; other actions omit the assignee so it's preserved. + if ($AssignToSelf -eq $true) { + $Assigned = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + } + + # Hashtable + ConvertTo-Json so free-text fields (resolvingComment) are escaped correctly. + $BodyObject = [ordered]@{} + $BodyParts = [System.Collections.Generic.List[string]]::new() try { # We won't update redirected incidents because the incident it is redirected to should instead be updated if ($Redirected -lt 1) { # Set received status if ($null -ne $Status) { - $AssignBody += $first + '"status":"' + $Status + '"' - $BodyBuild += $first + 'Set status for incident ' + $IncidentFilter + ' to ' + $Status - $first = ', ' + $BodyObject['status'] = $Status + $BodyParts.Add("status to $Status") + } + + # Set received severity + if ($null -ne $Severity) { + $BodyObject['severity'] = $Severity + $BodyParts.Add("severity to $Severity") } # Set received classification and determination @@ -42,43 +55,47 @@ Function Invoke-ExecSetSecurityIncident { throw } - $AssignBody += $first + '"classification":"' + $Classification + '", "determination":"' + $Determination + '"' - $BodyBuild += $first + 'Set classification & determination for incident ' + $IncidentFilter + ' to ' + $Classification + ' ' + $Determination - $first = ', ' + $BodyObject['classification'] = $Classification + $BodyObject['determination'] = $Determination + $BodyParts.Add("classification & determination to $Classification $Determination") + } + + # Set received resolving comment + if ($null -ne $Comment) { + $BodyObject['resolvingComment'] = $Comment + $BodyParts.Add('resolving comment') } # Set received assignee if ($null -ne $Assigned) { - $AssignBody += $first + '"assignedTo":"' + $Assigned + '"' + $BodyObject['assignedTo'] = $Assigned if ($null -eq $Status) { - $BodyBuild += $first + 'Set assigned for incident ' + $IncidentFilter + ' to ' + $Assigned + $BodyParts.Add("assigned to $Assigned") } - $first = ', ' } - $AssignBody += '}' + $AssignBody = ConvertTo-Json -InputObject $BodyObject -Compress + $BodyBuild = "Set $($BodyParts -join ', ') for incident $IncidentFilter" - $ResponseBody = [pscustomobject]@{'Results' = $BodyBuild } - New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true + $Result = $BodyBuild + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Update incident $IncidentFilter with values $AssignBody" -Sev 'Info' } else { - $ResponseBody = [pscustomobject]@{'Results' = "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" -Sev 'Info' + $Result = "Refused to update incident $IncidentFilter because it is redirected to another incident" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' } - $body = $ResponseBody $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to update incident $IncidentFilter : $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage - $body = [pscustomobject]@{'Results' = $Result } $StatusCode = [HttpStatusCode]::InternalServerError } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = $body + Body = @{ 'Results' = $Result } }) } diff --git a/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 new file mode 100644 index 0000000000000..8c12a28ca6ff6 --- /dev/null +++ b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Invoke-ExecSetSecurityIncident +# Validates the PATCH body built for severity changes and resolving comments, +# and that free-text comments produce valid JSON (regression for the body builder). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1' + + # The Functions worker exposes [HttpStatusCode] as an accelerator; register it for tests. + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function New-GraphPOSTRequest { param($uri, $type, $tenantid, $body, $asApp) $script:lastPatch = @{ Uri = $uri; Type = $type; Tenant = $tenantid; Body = $body } } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Invoke-ExecSetSecurityIncident' { + BeforeEach { + $script:lastPatch = $null + $script:logs = @() + } + + It 'sends only the chosen severity, leaving the assignee untouched' { + # The severity action omits Assigned, so no assignedTo should be written. + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + Severity = [pscustomobject]@{ value = 'high'; label = 'High' } + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $lastPatch.Type | Should -Be 'PATCH' + $lastPatch.Uri | Should -Match '/security/incidents/incident-1' + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.severity | Should -Be 'high' + $parsed.PSObject.Properties.Name | Should -Not -Contain 'assignedTo' + } + + It 'assigns to the calling user when the AssignToSelf flag is set' { + $principal = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('{"userDetails":"caller@contoso.com"}')) + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{ 'x-ms-client-principal' = $principal } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + AssignToSelf = $true + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($lastPatch.Body | ConvertFrom-Json).assignedTo | Should -Be 'caller@contoso.com' + } + + It 'sends status resolved together with the resolving comment' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-2' + Status = 'resolved' + Comment = 'Closed after review' + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.status | Should -Be 'resolved' + $parsed.resolvingComment | Should -Be 'Closed after review' + } + + It 'produces valid JSON when the comment contains quotes and newlines' { + $comment = "Line one with a `"quote`"`nLine two with a backslash \" + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-3' + Status = 'resolved' + Comment = $comment + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + { $lastPatch.Body | ConvertFrom-Json } | Should -Not -Throw + ($lastPatch.Body | ConvertFrom-Json).resolvingComment | Should -Be $comment + } + + It 'refuses to update a redirected incident' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-4' + Status = 'resolved' + Redirected = 1 + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Refused to update' + $lastPatch | Should -BeNullOrEmpty + } +} From ac8f24334d0fbdd1cdcea89ac85c05542ad258c9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:00:50 +0800 Subject: [PATCH 074/150] Language and Region fixes --- Config/standards.json | 12 ++--- .../Invoke-CIPPStandardSpamFilterPolicy.ps1 | 52 +++++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 6df63fcb9c65b..6552e8000429a 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -4157,12 +4157,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "LanguageCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.LanguageBlockList", - "label": "Languages to block (uppercase ISO 639-1 two-letter)", + "label": "Languages to block (ISO 639-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableLanguageBlockList", "compareType": "is", @@ -4176,12 +4174,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "CountryCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.RegionBlockList", - "label": "Regions to block (uppercase ISO 3166-1 two-letter)", + "label": "Regions to block (ISO 3166-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableRegionBlockList", "compareType": "is", diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index a305b15109731..df10b627db821 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 @@ -51,9 +51,9 @@ function Invoke-CIPPStandardSpamFilterPolicy { {"type":"switch","name":"standards.SpamFilterPolicy.MarkAsSpamWebBugsInHtml","label":"Mark as spam if message contains web bugs (also known as web beacons)","defaultValue":false} {"type":"switch","name":"standards.SpamFilterPolicy.MarkAsSpamSensitiveWordList","label":"Mark as spam if message contains words from the sensitive words list","defaultValue":false} {"type":"switch","name":"standards.SpamFilterPolicy.EnableLanguageBlockList","label":"Enable language block list","defaultValue":false} - {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.LanguageBlockList","label":"Languages to block (uppercase ISO 639-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableLanguageBlockList","compareType":"is","compareValue":true}} + {"type":"LanguageCodeMultiSelect","required":false,"name":"standards.SpamFilterPolicy.LanguageBlockList","label":"Languages to block (ISO 639-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableLanguageBlockList","compareType":"is","compareValue":true}} {"type":"switch","name":"standards.SpamFilterPolicy.EnableRegionBlockList","label":"Enable region block list","defaultValue":false} - {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.RegionBlockList","label":"Regions to block (uppercase ISO 3166-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableRegionBlockList","compareType":"is","compareValue":true}} + {"type":"CountryCodeMultiSelect","required":false,"name":"standards.SpamFilterPolicy.RegionBlockList","label":"Regions to block (ISO 3166-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableRegionBlockList","compareType":"is","compareValue":true}} {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.AllowedSenderDomains","label":"Allowed sender domains"} IMPACT Medium Impact @@ -103,6 +103,21 @@ function Invoke-CIPPStandardSpamFilterPolicy { $PhishQuarantineTag = $Settings.PhishQuarantineTag.value ?? $Settings.PhishQuarantineTag $HighConfidencePhishQuarantineTag = $Settings.HighConfidencePhishQuarantineTag.value ?? $Settings.HighConfidencePhishQuarantineTag + # Normalize list settings to clean string arrays. Values may arrive as a proper array or as a + # single comma-delimited string; splitting and trimming makes Compare-Object and remediation reliable. + # Case is folded to match what EXO stores and validates: ISO 3166-1 regions uppercase, ISO 639-1 languages lowercase. + $LanguageBlockList = @(@($Settings.LanguageBlockList.value) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }) + $RegionBlockList = @(@($Settings.RegionBlockList.value) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }) + $AllowedSenderDomains = @(@($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + # Block lists only matter when their Enable* toggle is on; when off, the list is ignored entirely. + $CurrentLanguageBlockList = @($CurrentState.LanguageBlockList) + $CurrentRegionBlockList = @($CurrentState.RegionBlockList) + $LanguageBlockListCorrect = ($Settings.EnableLanguageBlockList -ne $true) -or + (($CurrentLanguageBlockList.Count -eq $LanguageBlockList.Count) -and (($LanguageBlockList.Count -eq 0) -or !(Compare-Object -ReferenceObject $CurrentLanguageBlockList -DifferenceObject $LanguageBlockList))) + $RegionBlockListCorrect = ($Settings.EnableRegionBlockList -ne $true) -or + (($CurrentRegionBlockList.Count -eq $RegionBlockList.Count) -and (($RegionBlockList.Count -eq 0) -or !(Compare-Object -ReferenceObject $CurrentRegionBlockList -DifferenceObject $RegionBlockList))) + $IncreaseScoreWithImageLinks = if ($Settings.IncreaseScoreWithImageLinks) { 'On' } else { 'Off' } $IncreaseScoreWithBizOrInfoUrls = if ($Settings.IncreaseScoreWithBizOrInfoUrls) { 'On' } else { 'Off' } $MarkAsSpamFramesInHtml = if ($Settings.MarkAsSpamFramesInHtml) { 'On' } else { 'Off' } @@ -146,10 +161,10 @@ function Invoke-CIPPStandardSpamFilterPolicy { ($CurrentState.PhishZapEnabled -eq $true) -and ($CurrentState.SpamZapEnabled -eq $true) -and ($CurrentState.EnableLanguageBlockList -eq $Settings.EnableLanguageBlockList) -and - ((($null -eq $CurrentState.LanguageBlockList -or $CurrentState.LanguageBlockList.Count -eq 0) -and ($null -eq $Settings.LanguageBlockList.value)) -or ($null -ne $CurrentState.LanguageBlockList -and $CurrentState.LanguageBlockList.Count -gt 0 -and $null -ne $Settings.LanguageBlockList.value -and !(Compare-Object -ReferenceObject $CurrentState.LanguageBlockList -DifferenceObject $Settings.LanguageBlockList.value))) -and + $LanguageBlockListCorrect -and ($CurrentState.EnableRegionBlockList -eq $Settings.EnableRegionBlockList) -and - ((($null -eq $CurrentState.RegionBlockList -or $CurrentState.RegionBlockList.Count -eq 0) -and ($null -eq $Settings.RegionBlockList.value)) -or ($null -ne $CurrentState.RegionBlockList -and $CurrentState.RegionBlockList.Count -gt 0 -and $null -ne $Settings.RegionBlockList.value -and !(Compare-Object -ReferenceObject $CurrentState.RegionBlockList -DifferenceObject $Settings.RegionBlockList.value))) -and - ((($null -eq $CurrentState.AllowedSenderDomains -or $CurrentState.AllowedSenderDomains.Count -eq 0) -and ($null -eq ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains))) -or ($null -ne $CurrentState.AllowedSenderDomains -and $CurrentState.AllowedSenderDomains.Count -gt 0 -and $null -ne ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains) -and !(Compare-Object -ReferenceObject $CurrentState.AllowedSenderDomains -DifferenceObject ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains)))) + $RegionBlockListCorrect -and + ((($null -eq $CurrentState.AllowedSenderDomains -or $CurrentState.AllowedSenderDomains.Count -eq 0) -and ($AllowedSenderDomains.Count -eq 0)) -or ($null -ne $CurrentState.AllowedSenderDomains -and $CurrentState.AllowedSenderDomains.Count -gt 0 -and $AllowedSenderDomains.Count -gt 0 -and !(Compare-Object -ReferenceObject $CurrentState.AllowedSenderDomains -DifferenceObject $AllowedSenderDomains))) } catch { $StateIsCorrect = $false } @@ -201,19 +216,19 @@ function Invoke-CIPPStandardSpamFilterPolicy { InlineSafetyTipsEnabled = $true PhishZapEnabled = $true SpamZapEnabled = $true - AllowedSenderDomains = $Settings.AllowedSenderDomains.value ?? @{'@odata.type' = '#Exchange.GenericHashTable' } + AllowedSenderDomains = $AllowedSenderDomains.Count -gt 0 ? $AllowedSenderDomains : @{'@odata.type' = '#Exchange.GenericHashTable' } } # Remove optional block lists if not configured - if ($Settings.EnableLanguageBlockList -eq $true -and $Settings.LanguageBlockList.value) { + if ($Settings.EnableLanguageBlockList -eq $true -and $LanguageBlockList.Count -gt 0) { $cmdParams.Add('EnableLanguageBlockList', $Settings.EnableLanguageBlockList) - $cmdParams.Add('LanguageBlockList', $Settings.LanguageBlockList.value) + $cmdParams.Add('LanguageBlockList', $LanguageBlockList) } else { $cmdParams.Add('EnableLanguageBlockList', $false) } - if ($Settings.EnableRegionBlockList -eq $true -and $Settings.RegionBlockList.value) { + if ($Settings.EnableRegionBlockList -eq $true -and $RegionBlockList.Count -gt 0) { $cmdParams.Add('EnableRegionBlockList', $Settings.EnableRegionBlockList) - $cmdParams.Add('RegionBlockList', $Settings.RegionBlockList.value) + $cmdParams.Add('RegionBlockList', $RegionBlockList) } else { $cmdParams.Add('EnableRegionBlockList', $false) } @@ -309,9 +324,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { MarkAsSpamWebBugsInHtml = $CurrentState.MarkAsSpamWebBugsInHtml MarkAsSpamSensitiveWordList = $CurrentState.MarkAsSpamSensitiveWordList EnableLanguageBlockList = $CurrentState.EnableLanguageBlockList - LanguageBlockList = $CurrentState.LanguageBlockList EnableRegionBlockList = $CurrentState.EnableRegionBlockList - RegionBlockList = $CurrentState.RegionBlockList AllowedSenderDomains = $CurrentState.AllowedSenderDomains } $ExpectedValue = @{ @@ -335,11 +348,20 @@ function Invoke-CIPPStandardSpamFilterPolicy { MarkAsSpamWebBugsInHtml = $MarkAsSpamWebBugsInHtml MarkAsSpamSensitiveWordList = $MarkAsSpamSensitiveWordList EnableLanguageBlockList = $Settings.EnableLanguageBlockList - LanguageBlockList = $Settings.EnableLanguageBlockList ? @($Settings.EnableLanguageBlockList) : @() EnableRegionBlockList = $Settings.EnableRegionBlockList - RegionBlockList = $Settings.RegionBlockList.value ? @($Settings.RegionBlockList.value) : @() - AllowedSenderDomains = $Settings.AllowedSenderDomains.value ? @($Settings.AllowedSenderDomains.value) : @() + AllowedSenderDomains = $AllowedSenderDomains + } + + # Only include the block lists in the comparison when their toggle is enabled; otherwise they are ignored. + if ($Settings.EnableLanguageBlockList) { + $CurrentValue['LanguageBlockList'] = $CurrentState.LanguageBlockList + $ExpectedValue['LanguageBlockList'] = $LanguageBlockList + } + if ($Settings.EnableRegionBlockList) { + $CurrentValue['RegionBlockList'] = $CurrentState.RegionBlockList + $ExpectedValue['RegionBlockList'] = $RegionBlockList } + Set-CIPPStandardsCompareField -FieldName 'standards.SpamFilterPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } From 6c62d78ebf941f6154fa448c1070fec2f13ccb47 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:01:03 +0800 Subject: [PATCH 075/150] Blank value fixes --- .../Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 index 8ef3ef2cc58fb..2fe663123420d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 @@ -111,10 +111,10 @@ function Invoke-CIPPStandardMalwareFilterPolicy { $DefaultFileTypes = @('ace', 'ani', 'apk', 'app', 'appx', 'arj', 'bat', 'cab', 'cmd', 'com', 'deb', 'dex', 'dll', 'docm', 'elf', 'exe', 'hta', 'img', 'iso', 'jar', 'jnlp', 'kext', 'lha', 'lib', 'library', 'lnk', 'lzh', 'macho', 'msc', 'msi', 'msix', 'msp', 'mst', 'pif', 'ppa', 'ppam', 'reg', 'rev', 'scf', 'scr', 'sct', 'sys', 'uif', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'xll', 'xz', 'z') - if ($null -eq $Settings.OptionalFileTypes) { + if ([string]::IsNullOrWhiteSpace($Settings.OptionalFileTypes)) { $ExpectedFileTypes = $DefaultFileTypes } else { - $ExpectedFileTypes = $DefaultFileTypes + @($Settings.OptionalFileTypes.Split(',').Trim()) + $ExpectedFileTypes = $DefaultFileTypes + @($Settings.OptionalFileTypes.Split(',').Trim() | Where-Object { $_ }) } $FileTypeAction = $Settings.FileTypeAction.value ?? $Settings.FileTypeAction ?? 'Quarantine' From dc8b3af04deea5efe5c28c336c45a816d2424b73 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:37:05 +0800 Subject: [PATCH 076/150] Update Set-CIPPStandardsCompareField.ps1 --- .../Public/Set-CIPPStandardsCompareField.ps1 | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 index ab01757ac4e6f..2aff76ce9ac19 100644 --- a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 @@ -37,19 +37,72 @@ function Set-CIPPStandardsCompareField { return $JsonString } - if ($CurrentValue -and $CurrentValue -isnot [string]) { - $CurrentValue = [string](ConvertTo-Json -InputObject $CurrentValue -Depth 10 -Compress) + function ConvertTo-SortedObject { + param($Value) + + if ($null -eq $Value) { return $null } + + if ($Value -is [string] -or $Value -is [bool] -or $Value -is [System.ValueType]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary]) { + $Sorted = [ordered]@{} + foreach ($Key in ($Value.Keys | Sort-Object)) { + $Sorted[$Key] = ConvertTo-SortedObject -Value $Value[$Key] + } + return $Sorted + } + + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $Sorted = [ordered]@{} + foreach ($Name in ($Value.PSObject.Properties.Name | Sort-Object)) { + $Sorted[$Name] = ConvertTo-SortedObject -Value $Value.$Name + } + return $Sorted + } + + if ($Value -is [System.Collections.IEnumerable]) { + $Carriers = foreach ($Item in $Value) { + $SortedItem = ConvertTo-SortedObject -Value $Item + [PSCustomObject]@{ + SortKey = [string](ConvertTo-Json -InputObject $SortedItem -Depth 10 -Compress) + Item = $SortedItem + } + } + $Result = [System.Collections.Generic.List[object]]::new() + foreach ($Carrier in @($Carriers | Sort-Object -Property SortKey)) { + $Result.Add($Carrier.Item) + } + return , $Result.ToArray() + } + + return $Value } - if ($ExpectedValue -and $ExpectedValue -isnot [string]) { - $ExpectedValue = [string](ConvertTo-Json -InputObject $ExpectedValue -Depth 10 -Compress) + + function ConvertTo-CanonicalJsonString { + param($Value) + + if ($null -eq $Value) { return $Value } + + $ToSort = $Value + if ($Value -is [string]) { + try { + $ToSort = ConvertFrom-Json -InputObject $Value -ErrorAction Stop + } catch { + return $Value + } + } + + $Json = [string](ConvertTo-Json -InputObject (ConvertTo-SortedObject -Value $ToSort) -Depth 10 -Compress) + return ConvertTo-NormalizedJson -JsonString $Json } - # Normalize both values for consistent comparison (handle quoted numbers) if ($CurrentValue) { - $CurrentValue = ConvertTo-NormalizedJson -JsonString $CurrentValue + $CurrentValue = ConvertTo-CanonicalJsonString -Value $CurrentValue } if ($ExpectedValue) { - $ExpectedValue = ConvertTo-NormalizedJson -JsonString $ExpectedValue + $ExpectedValue = ConvertTo-CanonicalJsonString -Value $ExpectedValue } # Handle bulk operations From e21df9c9fba771170afa5c31219f0a7c10355266 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:37:40 +0800 Subject: [PATCH 077/150] Update New-ExoRequest.ps1 --- Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index 632fd0e7c6d69..f562d32c2eedc 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -48,7 +48,7 @@ function New-ExoRequest { } else { $Params = @{} } - $ExoBody = ConvertTo-Json -Depth 5 -Compress -InputObject @{ + $ExoBody = ConvertTo-Json -Depth 20 -Compress -InputObject @{ CmdletInput = @{ CmdletName = $cmdlet Parameters = $Params From 158e97324b53aa0c5e052d7f3873b004bbde2958 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:52:04 +0800 Subject: [PATCH 078/150] Update Set-CIPPSAMAdminRoles.ps1 --- .../CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 index 5ea7b643fcbc4..baf65914962df 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 @@ -82,13 +82,25 @@ function Set-CIPPSAMAdminRoles { $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -Compliance -tenantid $TenantFilter -useSystemMailbox $true -AsApp $ActionLogs.Add('Added Service Principal to Compliance Center') } catch { - $ActionLogs.Add('Service Principal already added to Compliance Center') + $SpError = $_.Exception.Message + if ($SpError -match 'already exist') { + $ActionLogs.Add('Service Principal already added to Compliance Center') + } else { + $ActionLogs.Add("Failed to add Service Principal to Compliance Center: $SpError") + $HasFailures = $true + } } try { $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp $ActionLogs.Add('Added Service Principal to Exchange Online') } catch { - $ActionLogs.Add('Service Principal already added to Exchange Online') + $SpError = $_.Exception.Message + if ($SpError -match 'already exist') { + $ActionLogs.Add('Service Principal already added to Exchange Online') + } else { + $ActionLogs.Add("Failed to add Service Principal to Exchange Online: $SpError") + $HasFailures = $true + } } Write-Verbose ($Requests | ConvertTo-Json -Depth 5) From e6c0c93708121242ababe641cb2586ed06b41ecb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:19:41 +0800 Subject: [PATCH 079/150] Update Set-CIPPSAMAdminRoles.ps1 --- Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 index baf65914962df..847b49dab1e8e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 @@ -91,8 +91,14 @@ function Set-CIPPSAMAdminRoles { } } try { - $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp - $ActionLogs.Add('Added Service Principal to Exchange Online') + try { + $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true + $ActionLogs.Add('Added Service Principal to Exchange Online (delegated)') + } catch { + if ($_.Exception.Message -match 'already exist') { throw } + $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp + $ActionLogs.Add('Added Service Principal to Exchange Online (app-only fallback)') + } } catch { $SpError = $_.Exception.Message if ($SpError -match 'already exist') { From 67856c49b19543d26b3789f2ad38149c04b6613a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:35:08 +0800 Subject: [PATCH 080/150] Update New-ExoRequest.ps1 --- Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index f562d32c2eedc..d5d32a2a2cf4d 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -160,6 +160,15 @@ function New-ExoRequest { } elseif ($ReportedError.error.message) { $ReportedError.error.message } } catch { $Message = $_.ErrorDetails } if ($null -eq $Message) { $Message = $ErrorMess } + if ($ResponseHeaders -and $ResponseHeaders['WWW-Authenticate']) { + $WwwAuth = $ResponseHeaders['WWW-Authenticate'] + if ($WwwAuth -is [array]) { $WwwAuth = $WwwAuth -join '; ' } + if ($WwwAuth -match 'error_description="([^"]+)"') { + $Message = "$Message (EXO auth: $($Matches[1]))" + } elseif (-not [string]::IsNullOrWhiteSpace($WwwAuth)) { + $Message = "$Message (WWW-Authenticate: $WwwAuth)" + } + } throw $Message } return $ReturnedData.value From 42087aefc622c764c0afcb01203bdda9cc19d1cb Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 15 Jun 2026 14:25:39 -0400 Subject: [PATCH 081/150] feat: Add message trace retrieval to Push-BECRun function --- .../Activity Triggers/BEC/Push-BECRun.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index f4cc20ab450b1..b933239db4136 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -103,6 +103,21 @@ function Push-BECRun { $RulesLog = @() } + Write-Information 'Getting sent message trace' + try { + $MessageTraceParams = @{ + SenderAddress = $UserName + StartDate = $startDate.ToString('s') + EndDate = $endDate.ToString('s') + } + $SentMessages = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MessageTraceV2' -cmdParams $MessageTraceParams -Anchor $UserName | + Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) + } catch { + $SentMessages = @() + $CippTraceError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve message trace for $($UserName): $($CippTraceError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippTraceError + } + Write-Information 'Getting last 50 logons' try { $Last50Logons = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userDisplayName ne 'On-Premises Directory Synchronization Service Account'&`$top=50&`$orderby=createdDateTime desc" -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = { $(($_.createdDateTime | Out-String) -replace '\r\n') } }, @@ -155,6 +170,7 @@ function Push-BECRun { LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) + SentMessages = @($SentMessages) MailboxPermissionChanges = @($PermissionsLog) NewUsers = @($NewUsers) MFADevices = @($MFADevices | Where-Object { $_.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' }) From f18525b6bfdda946a30ade0521cf39633b377a76 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:46:20 +0200 Subject: [PATCH 082/150] feat: configure auth method sub-settings The SetAuthMethod endpoint only toggled state and group targeting, so method-specific options (TAP lifetimes/usable-once, Authenticator feature settings, Email OTP external/exclude, QR code, FIDO2 attestation and self-service, Voice office phone, SMS sign-in) could not be managed from the portal. Forward those settings through the handler, honor them in Set-CIPPAuthenticationPolicy, treat an explicit empty Email exclude list as a clear, and add Pester coverage. --- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 44 ++++-- .../Administration/Invoke-SetAuthMethod.ps1 | 14 ++ .../Set-CIPPAuthenticationPolicy.Tests.ps1 | 129 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index cd6d7d7d8c185..e21035825f43a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -18,6 +18,10 @@ function Set-CIPPAuthenticationPolicy { [Parameter()][ValidateRange(8, 20)]$QRCodePinLength = 8, [Parameter()][ValidateSet('default', 'enabled', 'disabled')]$EmailAllowExternalIdToUseEmailOtp, [Parameter()][string[]]$EmailExcludeGroupIds, + [Parameter()][bool]$FIDO2AttestationEnforced, + [Parameter()][bool]$FIDO2SelfServiceRegistration, + [Parameter()][bool]$VoiceIsOfficePhoneAllowed, + [Parameter()][bool]$SmsIsUsableForSignIn, $APIName = 'Set Authentication Policy', $Headers ) @@ -39,37 +43,52 @@ function Set-CIPPAuthenticationPolicy { # FIDO2 'FIDO2' { if ($State -eq 'enabled') { - $CurrentInfo.isAttestationEnforced = $true - $CurrentInfo.isSelfServiceRegistrationAllowed = $true + # Honor passed values; otherwise default to enforced/allowed to preserve previous enable behavior + $CurrentInfo.isAttestationEnforced = if ($PSBoundParameters.ContainsKey('FIDO2AttestationEnforced')) { $FIDO2AttestationEnforced } else { $true } + $CurrentInfo.isSelfServiceRegistrationAllowed = if ($PSBoundParameters.ContainsKey('FIDO2SelfServiceRegistration')) { $FIDO2SelfServiceRegistration } else { $true } + $OptionalLogMessage = "with attestation enforced set to $($CurrentInfo.isAttestationEnforced) and self-service registration set to $($CurrentInfo.isSelfServiceRegistrationAllowed)" } } # Microsoft Authenticator 'MicrosoftAuthenticator' { if ($State -eq 'enabled') { + $AuthChanges = [System.Collections.Generic.List[string]]::new() # Set MS authenticator OTP state if parameter is passed in if ($null -ne $MicrosoftAuthenticatorSoftwareOathEnabled) { $CurrentInfo.isSoftwareOathEnabled = $MicrosoftAuthenticatorSoftwareOathEnabled - $OptionalLogMessage = "and MS Authenticator software OTP to $MicrosoftAuthenticatorSoftwareOathEnabled" + $AuthChanges.Add("software OTP set to $MicrosoftAuthenticatorSoftwareOathEnabled") } # Feature settings if ($MicrosoftAuthenticatorDisplayAppInfo) { $CurrentInfo.featureSettings.displayAppInformationRequiredState.state = $MicrosoftAuthenticatorDisplayAppInfo + $AuthChanges.Add("display app information set to $MicrosoftAuthenticatorDisplayAppInfo") } if ($MicrosoftAuthenticatorDisplayLocation) { $CurrentInfo.featureSettings.displayLocationInformationRequiredState.state = $MicrosoftAuthenticatorDisplayLocation + $AuthChanges.Add("display location set to $MicrosoftAuthenticatorDisplayLocation") } if ($MicrosoftAuthenticatorCompanionApp) { $CurrentInfo.featureSettings.companionAppAllowedState.state = $MicrosoftAuthenticatorCompanionApp + $AuthChanges.Add("companion app set to $MicrosoftAuthenticatorCompanionApp") } # numberMatchingRequiredState is permanently enabled by Microsoft and can no longer be toggled $CurrentInfo.featureSettings.PSObject.Properties.Remove('numberMatchingRequiredState') + if ($AuthChanges.Count -gt 0) { + $OptionalLogMessage = "with $($AuthChanges -join ', ')" + } } } # SMS 'SMS' { - # No special configuration needed + # SMS sign-in is set per include-target (smsAuthenticationMethodTarget.isUsableForSignIn) + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('SmsIsUsableForSignIn')) { + foreach ($Target in $CurrentInfo.includeTargets) { + $Target | Add-Member -NotePropertyName 'isUsableForSignIn' -NotePropertyValue $SmsIsUsableForSignIn -Force + } + $OptionalLogMessage = "with SMS sign-in set to $SmsIsUsableForSignIn" + } } # Temporary Access Pass @@ -80,7 +99,7 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.maximumLifetimeInMinutes = $TAPMaximumLifetime $CurrentInfo.defaultLifetimeInMinutes = $TAPDefaultLifeTime $CurrentInfo.defaultLength = $TAPDefaultLength - $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce" + $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce, minimum lifetime $TAPMinimumLifetime min, maximum lifetime $TAPMaximumLifetime min, default lifetime $TAPDefaultLifeTime min, and default length $TAPDefaultLength" } } @@ -96,7 +115,10 @@ function Set-CIPPAuthenticationPolicy { # Voice call 'Voice' { - # No special configuration needed + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('VoiceIsOfficePhoneAllowed')) { + $CurrentInfo.isOfficePhoneAllowed = $VoiceIsOfficePhoneAllowed + $OptionalLogMessage = "with isOfficePhoneAllowed set to $VoiceIsOfficePhoneAllowed" + } } # Email OTP @@ -106,7 +128,8 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.allowExternalIdToUseEmailOtp = $EmailAllowExternalIdToUseEmailOtp $OptionalLogMessage = "with allowExternalIdToUseEmailOtp set to $EmailAllowExternalIdToUseEmailOtp" } - if ($EmailExcludeGroupIds) { + # Present (even empty) means the caller is setting the exclude list; an empty array clears it + if ($PSBoundParameters.ContainsKey('EmailExcludeGroupIds')) { $CurrentInfo.excludeTargets = @( foreach ($id in $EmailExcludeGroupIds) { [pscustomobject]@{ @@ -115,7 +138,11 @@ function Set-CIPPAuthenticationPolicy { } } ) - $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + if ($EmailExcludeGroupIds) { + $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + } else { + $OptionalLogMessage += ' and excluded groups cleared' + } } } } @@ -130,6 +157,7 @@ function Set-CIPPAuthenticationPolicy { if ($State -eq 'enabled') { $CurrentInfo.standardQRCodeLifetimeInDays = $QRCodeLifetimeInDays $CurrentInfo.pinLength = $QRCodePinLength + $OptionalLogMessage = "with QR code lifetime $QRCodeLifetimeInDays days and PIN length $QRCodePinLength" } } default { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 index 71b5adf5984dc..d179814f4f3d0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 @@ -28,6 +28,20 @@ function Invoke-SetAuthMethod { if ($GroupIds) { $Params.GroupIds = @($GroupIds) } + # Forward any method-specific sub-settings present in the body (omitted ones keep current/default values) + $OptionalSettings = @( + 'TAPisUsableOnce', 'TAPMinimumLifetime', 'TAPMaximumLifetime', 'TAPDefaultLifeTime', 'TAPDefaultLength' + 'MicrosoftAuthenticatorSoftwareOathEnabled', 'MicrosoftAuthenticatorDisplayAppInfo', 'MicrosoftAuthenticatorDisplayLocation', 'MicrosoftAuthenticatorCompanionApp' + 'EmailAllowExternalIdToUseEmailOtp', 'EmailExcludeGroupIds' + 'QRCodeLifetimeInDays', 'QRCodePinLength' + 'FIDO2AttestationEnforced', 'FIDO2SelfServiceRegistration', 'VoiceIsOfficePhoneAllowed' + 'SmsIsUsableForSignIn' + ) + foreach ($Setting in $OptionalSettings) { + if ($null -ne $Request.Body.$Setting) { + $Params.$Setting = $Request.Body.$Setting + } + } $Result = Set-CIPPAuthenticationPolicy @Params $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 new file mode 100644 index 0000000000000..c5402c179623e --- /dev/null +++ b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Set-CIPPAuthenticationPolicy +# Validates that method-specific sub-settings are written to the Graph PATCH body + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1' + + # Mock returns whatever the current test stored, simulating the existing Graph config + function New-GraphGetRequest { param($Uri, $tenantid, $AsApp) return $script:mockCurrentInfo } + function New-GraphPostRequest { + param($tenantid, $Uri, $Type, $Body, $ContentType, $AsApp) + $script:lastBody = $Body + } + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + + . $FunctionPath +} + +Describe 'Set-CIPPAuthenticationPolicy' { + BeforeEach { + $script:lastBody = $null + $script:mockCurrentInfo = $null + } + + It 'writes Temporary Access Pass sub-settings to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isUsableOnce = $true + minimumLifetimeInMinutes = 10 + maximumLifetimeInMinutes = 20 + defaultLifetimeInMinutes = 15 + defaultLength = 8 + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'TemporaryAccessPass' ` + -Enabled $true -TAPisUsableOnce $false -TAPDefaultLength 12 -TAPDefaultLifeTime 90 + + $body = $script:lastBody | ConvertFrom-Json + $body.state | Should -Be 'enabled' + $body.isUsableOnce | Should -Be $false + $body.defaultLength | Should -Be 12 + $body.defaultLifetimeInMinutes | Should -Be 90 + } + + It 'honors the FIDO2 attestation/self-service parameters instead of forcing true' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $true + isSelfServiceRegistrationAllowed = $true + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' ` + -Enabled $true -FIDO2AttestationEnforced $false -FIDO2SelfServiceRegistration $false + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $false + $body.isSelfServiceRegistrationAllowed | Should -Be $false + } + + It 'defaults FIDO2 to enforced/allowed when no parameters are passed' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $false + isSelfServiceRegistrationAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' -Enabled $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $true + $body.isSelfServiceRegistrationAllowed | Should -Be $true + } + + It 'scopes the method to all users when GroupIds contains all_users' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @() + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'softwareOath' ` + -Enabled $true -GroupIds @('all_users') + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].targetType | Should -Be 'group' + $body.includeTargets[0].id | Should -Be 'all_users' + } + + It 'stamps SMS isUsableForSignIn onto every include-target' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @( + [pscustomobject]@{ targetType = 'group'; id = 'all_users' } + ) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'SMS' ` + -Enabled $true -SmsIsUsableForSignIn $true + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].isUsableForSignIn | Should -Be $true + } + + It 'clears Email exclude targets when an empty group list is supplied' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + excludeTargets = @([pscustomobject]@{ targetType = 'group'; id = 'old-group' }) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Email' ` + -Enabled $true -EmailExcludeGroupIds @() + + $body = $script:lastBody | ConvertFrom-Json + @($body.excludeTargets).Count | Should -Be 0 + } + + It 'writes the Voice isOfficePhoneAllowed setting to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isOfficePhoneAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Voice' ` + -Enabled $true -VoiceIsOfficePhoneAllowed $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isOfficePhoneAllowed | Should -Be $true + } +} From fe8725269e9025ec2876a51aba86c6560f31dcec Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:02:20 +0800 Subject: [PATCH 083/150] Revert "Update New-ExoRequest.ps1" This reverts commit 67856c49b19543d26b3789f2ad38149c04b6613a. --- Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index d5d32a2a2cf4d..f562d32c2eedc 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -160,15 +160,6 @@ function New-ExoRequest { } elseif ($ReportedError.error.message) { $ReportedError.error.message } } catch { $Message = $_.ErrorDetails } if ($null -eq $Message) { $Message = $ErrorMess } - if ($ResponseHeaders -and $ResponseHeaders['WWW-Authenticate']) { - $WwwAuth = $ResponseHeaders['WWW-Authenticate'] - if ($WwwAuth -is [array]) { $WwwAuth = $WwwAuth -join '; ' } - if ($WwwAuth -match 'error_description="([^"]+)"') { - $Message = "$Message (EXO auth: $($Matches[1]))" - } elseif (-not [string]::IsNullOrWhiteSpace($WwwAuth)) { - $Message = "$Message (WWW-Authenticate: $WwwAuth)" - } - } throw $Message } return $ReturnedData.value From ad7e40911d2350e26a40edd036423346552db180 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:02:24 +0800 Subject: [PATCH 084/150] Revert "Update Set-CIPPSAMAdminRoles.ps1" This reverts commit e6c0c93708121242ababe641cb2586ed06b41ecb. --- Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 index 847b49dab1e8e..baf65914962df 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 @@ -91,14 +91,8 @@ function Set-CIPPSAMAdminRoles { } } try { - try { - $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true - $ActionLogs.Add('Added Service Principal to Exchange Online (delegated)') - } catch { - if ($_.Exception.Message -match 'already exist') { throw } - $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp - $ActionLogs.Add('Added Service Principal to Exchange Online (app-only fallback)') - } + $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp + $ActionLogs.Add('Added Service Principal to Exchange Online') } catch { $SpError = $_.Exception.Message if ($SpError -match 'already exist') { From b6b918eea642011ad5b04d3ea65d344706045cc8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:23:03 +0800 Subject: [PATCH 085/150] permission repair improvements --- .../GraphHelper/Get-CippSamPermissions.ps1 | 241 ++++++++++++------ .../SAMManifest/Update-CippSamPermissions.ps1 | 109 +++++--- .../Settings/Invoke-ExecSAMAppPermissions.ps1 | 52 +++- Shared/CIPPSharp/CIPPRestClient.cs | 31 ++- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 42496 -> 43008 bytes 5 files changed, 303 insertions(+), 130 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 620f36a0c2105..39b3e22721501 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -4,11 +4,21 @@ function Get-CippSamPermissions { This script retrieves the CIPP-SAM permissions. .DESCRIPTION - The Get-CippSamManifest function is used to retrieve the CIPP-SAM permissions either from the manifest files or table. + Retrieves the CIPP-SAM permissions as a layered set: the permissions defined in the SAM manifest + files (SAMManifest.json + AdditionalPermissions.json) are ALWAYS treated as the required base and + can never be removed. Any permissions saved in the AppPermissions table are treated as EXTRAS that + are layered on top of (not instead of) the manifest base. + + The effective set returned in .Permissions is therefore always manifest ∪ extras. Each permission + is annotated with a 'required' boolean so the UI can lock the manifest-defined defaults. + + Unless -NoDiff is used, the function also pulls the live CIPP-SAM application registration from the + partner tenant and diffs its requiredResourceAccess against the effective set, surfacing + permissions that need to be added to (MissingPermissions) and removed from (PartnerAppDiff) the app. .EXAMPLE - Get-CippSamManifest - Retrieves the CIPP SAM manifest located in the module root + Get-CippSamPermissions + Returns the effective permission set plus the partner app drift diff. .FUNCTIONALITY Internal @@ -23,6 +33,8 @@ function Get-CippSamPermissions { [switch]$NoDiff ) + $GuidRegex = '^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$' + if (!$SavedOnly.IsPresent) { # Return cached result if available and less than 5 minutes old (avoids duplicate partner-tenant Graph calls within same invocation) if ($NoDiff.IsPresent -and $script:CippSamPermissionsCache -and @@ -32,12 +44,12 @@ function Get-CippSamPermissions { } $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') - $AdditionalPermissions = Get-Item (Join-Path $env:CIPPRootPath 'Config\AdditionalPermissions.json') + $AdditionalPermissionsFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\AdditionalPermissions.json') $ServicePrincipalList = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/servicePrincipals?$top=999&$select=id,appId,displayName' -tenantid $env:TenantID -NoAuthCheck $true $SAMManifest = Get-Content -Path $SamManifestFile.FullName | ConvertFrom-Json - $AdditionalPermissions = Get-Content -Path $AdditionalPermissions.FullName | ConvertFrom-Json + $AdditionalPermissions = Get-Content -Path $AdditionalPermissionsFile.FullName | ConvertFrom-Json $RequiredResources = $SAMManifest.requiredResourceAccess @@ -59,14 +71,15 @@ function Get-CippSamPermissions { $_.body } - $Permissions = @{} + # Build the manifest (required / default) permission set. These are immutable and always required. + $ManifestPermissions = @{} foreach ($AppId in $AppIds) { $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId $AppPermissions = [System.Collections.Generic.List[object]]@() - $ManifestPermissions = ($RequiredResources | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess + $ManifestResourceAccess = ($RequiredResources | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess $UnpublishedPermissions = ($AdditionalPermissions | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess - foreach ($Permission in $ManifestPermissions) { + foreach ($Permission in $ManifestResourceAccess) { $AppPermissions.Add($Permission) } if ($UnpublishedPermissions) { @@ -75,10 +88,10 @@ function Get-CippSamPermissions { } } - $ApplicationPermissions = [system.collections.generic.list[object]]@() - $DelegatedPermissions = [system.collections.generic.list[object]]@() + $ApplicationPermissions = [System.Collections.Generic.List[object]]@() + $DelegatedPermissions = [System.Collections.Generic.List[object]]@() foreach ($Permission in $AppPermissions) { - if ($Permission.id -match '^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$') { + if ($Permission.id -match $GuidRegex) { if ($Permission.type -eq 'Role') { $PermissionName = ($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Permission.id).value } else { @@ -88,112 +101,175 @@ function Get-CippSamPermissions { $PermissionName = $Permission.id } + $Entry = [PSCustomObject]@{ + id = $Permission.id + value = $PermissionName + required = $true + } if ($Permission.type -eq 'Role') { - $ApplicationPermissions.Add([PSCustomObject]@{ - id = $Permission.id - value = $PermissionName - - }) + $ApplicationPermissions.Add($Entry) } else { - $DelegatedPermissions.Add([PSCustomObject]@{ - id = $Permission.id - value = $PermissionName - }) + $DelegatedPermissions.Add($Entry) } } - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $Permissions.$AppId = @{ - applicationPermissions = @($ApplicationPermissions | Sort-Object -Property label) - delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property label) + $ManifestPermissions.$AppId = @{ + applicationPermissions = @($ApplicationPermissions | Sort-Object -Property value) + delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property value) } } } + if ($ManifestOnly) { return [PSCustomObject]@{ - Permissions = $Permissions + Permissions = [PSCustomObject]$ManifestPermissions Type = 'Manifest' } } + # Load the saved EXTRA permissions (layered on top of the manifest base) $Table = Get-CippTable -tablename 'AppPermissions' - $SavedPermissions = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" - if ($SavedPermissions.Permissions) { + $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if ($SavedRow.Permissions) { try { - $SavedPermissions.Permissions = $SavedPermissions.Permissions | ConvertFrom-Json -ErrorAction Stop + $SavedPermissions = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop } catch { - $SavedPermissions.Permissions = [PSCustomObject]@{} + $SavedPermissions = [PSCustomObject]@{} } } else { - $SavedPermissions = @{ - Permissions = [PSCustomObject]@{} - } + $SavedPermissions = [PSCustomObject]@{} } if ($SavedOnly.IsPresent) { - $SavedPermissions | Add-Member -MemberType NoteProperty -Name Type -Value 'Table' - return $SavedPermissions + return [PSCustomObject]@{ + Permissions = $SavedPermissions + Type = 'Table' + } } - if (!$NoDiff.IsPresent -and $SavedPermissions.Permissions) { - $DiffPermissions = @{} - foreach ($AppId in $AppIds) { - $ManifestSpPermissions = $Permissions.$AppId - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $SavedSpPermission = $SavedPermissions.Permissions.$AppId - $MissingApp = [System.Collections.Generic.List[object]]::new() - $MissingDelegated = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $ManifestSpPermissions.applicationPermissions) { - if ($SavedSpPermission.applicationPermissions.id -notcontains $Permission.id) { - $AppRole = $ServicePrincipal.appRoles | Where-Object -Property id -EQ $Permission.id | Select-Object id, value - $MissingApp.Add($AppRole ?? $Permission) - } + # Build the effective set = manifest (required) ∪ saved extras (required = false). + # Manifest permissions are always present, so a stale/edited saved set can never drop a required scope. + $EffectivePermissions = @{} + $AdditionalOnly = @{} + $AllAppIds = @(@($ManifestPermissions.Keys) + @($SavedPermissions.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + + foreach ($AppId in $AllAppIds) { + $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $SavedPermissions.$AppId + + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $EffApp = [System.Collections.Generic.List[object]]::new() + $EffDel = [System.Collections.Generic.List[object]]::new() + $ExtraApp = [System.Collections.Generic.List[object]]::new() + $ExtraDel = [System.Collections.Generic.List[object]]::new() + + foreach ($Permission in $ManifestApp.applicationPermissions) { $EffApp.Add($Permission) } + foreach ($Permission in $ManifestApp.delegatedPermissions) { $EffDel.Add($Permission) } + + foreach ($Permission in $SavedApp.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $Extra = [PSCustomObject]@{ id = $Permission.id; value = $Permission.value; required = $false } + $EffApp.Add($Extra) + $ExtraApp.Add($Extra) } - foreach ($Permission in $ManifestSpPermissions.delegatedPermissions) { - if ($SavedSpPermission.delegatedPermissions.id -notcontains $Permission.id) { - $PermissionScope = $ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Permission.id | Select-Object id, value - $MissingDelegated.Add($PermissionScope ?? $Permission) - } + } + foreach ($Permission in $SavedApp.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $Extra = [PSCustomObject]@{ id = $Permission.id; value = $Permission.value; required = $false } + $EffDel.Add($Extra) + $ExtraDel.Add($Extra) } - if ($MissingApp -or $MissingDelegated) { - $DiffPermissions.$AppId = @{ - applicationPermissions = $MissingApp - delegatedPermissions = $MissingDelegated - } + } + + $EffectivePermissions.$AppId = @{ + applicationPermissions = @($EffApp | Sort-Object -Property value) + delegatedPermissions = @($EffDel | Sort-Object -Property value) + } + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $AdditionalOnly.$AppId = @{ + applicationPermissions = @($ExtraApp) + delegatedPermissions = @($ExtraDel) } } } - $SamAppPermissions = @{} - if (($SavedPermissions.Permissions.PSObject.Properties.Name | Measure-Object).Count -gt 0) { - $SamAppPermissions.Permissions = $SavedPermissions.Permissions - $SamAppPermissions.UsedServicePrincipals = $UsedServicePrincipals - $SamAppPermissions.UpdatedBy = $SavedPermissions.UpdatedBy - $SamAppPermissions.Timestamp = $SavedPermissions.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') - $SamAppPermissions.Type = 'Table' - } else { - $SamAppPermissions.Permissions = $Permissions - $SamAppPermissions.UsedServicePrincipals = $UsedServicePrincipals - $SamAppPermissions.Type = 'Manifest' - $SamAppPermissions.UpdatedBy = 'CIPP' - $SamAppPermissions.Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - - $Entity = @{ - 'PartitionKey' = 'CIPP-SAM' - 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Permissions | ConvertTo-Json -Depth 10 -Compress) - 'UpdatedBy' = 'CIPP' - } - $Table = Get-CIPPTable -TableName 'AppPermissions' + # Diff the effective set against the live CIPP-SAM application registration in the partner tenant. + # MissingPermissions = effective perms not yet on the app (need to be added). + # PartnerAppDiff also surfaces extra perms on the app that are not in the effective set (need to be removed). + $MissingPermissions = @{} + $PartnerAppDiff = @{} + if (!$NoDiff.IsPresent) { try { - $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true + foreach ($AppId in $AllAppIds) { + $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId + $AppRegResource = $PartnerApp.requiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId + $AppRegRoleIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Role' }).id) + $AppRegScopeIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Scope' }).id) + + # Only GUID-based permissions live in the app registration's requiredResourceAccess. + # String-named scopes (e.g. the .Sdp AdditionalPermissions) are applied as direct grants, + # so excluding them here avoids permanent false-positive "missing" entries. + $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) + $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions | Where-Object { $_.id -match $GuidRegex }) + $EffAppIds = @($EffApp.id) + $EffDelIds = @($EffDel.id) + + $MissingApp = @(foreach ($Permission in $EffApp) { if ($AppRegRoleIds -notcontains $Permission.id) { $Permission } }) + $MissingDel = @(foreach ($Permission in $EffDel) { if ($AppRegScopeIds -notcontains $Permission.id) { $Permission } }) + $ExtraApp = @(foreach ($Id in $AppRegRoleIds) { + if ($EffAppIds -notcontains $Id) { + [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } + } + }) + $ExtraDel = @(foreach ($Id in $AppRegScopeIds) { + if ($EffDelIds -notcontains $Id) { + [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Id).value) ?? $Id } + } + }) + + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { + $MissingPermissions.$AppId = @{ + applicationPermissions = $MissingApp + delegatedPermissions = $MissingDel + } + } + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0 -or $ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $PartnerAppDiff.$AppId = @{ + missingApplicationPermissions = $MissingApp + missingDelegatedPermissions = $MissingDel + extraApplicationPermissions = $ExtraApp + extraDelegatedPermissions = $ExtraDel + } + } + } } catch { - Write-Error "Failed to save the CIPP-SAM permissions: $($_.Exception.Message)" + Write-Information "Failed to retrieve partner app registration for permission diff: $($_.Exception.Message)" } } - if (!$NoDiff.IsPresent -and $SamAppPermissions.Type -eq 'Table') { - $SamAppPermissions.MissingPermissions = $DiffPermissions + $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + if ($SavedRow.Timestamp) { + $SavedTimestamp = $SavedRow.Timestamp.DateTime.ToUniversalTime() + if ($SavedTimestamp -gt $Timestamp) { + $Timestamp = $SavedTimestamp + } + } + + $HasSaved = ($SavedPermissions.PSObject.Properties.Name | Measure-Object).Count -gt 0 + + $SamAppPermissions = [PSCustomObject]@{ + Permissions = [PSCustomObject]$EffectivePermissions + DefaultPermissions = [PSCustomObject]$ManifestPermissions + AdditionalPermissions = [PSCustomObject]$AdditionalOnly + MissingPermissions = [PSCustomObject]$MissingPermissions + PartnerAppDiff = [PSCustomObject]$PartnerAppDiff + UsedServicePrincipals = $UsedServicePrincipals + Type = if ($HasSaved) { 'Table' } else { 'Manifest' } + UpdatedBy = $SavedRow.UpdatedBy ?? 'CIPP' + Timestamp = $Timestamp.ToString('yyyy-MM-ddTHH:mm:ssZ') } $SamAppPermissions = $SamAppPermissions | ConvertTo-Json -Depth 10 -Compress | ConvertFrom-Json @@ -205,4 +281,3 @@ function Get-CippSamPermissions { return $SamAppPermissions } - diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index c63ff4872094e..226e9b9945422 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,9 +1,15 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Updates CIPP-SAM app permissions by merging missing permissions. + Repairs the CIPP-SAM app registration permissions in the partner tenant. .DESCRIPTION - Retrieves current SAM permissions, merges any missing permissions, and updates the AppPermissions table. + Diffs the effective CIPP-SAM permission set (manifest defaults + saved extras) against the live + CIPP-SAM application registration in the partner tenant and ADDS any missing permissions to the + app registration's requiredResourceAccess. This is additive only: it never removes permissions, + so it cannot strip a legitimately-configured entry. Extra permissions found on the app that are + not part of the effective set are reported back so an admin can review/remove them manually. + + Pushing these permissions out to customer tenants is handled separately by the CPV refresh. .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -17,62 +23,85 @@ function Update-CippSamPermissions { try { $CurrentPermissions = Get-CippSamPermissions + $PartnerAppDiff = $CurrentPermissions.PartnerAppDiff + $MissingPermissions = $CurrentPermissions.MissingPermissions - if (($CurrentPermissions.MissingPermissions | Measure-Object).Count -eq 0) { + $MissingAppIds = @($MissingPermissions.PSObject.Properties.Name) + $ExtraAppIds = @($PartnerAppDiff.PSObject.Properties.Name | Where-Object { + ($PartnerAppDiff.$_.extraApplicationPermissions | Measure-Object).Count -gt 0 -or + ($PartnerAppDiff.$_.extraDelegatedPermissions | Measure-Object).Count -gt 0 + }) + + if ($MissingAppIds.Count -eq 0) { + if ($ExtraAppIds.Count -gt 0) { + $ExtraSummary = foreach ($AppId in $ExtraAppIds) { + $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) + "$AppId ($($Names -join ', '))" + } + return "No missing permissions to add. The following extra permissions are present on the app and should be reviewed/removed manually: $($ExtraSummary -join '; ')" + } return 'No permissions to update' } - Write-Information 'Missing permissions found' - $MissingPermissions = $CurrentPermissions.MissingPermissions - $Permissions = $CurrentPermissions.Permissions - - $AppIds = @($Permissions.PSObject.Properties.Name + $MissingPermissions.PSObject.Properties.Name) - $NewPermissions = @{} - - foreach ($AppId in $AppIds) { - if (!$AppId) { continue } - $ApplicationPermissions = [system.collections.generic.list[object]]::new() - $DelegatedPermissions = [system.collections.generic.list[object]]::new() + # Retrieve the live CIPP-SAM application registration in the partner tenant. + $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=id,requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true - foreach ($Permission in $Permissions.$AppId.applicationPermissions) { - $ApplicationPermissions.Add($Permission) + $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() + foreach ($Resource in $PartnerApp.requiredResourceAccess) { + $ResourceAccess = [System.Collections.Generic.List[object]]::new() + foreach ($Access in $Resource.resourceAccess) { + $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) } - if (($MissingPermissions.$AppId.applicationPermissions | Measure-Object).Count -gt 0) { - foreach ($MissingPermission in $MissingPermissions.$AppId.applicationPermissions) { - Write-Host "Adding missing permission: $MissingPermission" - $ApplicationPermissions.Add($MissingPermission) + $RequiredResourceAccess.Add([PSCustomObject]@{ + resourceAppId = $Resource.resourceAppId + resourceAccess = $ResourceAccess + }) + } + + $AddedPermissions = [System.Collections.Generic.List[string]]::new() + foreach ($AppId in $MissingAppIds) { + $Resource = $RequiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId | Select-Object -First 1 + if (!$Resource) { + $Resource = [PSCustomObject]@{ + resourceAppId = $AppId + resourceAccess = [System.Collections.Generic.List[object]]::new() } + $RequiredResourceAccess.Add($Resource) } + $ExistingIds = @($Resource.resourceAccess.id) - foreach ($Permission in $Permissions.$AppId.delegatedPermissions) { - $DelegatedPermissions.Add($Permission) - } - if (($MissingPermissions.$AppId.delegatedPermissions | Measure-Object).Count -gt 0) { - foreach ($MissingPermission in $MissingPermissions.$AppId.delegatedPermissions) { - Write-Host "Adding missing permission: $MissingPermission" - $DelegatedPermissions.Add($MissingPermission) + foreach ($Permission in $MissingPermissions.$AppId.applicationPermissions) { + if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { + $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Role' }) + $AddedPermissions.Add("$($Permission.value) (Application)") } } - - $NewPermissions.$AppId = @{ - applicationPermissions = @($ApplicationPermissions | Sort-Object -Property label) - delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property label) + foreach ($Permission in $MissingPermissions.$AppId.delegatedPermissions) { + if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { + $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Scope' }) + $AddedPermissions.Add("$($Permission.value) (Delegated)") + } } } - $Entity = @{ - 'PartitionKey' = 'CIPP-SAM' - 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$NewPermissions | ConvertTo-Json -Depth 10 -Compress) - 'UpdatedBy' = $UpdatedBy + if ($AddedPermissions.Count -eq 0) { + return 'No permissions to update' } - $Table = Get-CIPPTable -TableName 'AppPermissions' - $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $PatchBody = @{ requiredResourceAccess = @($RequiredResourceAccess) } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/applications/$($PartnerApp.id)" -tenantid $env:TenantID -body $PatchBody -type PATCH -NoAuthCheck $true - Write-LogMessage -API 'UpdateCippSamPermissions' -message 'CIPP-SAM Permissions Updated' -Sev 'Info' -LogData $NewPermissions + Write-LogMessage -API 'UpdateCippSamPermissions' -message "CIPP-SAM app registration permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Added = $AddedPermissions } - return 'Permissions Updated' + $Result = "Added $($AddedPermissions.Count) missing permission(s) to the CIPP-SAM app registration: $($AddedPermissions -join ', '). Run a CPV refresh to apply these to customer tenants." + if ($ExtraAppIds.Count -gt 0) { + $ExtraSummary = foreach ($AppId in $ExtraAppIds) { + $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) + "$AppId ($($Names -join ', '))" + } + $Result += " Extra permissions present on the app that should be reviewed/removed manually: $($ExtraSummary -join '; ')." + } + return $Result } catch { throw "Failed to update permissions: $($_.Exception.Message)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 index 1421a72603010..9c7885ab908a1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 @@ -13,19 +13,63 @@ function Invoke-ExecSAMAppPermissions { switch ($Request.Query.Action) { 'Update' { try { - $Permissions = $Request.Body.Permissions + $Submitted = $Request.Body.Permissions + $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions + + $Extras = @{} + foreach ($AppId in $Submitted.PSObject.Properties.Name) { + $ManifestApp = $ManifestPermissions.$AppId + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $ExtraApp = @(foreach ($Permission in $Submitted.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } + } + }) + $ExtraDel = @(foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } + } + }) + + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $Extras.$AppId = @{ + applicationPermissions = $ExtraApp + delegatedPermissions = $ExtraDel + } + } + } + $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]($Permissions | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $User.UserDetails ?? 'CIPP-API' } $Table = Get-CIPPTable -TableName 'AppPermissions' $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force $Body = @{ - 'Results' = 'Permissions Updated' + 'Results' = 'Additional permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' + } + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM additional permissions updated' -Sev 'Info' -LogData $Extras + } catch { + $Body = @{ + 'Results' = $_.Exception.Message + } + } + } + 'Reset' { + try { + $Table = Get-CIPPTable -TableName 'AppPermissions' + $Existing = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if ($Existing) { + $null = Remove-AzDataTableEntity @Table -Entity $Existing -Force + } + $Body = @{ + 'Results' = 'Permissions reset to CIPP defaults.' } - Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM Permissions Updated' -Sev 'Info' -LogData $Permissions + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM permissions reset to CIPP defaults' -Sev 'Info' } catch { $Body = @{ 'Results' = $_.Exception.Message diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 70a610110c128..08c5a60fde372 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -609,9 +610,33 @@ public static async Task SendAsync( using (response) { var statusCode = (int)response.StatusCode; - var content = response.Content is not null - ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) - : string.Empty; + + // Read the body defensively. With AutomaticDecompression enabled, ReadAsStringAsync can throw + // (InvalidDataException "...unsupported compression method", IOException, ...) when a response + // carries a Content-Encoding the handler cannot decode or a malformed/mislabeled body. Such a + // read failure must NOT mask the HTTP status: for an error response we surface the status code + // (the actionable signal - e.g. a 403 from EXO), and for a success response we surface a clear, + // attributable error instead of an opaque decompression exception. Callers that skip the error + // check (e.g. redirect / compliance-URL discovery) keep their headers and fall back to an empty body. + string content; + try + { + content = response.Content is not null + ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) + : string.Empty; + } + catch (Exception ex) when (ex is InvalidDataException || ex is IOException) + { + if (!(skipErrorCheck || noRedirect)) + { + TrackPoolResult(selection.Pool, response.IsSuccessStatusCode, statusCode); + var readFailMessage = !response.IsSuccessStatusCode + ? $"Response status code does not indicate success: {statusCode}" + : $"Failed to read response body (status {statusCode}): {ex.Message}"; + throw new HttpRequestException(readFailMessage, ex, response.StatusCode); + } + content = string.Empty; + } // ---------------------------------------------------------- // Response headers diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index b5ad6c396c0c75f6e7a2b10812f9de152a0d1e58..242e6458a8033fa17149cdb26863d007254a2571 100644 GIT binary patch delta 11716 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 L?)*eF=a~K*z$!{k delta 11146 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 i9;)Pb4#uiD_eP8jhy27vj~@(3c*z`X{68OXb From e987ea486c5030d9e22a6ff73f103ce746634664 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:14:57 +0800 Subject: [PATCH 086/150] Move NinjaOne Sync from BPA to Reporting DB --- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 144 ++++++++---------- 1 file changed, 62 insertions(+), 82 deletions(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 5c6753fc6de9e..a4200a6a7f445 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1945,95 +1945,75 @@ function Invoke-NinjaOneTenantSync { [System.Collections.Generic.List[PSCustomObject]]$WidgetData = @() - ### Fetch BPA Data - $Table = get-cipptable 'cachebpav2' - $BPAData = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$($Customer.customerId)'") - - if ($Null -ne $BPAData.Timestamp) { - ## BPA Data Widgets - # Shared Mailboxes with Enabled Users - #$WidgetData.add([PSCustomObject]@{ - # Value = $( - # $SharedSendMailboxCount = ($BpaData.SharedMailboxeswithenabledusers | ConvertFrom-Json | Measure-Object).count - # if ($SharedSendMailboxCount -ne 0) { - # $ResultColour = '#D53948' - # } else { - # $ResultColour = '#26A644' - # } - # $SharedSendMailboxCount - # ) - # Description = 'Shared Mailboxes with enabled users' - # Colour = $ResultColour - # Link = "https://$CIPPUrl/tenant/standards/bpa-report?SearchNow=true&Report=CIPP+Best+Practices+v1.0+-+Tenant+view&tenantFilter=$($Customer.customerId)" - # }) - - # Unused Licenses - $WidgetData.add([PSCustomObject]@{ - Value = $( - try { - $BPAUnusedLicenses = (($BpaData.Unusedlicenses | ConvertFrom-Json -ErrorAction SilentlyContinue).availableUnits | Measure-Object -Sum).sum - } catch { - $BPAUnusedLicenses = 'Failed to retrieve unused licenses' - } - if ($BPAUnusedLicenses -ne 0) { - $ResultColour = '#D53948' - } else { - $ResultColour = '#26A644' - } - $BPAUnusedLicenses - ) - Description = 'Unused Licenses' - Colour = $ResultColour - Link = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" - }) + ### Tenant Posture Widgets (CIPP Reporting DB) + $PostureTenant = $Customer.defaultDomainName + # Reads a reporting DB type and returns the deserialized data objects (count rows excluded). + $GetDbData = { + param($Tenant, $Type) + try { + Get-CIPPDbItem -TenantFilter $Tenant -Type $Type | Where-Object { $_.RowKey -notlike '*-Count' } | ForEach-Object { $_.Data | ConvertFrom-Json -ErrorAction SilentlyContinue } + } catch { + Write-Information "NinjaOne: failed to read '$Type' from reporting DB for $Tenant : $($_.Exception.Message)" + } + } - # Unified Audit Log - $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.UnifiedAuditLog -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'Unified Audit Log' - Colour = $ResultColour - Link = "https://security.microsoft.com/auditlogsearch?viewid=Async%20Search&tid=$($Customer.customerId)" - }) + # OAuth App Consent - user consent restricted (legacy open-consent policy not assigned). + $AuthPolicy = (& $GetDbData -Tenant $PostureTenant -Type 'AuthorizationPolicy') | Select-Object -First 1 + $HasAuthPolicy = $null -ne $AuthPolicy + $OAuthConsentRestricted = 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy' -notin $AuthPolicy.permissionGrantPolicyIdsAssignedToDefaultUserRole + + # Unified Audit Log - ingestion enabled + $AuditConfig = (& $GetDbData -Tenant $PostureTenant -Type 'ExoAdminAuditLogConfig') | Select-Object -First 1 + $HasAuditConfig = $null -ne $AuditConfig + $UnifiedAuditLogEnabled = $AuditConfig.UnifiedAuditLogIngestionEnabled -eq $true + + # Password Never Expires - any domain with password validity set to never (2147483647) + $DomainData = & $GetDbData -Tenant $PostureTenant -Type 'Domains' + $HasDomainData = ($DomainData | Measure-Object).Count -gt 0 + $PasswordNeverExpires = [bool]($DomainData | Where-Object { $_.passwordValidityPeriodInDays -eq 2147483647 }) + + # Unused Licenses - sum of available units across SKUs with spare licenses + $LicenseData = & $GetDbData -Tenant $PostureTenant -Type 'LicenseOverview' + $HasLicenseData = ($LicenseData | Measure-Object).Count -gt 0 + $UnusedLicenseCount = (($LicenseData | Where-Object { $_.availableUnits -gt 0 }).availableUnits | Measure-Object -Sum).Sum + if ($null -eq $UnusedLicenseCount) { $UnusedLicenseCount = 0 } + + Write-Information "Tenant posture (reporting DB) - AuthPolicy:$HasAuthPolicy AuditConfig:$HasAuditConfig Domains:$HasDomainData Licenses:$HasLicenseData" + + # Renders a boolean posture widget, with a neutral state when no cached data is available. + $NewPostureWidget = { + param($Description, $Link, $HasData, $State) + if (-not $HasData) { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#CCCCCC'; Link = $Link } + } elseif ($State) { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#26A644'; Link = $Link } + } else { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#D53948'; Link = $Link } + } + } - # Passwords Never Expire + # Unused Licenses + $UnusedLicenseLink = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" + if (-not $HasLicenseData) { + $WidgetData.add([PSCustomObject]@{ Value = 'No data'; Description = 'Unused Licenses'; Colour = '#CCCCCC'; Link = $UnusedLicenseLink }) + } else { $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.PasswordNeverExpires -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'Password Never Expires' - Colour = $ResultColour - Link = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" + Value = $UnusedLicenseCount + Description = 'Unused Licenses' + Colour = $(if ($UnusedLicenseCount -ne 0) { '#D53948' } else { '#26A644' }) + Link = $UnusedLicenseLink }) + } - # oAuth App Consent - $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.OAuthAppConsent -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'OAuth App Consent' - Colour = $ResultColour - Link = "https://entra.microsoft.com/$($Customer.defaultDomainName)/#view/Microsoft_AAD_IAM/ConsentPoliciesMenuBlade/~/UserSettings" - }) + # Unified Audit Log + $WidgetData.add((& $NewPostureWidget -Description 'Unified Audit Log' -Link "https://security.microsoft.com/auditlogsearch?viewid=Async%20Search&tid=$($Customer.customerId)" -HasData $HasAuditConfig -State $UnifiedAuditLogEnabled)) - } + # Password Never Expires + $WidgetData.add((& $NewPostureWidget -Description 'Password Never Expires' -Link "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" -HasData $HasDomainData -State $PasswordNeverExpires)) + + # OAuth App Consent + $WidgetData.add((& $NewPostureWidget -Description 'OAuth App Consent' -Link "https://entra.microsoft.com/$($Customer.defaultDomainName)/#view/Microsoft_AAD_IAM/ConsentPoliciesMenuBlade/~/UserSettings" -HasData $HasAuthPolicy -State $OAuthConsentRestricted)) # Blocked Senders $BlockedSenderCount = ($BlockedSenders | Measure-Object).count From 04b3e124ab45972bd7f8ede007a6c253a246719c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:31:29 +0800 Subject: [PATCH 087/150] Disable App insights page when not needed --- Config/FeatureFlags.json | 15 +++++++++++++++ .../CIPP/Core/Invoke-ListFeatureFlags.ps1 | 3 +++ 2 files changed, 18 insertions(+) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index bd8fe152ce5e8..e90b238e94beb 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -81,5 +81,20 @@ ], "Pages": [], "Hidden": false + }, + { + "Id": "AppInsights", + "Name": "App Insights", + "Description": "App Insights page not used in NG", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecAppInsightsQuery" + ], + "Pages": [ + "/cipp/advanced/diagnostics" + ], + "Hidden": true } ] diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 index a5a2b1b74bd39..d4d924f206839 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 @@ -21,6 +21,9 @@ function Invoke-ListFeatureFlags { if ($Flag.Id -eq 'SuperAdminNG') { $Flag.Enabled = $true } + elseIf ($Flag.Id -eq 'AppInsights') { + $Flag.Enabled = $false + } } } From 9deb4585b17101bc3f51f1e6adee01559e8696d2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:09 +0800 Subject: [PATCH 088/150] Update Get-NinjaOneFieldMapping.ps1 --- .../Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 b/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 index 6e61d59f7e3a5..c82202da52009 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 @@ -3,6 +3,13 @@ function Get-NinjaOneFieldMapping { param ( $CIPPMapping ) + + $Unset = [PSCustomObject]@{ + name = '--- Do not synchronize ---' + value = $null + type = 'unset' + } + try { #Get available mappings $Mappings = [pscustomobject]@{} @@ -94,13 +101,9 @@ function Get-NinjaOneFieldMapping { if ($Null -eq $NinjaCustomFieldsOrg) { [System.Collections.Generic.List[object]]$NinjaCustomFieldsOrg = @() } - $Unset = [PSCustomObject]@{ - name = '--- Do not synchronize ---' - value = $null - type = 'unset' - } } catch { + Write-Information "Get-NinjaOneFieldMapping: failed to retrieve NinjaOne custom fields: $($_.Exception.Message)" [System.Collections.Generic.List[object]]$NinjaCustomFieldsNode = @() [System.Collections.Generic.List[object]]$NinjaCustomFieldsOrg = @() } From 428ebd3aaa576a5a9f8d6aff4f3e5e91d294175e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:44:12 +0800 Subject: [PATCH 089/150] Update Set-CIPPDBCacheRiskDetections.ps1 --- Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 index 6abdfd46ff952..a2590aa4d2f91 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 @@ -20,7 +20,7 @@ function Set-CIPPDBCacheRiskDetections { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching risk detections from Identity Protection' -sev Debug # Requires P2 licensing - $RiskDetections = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identityProtection/riskDetections' -tenantid $TenantFilter + $RiskDetections = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identityProtection/riskDetections?$top=500' -tenantid $TenantFilter if ($RiskDetections) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskDetections' -Data $RiskDetections -AddCount From 45b96d6afcd47fdc4d3579599e12effea968a56f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:52:13 +0800 Subject: [PATCH 090/150] Fix contact template deployment to align with frontend changes --- .../Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 index b6813487ee951..75154d84641ee 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 @@ -147,7 +147,7 @@ function Invoke-CIPPStandardDeployContactTemplates { @{ Template = 'hidefromGAL'; Current = $ExistingContact.HiddenFromAddressListsEnabled; IsBool = $true } @{ Template = 'companyName'; Current = $ExtendedContact.Company } @{ Template = 'state'; Current = $ExtendedContact.StateOrProvince } - @{ Template = 'streetAddress'; Current = $ExtendedContact.Office } + @{ Template = 'streetAddress'; Current = $ExtendedContact.StreetAddress } @{ Template = 'businessPhone'; Current = $ExtendedContact.Phone } @{ Template = 'website'; Current = $ExtendedContact.WebPage } @{ Template = 'jobTitle'; Current = $ExtendedContact.Title } @@ -338,7 +338,7 @@ function Invoke-CIPPStandardDeployContactTemplates { $PropertyMap = @{ 'Company' = $Template.companyName 'StateOrProvince' = $Template.state - 'Office' = $Template.streetAddress + 'StreetAddress' = $Template.streetAddress 'Phone' = $Template.businessPhone 'WebPage' = $Template.website 'Title' = $Template.jobTitle From d2e88c5a95135a987cf02610c755d7c0da542e11 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:31:49 +0800 Subject: [PATCH 091/150] Start job earlier --- Config/CIPPTimers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index b03f2070fa0ba..63203c303ab83 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -241,7 +241,7 @@ "Id": "5e8a9b4c-2d6f-4a3e-b7c1-9d0e5f3a8b2c", "Command": "Start-IntuneReportExportOrchestrator", "Description": "Submit Intune report-export jobs ahead of nightly DB cache run", - "Cron": "0 0 2 * * *", + "Cron": "0 0 1 * * *", "Priority": 22, "RunOnProcessor": true, "TZOffset": true, From 6192717c756d282bf961335981ad8c9aa390c17e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:07:42 +0800 Subject: [PATCH 092/150] Fixes for spam filter policy when using custom names --- .../Invoke-CIPPStandardSpamFilterPolicy.ps1 | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index df10b627db821..6be274bd9079c 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 @@ -82,17 +82,35 @@ function Invoke-CIPPStandardSpamFilterPolicy { } #we're done. # Use custom name if provided, otherwise use default for backward compatibility - $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Spam Filter Policy' } + $DefaultPolicyName = 'CIPP Default Spam Filter Policy' + $PolicyName = if ($Settings.name) { $Settings.name } else { $DefaultPolicyName } try { - $CurrentState = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-HostedContentFilterPolicy' | - Where-Object -Property Name -EQ $PolicyName + $AllSpamFilterPolicies = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-HostedContentFilterPolicy' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SpamFilterPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error return } + # Only match against legacy/default names when no custom name is provided. When a custom name is + # set, deploy it as a new policy instead of reusing an existing default-named one. 'Default' is + # Microsoft's built-in inbound anti-spam policy ("Anti-spam inbound policy" in the portal); it + # cannot be renamed and has no associated rule. + if ($PolicyName -eq $DefaultPolicyName) { + $PolicyList = @($PolicyName, 'Default Spam Filter Policy', 'Default') + $ExistingPolicy = $AllSpamFilterPolicies | Where-Object -Property Name -In $PolicyList | Select-Object -First 1 + if ($null -ne $ExistingPolicy.Name) { + # Use existing policy name if found + $PolicyName = $ExistingPolicy.Name + } + } + + # The built-in default policy cannot have a HostedContentFilterRule, so rule remediation is skipped for it. + $IsDefaultPolicy = $PolicyName -eq 'Default' + + $CurrentState = $AllSpamFilterPolicies | Where-Object -Property Name -EQ $PolicyName + $SpamAction = $Settings.SpamAction.value ?? $Settings.SpamAction $SpamQuarantineTag = $Settings.SpamQuarantineTag.value ?? $Settings.SpamQuarantineTag $HighConfidenceSpamAction = $Settings.HighConfidenceSpamAction.value ?? $Settings.HighConfidenceSpamAction @@ -253,7 +271,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { } } - if ($RuleStateIsCorrect -eq $false) { + if ($RuleStateIsCorrect -eq $false -and -not $IsDefaultPolicy) { $cmdParams = @{ Priority = 0 RecipientDomainIs = ConvertTo-SafeArray -Field $AcceptedDomains.Name From ee5c30aa1ff4361e43a73655278cdf1fe70ae00d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:16:39 +0200 Subject: [PATCH 093/150] feat: enhance device import status handling and logging --- .../Endpoint/Autopilot/Invoke-AddAPDevice.ps1 | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 index 56dc7c0bda121..ee7a42396de3a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 @@ -47,27 +47,54 @@ function Invoke-AddAPDevice { $Amount++ Start-Sleep 1 $NewStatus = New-GraphGetRequest -uri "https://api.partnercenter.microsoft.com/v1/$($GraphRequest.Location)" -scope 'https://api.partnercenter.microsoft.com/user_impersonation' - } until ($NewStatus.status -eq 'finished' -or $Amount -eq 4) - if ($NewStatus.status -ne 'finished') { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } - Write-LogMessage -headers $Request.Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' + } until ($NewStatus.status -in @('finished', 'finished_with_errors') -or $Amount -eq 4) + if ($NewStatus.status -notin @('finished', 'finished_with_errors')) { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' - [PSCustomObject]@{ - Status = 'Import Job Completed' - Devices = @($NewStatus.devicesStatus) + # DEBUG: dump the raw status so we can inspect what Partner Center returns per device. + Write-Host "RAW NewStatus: $($NewStatus | ConvertTo-Json -Depth 10)" + + # Build one result per device (DeviceUploadDetails) so the frontend renders a + # single bar each, instead of flattening raw device fields into many stray bars. + $Index = 0 + $DeviceResults = foreach ($Device in @($NewStatus.devicesStatus)) { + $Index++ + # Hash-only uploads return no serial/productKey/deviceId; fall back to a number. + $DeviceId = $Device.serialNumber ?? $Device.productKey ?? $Device.deviceId + $Label = $DeviceId ?? "Device $Index" + $IsError = $Device.status -match 'error' + $Text = "$($Label): $($Device.status)" + if ($IsError -and $Device.errorDescription) { + $Text += " - $($Device.errorCode) $($Device.errorDescription)" + } + # Log each device with the input data that was submitted for it (matched by position). + $InputDevice = @($rawDevices)[$Index - 1] + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Autopilot import - $Text" -Sev $(if ($IsError) { 'Error' } else { 'Info' }) -LogData $InputDevice + [PSCustomObject]@{ + resultText = $Text + state = if ($IsError) { 'error' } else { 'success' } + copyField = $DeviceId + details = $Device + } + } + if (-not $DeviceResults) { + $DeviceResults = [PSCustomObject]@{ resultText = "Import job '$($NewStatus.status)' for group $GroupName"; state = 'success' } } $StatusCode = [HttpStatusCode]::OK + # Emit as the try block's value so the outer `$Result = try {...}` captures it. + $DeviceResults } catch { $ErrorMessage = Get-CippException -Exception $_ $StatusCode = [HttpStatusCode]::InternalServerError [PSCustomObject]@{ - Status = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" - Devices = @() + resultText = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" + state = 'error' } Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = @{'Results' = $Result } + Body = @{'Results' = @($Result) } }) } From a9b3e404e92bdd7a94968ec612771c44a3daa6e8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:02:06 +0800 Subject: [PATCH 094/150] Purview DLP Policy and Standard implementation and fixes --- .../Compare-CIPPDlpCompliancePolicy.ps1 | 165 ++++++++++++++++++ ...ConvertTo-CIPPSensitiveInformationType.ps1 | 103 +++++++++++ .../Public/Get-CIPPDlpComplianceFieldList.ps1 | 65 +++++++ .../Public/Set-CIPPDlpCompliancePolicy.ps1 | 108 +++++++----- .../Invoke-AddDlpCompliancePolicyTemplate.ps1 | 58 ++++-- ...IPPStandardDlpCompliancePolicyTemplate.ps1 | 54 ++++-- 6 files changed, 478 insertions(+), 75 deletions(-) create mode 100644 Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 create mode 100644 Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 diff --git a/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 new file mode 100644 index 0000000000000..eaa6c76bee657 --- /dev/null +++ b/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 @@ -0,0 +1,165 @@ +function ConvertTo-CIPPComparableString { + <# + .SYNOPSIS + Produce an order-independent canonical string for a value, for equality comparison. + .DESCRIPTION + Recursively serializes scalars, dictionaries/objects (keys sorted), and arrays (elements sorted) + into a deterministic string. Two values are equal iff their canonical strings match - independent + of property order or array order, which is the right semantics for DLP locations and the set of + sensitive information types (order is not meaningful for matching). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Value) + + if ($null -eq $Value) { return 'null' } + if ($Value -is [string]) { return '"' + $Value + '"' } + if ($Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) { + return [string]$Value + } + if ($Value -is [System.Collections.IDictionary]) { + $parts = foreach ($k in (@($Value.Keys) | Sort-Object)) { '"' + $k + '":' + (ConvertTo-CIPPComparableString -Value $Value[$k]) } + return '{' + ($parts -join ',') + '}' + } + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $parts = foreach ($p in ($Value.PSObject.Properties | Sort-Object Name)) { '"' + $p.Name + '":' + (ConvertTo-CIPPComparableString -Value $p.Value) } + return '{' + ($parts -join ',') + '}' + } + if ($Value -is [System.Collections.IEnumerable]) { + $items = @(foreach ($item in $Value) { ConvertTo-CIPPComparableString -Value $item }) | Sort-Object + return '[' + ($items -join ',') + ']' + } + return '"' + ([string]$Value) + '"' +} + +function ConvertTo-CIPPDlpComparable { + <# + .SYNOPSIS + Normalize a DLP policy source (template or live policy) + its rules into a comparable param map. + .DESCRIPTION + Runs the source through the exact same normalization the deploy path uses - allowlist filtering, + location normalization, sensitive-information-type conversion (which also strips output-only + rulePackId), and IncidentReportContent string->array - so a template and the live policy it was + deployed from collapse to identical structures when nothing has actually drifted. + .PARAMETER PolicySource + The policy-level object (a stored template, or a Get-DlpCompliancePolicy result). + .PARAMETER RuleSource + The rule collection (template RuleParams, or Get-DlpComplianceRule results). + .OUTPUTS + PSCustomObject with Policy (hashtable of normalized policy params) and Rules (ordered map of + rule name -> hashtable of normalized rule params). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($PolicySource, $RuleSource) + + $Fields = Get-CIPPDlpComplianceFieldList + + $Policy = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $Fields.Policy -LocationFields $Fields.Location + $Policy.Remove('Name') | Out-Null # identity, not a comparable setting + # Mirror deploy: an invalid/transient Mode (e.g. PendingDeletion) is never deployed, so it must not + # register as drift either. + if ($Policy.ContainsKey('Mode') -and $Policy['Mode'] -notin $Fields.ValidPolicyModes) { + $Policy.Remove('Mode') | Out-Null + } + + $Rules = [ordered]@{} + foreach ($Rule in @($RuleSource) | Where-Object { $_ }) { + $RuleParams = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $Fields.Rule + $RuleName = [string]$RuleParams['Name'] + $RuleParams.Remove('Policy') | Out-Null + $RuleParams.Remove('Name') | Out-Null + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleParams.ContainsKey($SitField)) { + $RuleParams[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleParams[$SitField]) + } + } + if ($RuleParams.ContainsKey('IncidentReportContent') -and $RuleParams['IncidentReportContent'] -is [string]) { + $RuleParams['IncidentReportContent'] = @($RuleParams['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + if (-not [string]::IsNullOrWhiteSpace($RuleName)) { $Rules[$RuleName] = $RuleParams } + } + + return [pscustomobject]@{ Policy = $Policy; Rules = $Rules } +} + +function Compare-CIPPDlpCompliancePolicy { + <# + .SYNOPSIS + Compare a stored DLP template against the live policy + rules in a tenant and report drift. + .DESCRIPTION + Normalizes both sides through ConvertTo-CIPPDlpComparable and diffs them field by field + (policy-level and per-rule, matched by rule name). Returns the overall state and the specific + differing fields with their expected (template) and current (tenant) values, so callers can + decide whether to remediate and can surface exactly what differs. + .PARAMETER TenantFilter + Target tenant. + .PARAMETER Template + The stored template object (already ConvertFrom-Json'd). + .OUTPUTS + PSCustomObject: Name, State ('Missing' | 'PendingDeletion' | 'InSync' | 'Drift'), and Differences + (array of { Scope, Field, Expected, Current }). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantFilter, + [Parameter(Mandatory)] $Template + ) + + $PolicyName = $Template.Name ?? $Template.name + + $LivePolicy = try { + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | + Where-Object { $_.Name -eq $PolicyName } | Select-Object -First 1 + } catch { $null } + + if (-not $LivePolicy) { + return [pscustomobject]@{ Name = $PolicyName; State = 'Missing'; Differences = @() } + } + if ($LivePolicy.Mode -eq 'PendingDeletion') { + return [pscustomobject]@{ Name = $PolicyName; State = 'PendingDeletion'; Differences = @() } + } + + $LiveRules = try { + @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | + Where-Object { $_.ParentPolicyName -eq $PolicyName }) + } catch { @() } + + $Want = ConvertTo-CIPPDlpComparable -PolicySource $Template -RuleSource $Template.RuleParams + $Have = ConvertTo-CIPPDlpComparable -PolicySource $LivePolicy -RuleSource $LiveRules + + $Differences = [System.Collections.Generic.List[object]]::new() + + # Policy-level diff + foreach ($Key in (@($Want.Policy.Keys) + @($Have.Policy.Keys) | Select-Object -Unique)) { + $Expected = if ($Want.Policy.ContainsKey($Key)) { $Want.Policy[$Key] } else { $null } + $Current = if ($Have.Policy.ContainsKey($Key)) { $Have.Policy[$Key] } else { $null } + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { + $Differences.Add([pscustomobject]@{ Scope = 'Policy'; Field = $Key; Expected = $Expected; Current = $Current }) + } + } + + # Rule-level diff (only rules the template defines; matched by name) + foreach ($RuleName in @($Want.Rules.Keys)) { + if (@($Have.Rules.Keys) -notcontains $RuleName) { + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = '(entire rule)'; Expected = 'present'; Current = 'missing' }) + continue + } + $WantRule = $Want.Rules[$RuleName] + $HaveRule = $Have.Rules[$RuleName] + foreach ($Key in (@($WantRule.Keys) + @($HaveRule.Keys) | Select-Object -Unique)) { + $Expected = if ($WantRule.ContainsKey($Key)) { $WantRule[$Key] } else { $null } + $Current = if ($HaveRule.ContainsKey($Key)) { $HaveRule[$Key] } else { $null } + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = $Key; Expected = $Expected; Current = $Current }) + } + } + } + + $State = if ($Differences.Count -eq 0) { 'InSync' } else { 'Drift' } + return [pscustomobject]@{ Name = $PolicyName; State = $State; Differences = @($Differences) } +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 new file mode 100644 index 0000000000000..9294fee20d6fd --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 @@ -0,0 +1,103 @@ +function ConvertTo-CIPPSensitiveInformationType { + <# + .SYNOPSIS + Normalize a DLP rule's ContentContainsSensitiveInformation value into clean input objects. + .DESCRIPTION + Get-DlpComplianceRule returns ContentContainsSensitiveInformation (and the ExceptIf variant) + in an output-only @odata serialization that New-/Set-DlpComplianceRule will not accept as input. + Two shapes occur: + + - Flat list: an array of SITs, each SIT being an array of '{ _key, _value }' GenericHashTable + pairs - e.g. { _key = 'name'; _value = 'Credit Card Number' }. + + - Grouped: an array containing a single wrapper '{ groups = (...); operator = 'And' }', where + each group is an array of pairs carrying 'name', 'operator', and a nested 'sensitivetypes' + value (itself a flat list of SITs). Used by templates that AND/OR several named groups + together (e.g. HIPAA Enhanced). NOTE the wrapper is delivered inside an array, so the + top-level value is an array in BOTH shapes. + + This collapses every '{ _key, _value }' pair group into a single flat object and recurses through + the grouped / groups / sensitivetypes nesting, producing a structure the New-/Set-* cmdlets accept. + + The function is idempotent: a value already in the clean shape (no '_key' pairs) is returned + unchanged, so it is safe to call at both template-build time and deploy time. + .PARAMETER SensitiveInformation + The ContentContainsSensitiveInformation value to normalize. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($SensitiveInformation) + + if ($null -eq $SensitiveInformation) { return $null } + + # Output-only SIT properties that Get-DlpComplianceRule emits but New-/Set-DlpComplianceRule reject + # as input (matched case-insensitively against the lower-cased key). + $script:InvalidSitProperties = @('rulepackid') + + # Recursively normalize a single entry. An entry is one of: + # - a raw array of { _key, _value } pairs (a SIT, or a group) -> collapse to a flat object + # - a grouped wrapper object { groups, operator } -> recurse each group + # - an already-clean object with a nested sensitivetypes list -> recurse that list + # - anything else -> pass through unchanged + # 'groups' and 'sensitivetypes' are recursed on BOTH the raw and the already-clean paths so the + # conversion is correct whether the wrapper arrives bare or (as the cmdlets deliver it) array-wrapped, + # and so re-running on an already-converted value is a no-op. + function Convert-Entry { + param($Entry) + + if ($null -eq $Entry) { return $null } + + $first = @($Entry) | Where-Object { $null -ne $_ } | Select-Object -First 1 + $isRawPairs = ($Entry -isnot [string]) -and $null -ne $first -and ($first.PSObject.Properties.Name -contains '_key') + + if ($isRawPairs) { + $ht = [ordered]@{} + foreach ($pair in @($Entry)) { + if ($null -eq $pair -or ($pair.PSObject.Properties.Name -notcontains '_key')) { continue } + $key = [string]$pair._key + # Skip output-only properties the New-/Set-* cmdlets reject as input (e.g. rulePackId, + # which Get-DlpComplianceRule emits on every SIT). + if ($key -in $script:InvalidSitProperties) { continue } + if ($key -in @('sensitivetypes', 'groups')) { + $ht[$key] = @(foreach ($child in @($pair._value)) { Convert-Entry -Entry $child }) + } else { + $ht[$key] = $pair._value + } + } + return [pscustomobject]$ht + } + + # Already clean (or partially clean) object - recurse the nested collections, strip invalid + # properties, pass the rest through. Rebuild when there is anything to recurse or strip. + $propNames = @($Entry.PSObject.Properties.Name) + $needsRebuild = ($propNames | Where-Object { $_ -in @('groups', 'sensitivetypes') -or $_ -in $script:InvalidSitProperties }).Count -gt 0 + if ($needsRebuild) { + $clone = [ordered]@{} + foreach ($prop in $Entry.PSObject.Properties) { + if ($prop.Name -in $script:InvalidSitProperties) { continue } + if ($prop.Name -in @('groups', 'sensitivetypes')) { + $clone[$prop.Name] = @(foreach ($child in @($prop.Value)) { Convert-Entry -Entry $child }) + } else { + $clone[$prop.Name] = $prop.Value + } + } + return [pscustomobject]$clone + } + + return $Entry + } + + # Grouped form: a bare wrapper object exposing a 'groups' collection. (When array-wrapped, the + # branch below handles it via Convert-Entry on each element.) + if ($SensitiveInformation -isnot [System.Collections.IEnumerable] -and + ($SensitiveInformation.PSObject.Properties.Name -contains 'groups')) { + # Callers MUST wrap the result in @(...) so this lands as a PswsHashtable[] array on the wire - + # PowerShell unwraps a single-element return to a bare object, which is rejected server-side. + return @(Convert-Entry -Entry $SensitiveInformation) + } + + # Array form (the normal case): flat list of SITs, OR an array carrying the grouped wrapper. + # Callers must wrap in @(...) - see the note above. + return @(foreach ($entry in @($SensitiveInformation)) { Convert-Entry -Entry $entry }) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 b/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 new file mode 100644 index 0000000000000..d73119667d103 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 @@ -0,0 +1,65 @@ +function Get-CIPPDlpComplianceFieldList { + <# + .SYNOPSIS + Single source of truth for the DLP compliance policy/rule cmdlet parameter allowlists. + .DESCRIPTION + The New-/Set-DlpCompliancePolicy and New-/Set-DlpComplianceRule cmdlets accept only a subset of + the (much larger) set of properties Get-* returns. These allowlists are shared by every code path + that builds or compares DLP policy params - template creation, deploy, and drift comparison - so + the accepted fields never diverge between them (divergence here previously caused 'Mode'/'Priority' + being sent where invalid, etc.). + + Priority is intentionally excluded: Microsoft assigns it per tenant from existing policy ordering, + so it varies between tenants and must not be captured into, deployed from, or drift-compared. + .OUTPUTS + PSCustomObject with Policy, Rule, and Location (subset of Policy) string arrays. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Policy = @( + 'Name', 'Comment', 'Mode', + 'ExchangeLocation', 'ExchangeLocationException', + 'SharePointLocation', 'SharePointLocationException', + 'OneDriveLocation', 'OneDriveLocationException', + 'TeamsLocation', 'TeamsLocationException', + 'EndpointDlpLocation', 'EndpointDlpLocationException', + 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', + 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', + 'PowerBIDlpLocation', 'PowerBIDlpLocationException', + 'ModernGroupLocation', 'ModernGroupLocationException' + ) + + # Note: DLP rules have no 'Mode' parameter (that is policy-level). 'Policy' is the parent reference + # added at deploy time; it is not a comparable setting. + $Rule = @( + 'Name', 'Policy', 'Comment', 'Disabled', + 'ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation', + 'ContentPropertyContainsWords', 'BlockAccess', 'BlockAccessScope', + 'NotifyUser', 'NotifyEmailCustomText', 'NotifyEmailCustomSubject', + 'NotifyPolicyTipCustomText', 'GenerateAlert', 'AlertProperties', + 'GenerateIncidentReport', 'IncidentReportContent', + 'AccessScope', 'From', 'FromMemberOf', 'FromAddressContainsWords', + 'FromAddressMatchesPatterns', 'SentTo', 'SentToMemberOf', + 'RecipientDomainIs', 'AnyOfRecipientAddressContainsWords', + 'AnyOfRecipientAddressMatchesPatterns', 'AnyOfRecipientAddressDomainIs', + 'ExceptIfFrom', 'ExceptIfFromMemberOf', 'ExceptIfFromAddressContainsWords', + 'ExceptIfFromAddressMatchesPatterns', + 'AddRecipients', 'BlockMessage', 'GenerateAlertOn', 'IncidentReportTo', + 'ReportSeverityLevel', 'RuleErrorAction', + 'ContentExtensionMatchesWords', 'DocumentNameMatchesPatterns', + 'DocumentNameMatchesWords', 'DocumentSizeOver', + 'ContentCharacterSetContainsWords', 'ContentFileTypeMatches' + ) + + return [pscustomobject]@{ + Policy = $Policy + Rule = $Rule + Location = @($Policy | Where-Object { $_ -like '*Location*' }) + # Valid -Mode input values for New-/Set-DlpCompliancePolicy. Transient/output-only states such as + # 'PendingDeletion' are NOT accepted as input and must be dropped before deploy. + ValidPolicyModes = @('Enable', 'TestWithNotifications', 'TestWithoutNotifications', 'Disable') + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 index fb8587ad03f07..e963f40db0e0a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 @@ -29,46 +29,25 @@ function Set-CIPPDlpCompliancePolicy { $Headers ) - $PolicyAllowedFields = @( - 'Name', 'Comment', 'Mode', 'Priority', - 'ExchangeLocation', 'ExchangeLocationException', - 'SharePointLocation', 'SharePointLocationException', - 'OneDriveLocation', 'OneDriveLocationException', - 'TeamsLocation', 'TeamsLocationException', - 'EndpointDlpLocation', 'EndpointDlpLocationException', - 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', - 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', - 'PowerBIDlpLocation', 'PowerBIDlpLocationException', - 'ModernGroupLocation', 'ModernGroupLocationException' - ) - $RuleAllowedFields = @( - 'Name', 'Policy', 'Comment', 'Disabled', 'Mode', 'Priority', - 'ContentContainsSensitiveInformation', - 'ContentPropertyContainsWords', 'BlockAccess', 'BlockAccessScope', - 'NotifyUser', 'NotifyEmailCustomText', 'NotifyEmailCustomSubject', - 'NotifyPolicyTipCustomText', 'GenerateAlert', 'AlertProperties', - 'GenerateIncidentReport', 'IncidentReportContent', - 'ExceptIfContentContainsSensitiveInformation', - 'AccessScope', 'From', 'FromMemberOf', 'FromAddressContainsWords', - 'FromAddressMatchesPatterns', 'SentTo', 'SentToMemberOf', - 'RecipientDomainIs', 'AnyOfRecipientAddressContainsWords', - 'AnyOfRecipientAddressMatchesPatterns', 'AnyOfRecipientAddressDomainIs', - 'ExceptIfFrom', 'ExceptIfFromMemberOf', 'ExceptIfFromAddressContainsWords', - 'ExceptIfFromAddressMatchesPatterns', - 'AddRecipients', 'BlockMessage', 'GenerateAlertOn', 'IncidentReportTo', - 'ReportSeverityLevel', 'RuleErrorAction', - 'ContentExtensionMatchesWords', 'DocumentNameMatchesPatterns', - 'DocumentNameMatchesWords', 'DocumentSizeOver', - 'ContentCharacterSetContainsWords', 'ContentFileTypeMatches' - ) - $LocationFields = $PolicyAllowedFields | Where-Object { $_ -like '*Location*' } + # Allowlists come from the single shared source so deploy, template creation, and drift comparison + # never diverge. Priority is excluded there (per-tenant), and rules carry no 'Mode' (policy-level). + $Fields = Get-CIPPDlpComplianceFieldList + $PolicyAllowedFields = $Fields.Policy + $RuleAllowedFields = $Fields.Rule + $LocationFields = $Fields.Location $PolicyParams = Format-CIPPCompliancePolicyParams -Source $Template -AllowedFields $PolicyAllowedFields -LocationFields $LocationFields + # Drop a Mode the cmdlets won't accept as input (e.g. 'PendingDeletion' captured from a policy that + # was mid-deletion); New-/Set-* would otherwise throw InvalidCompliancePolicyMode. + if ($PolicyParams.ContainsKey('Mode') -and $PolicyParams['Mode'] -notin $Fields.ValidPolicyModes) { + $PolicyParams.Remove('Mode') | Out-Null + } $RuleSource = $Template.RuleParams $PolicyName = $PolicyParams.Name try { - $ExistingPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object Name, IsDefault } catch { @() } + # Pull the location fields too so re-deploys can diff against what the policy already has. + $ExistingPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object (@('Name', 'IsDefault') + $LocationFields) } catch { @() } $ExistingRules = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | Select-Object Name, ParentPolicyName } catch { @() } $ExistingPolicy = $ExistingPolicies | Where-Object { $_.Name -eq $PolicyName } | Select-Object -First 1 @@ -79,7 +58,23 @@ function Set-CIPPDlpCompliancePolicy { } if ($ExistingPolicy) { - $SetParams = ConvertTo-CIPPComplianceSetParams -Params $PolicyParams -Identity $PolicyName -AddPrefixFields $LocationFields + # Location params are Add-prefixed on Set (incremental), so re-adding a location the policy + # already has (e.g. 'All') throws LocationAlreadyExistsException and aborts the entire Set. + # Diff each location field against the existing policy and only Add what's genuinely new. + $DeltaParams = @{} + foreach ($key in $PolicyParams.Keys) { + if ($key -notin $LocationFields) { $DeltaParams[$key] = $PolicyParams[$key]; continue } + $existingLocs = @($ExistingPolicy.$key) | ForEach-Object { + if ($null -eq $_) { return } + if ($_ -is [string]) { $_ } + elseif ($_.Name) { $_.Name } + elseif ($_.DisplayName) { $_.DisplayName } + elseif ($_.PrimarySmtpAddress) { $_.PrimarySmtpAddress } + } + $newLocs = @($PolicyParams[$key]) | Where-Object { $_ -and $_ -notin $existingLocs } + if ($newLocs.Count -gt 0) { $DeltaParams[$key] = $newLocs } + } + $SetParams = ConvertTo-CIPPComplianceSetParams -Params $DeltaParams -Identity $PolicyName -AddPrefixFields $LocationFields $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpCompliancePolicy' -cmdParams $SetParams -Compliance -useSystemMailbox $true $PolicyAction = "Updated DLP compliance policy '$PolicyName' in $TenantFilter." } else { @@ -87,29 +82,60 @@ function Set-CIPPDlpCompliancePolicy { $PolicyAction = "Created DLP compliance policy '$PolicyName' in $TenantFilter." } - if ($RuleSource) { - $RuleHash = Format-CIPPCompliancePolicyParams -Source $RuleSource -AllowedFields $RuleAllowedFields + # RuleParams may be a single rule object (legacy templates) or an array of rules - a DLP + # policy can carry several (e.g. low- vs high-volume detection). Normalize to an array. + $RuleList = @($RuleSource) | Where-Object { $_ } + $RuleActions = @() + $RuleIndex = 0 + foreach ($Rule in $RuleList) { + $RuleIndex++ + $RuleHash = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $RuleAllowedFields + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleHash.ContainsKey($SitField)) { + $RuleHash[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleHash[$SitField]) + } + } + # Get-* returns IncidentReportContent as a single comma-joined string, but the New-/Set-* + # cmdlets expect a ReportContentOption[] array - split it back out. + if ($RuleHash.ContainsKey('IncidentReportContent') -and $RuleHash['IncidentReportContent'] -is [string]) { + $RuleHash['IncidentReportContent'] = @($RuleHash['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } $RuleHash['Policy'] = $PolicyName $RuleName = if ($RuleHash.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace([string]$RuleHash['Name'])) { $RuleHash['Name'] + } elseif ($RuleList.Count -gt 1) { + "$PolicyName Rule $RuleIndex" } else { "$PolicyName Rule" } $RuleHash['Name'] = $RuleName - $RuleExists = [bool]($ExistingRules | Where-Object { $_.Name -eq $RuleName -or $_.ParentPolicyName -eq $PolicyName }) + # DLP rule names are unique tenant-wide, so match on BOTH name and parent policy: + # - same name under THIS policy -> update it (idempotent re-deploy) + # - same name under a DIFFERENT policy -> conflict; skip rather than clobber that policy's + # rule (the name must be made unique to deploy here) + # - name free -> create it + $RuleUnderThisPolicy = $ExistingRules | Where-Object { $_.Name -eq $RuleName -and $_.ParentPolicyName -eq $PolicyName } + $RuleNameOwnedElsewhere = $ExistingRules | Where-Object { $_.Name -eq $RuleName -and $_.ParentPolicyName -ne $PolicyName } | Select-Object -First 1 - if ($RuleExists) { + if ($RuleUnderThisPolicy) { $SetRuleHash = ConvertTo-CIPPComplianceSetParams -Params $RuleHash -Identity $RuleName $SetRuleHash.Remove('Policy') | Out-Null $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpComplianceRule' -cmdParams $SetRuleHash -Compliance -useSystemMailbox $true + $RuleActions += "updated rule '$RuleName'" + } elseif ($RuleNameOwnedElsewhere) { + $Warn = "rule '$RuleName' already exists under policy '$($RuleNameOwnedElsewhere.ParentPolicyName)' - rule names must be unique tenant-wide, so it was NOT created for '$PolicyName'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Warn -sev Warning + $RuleActions += $Warn } else { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpComplianceRule' -cmdParams $RuleHash -Compliance -useSystemMailbox $true + $RuleActions += "created rule '$RuleName'" } } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $PolicyAction -sev Info - return $PolicyAction + $Result = if ($RuleActions.Count -gt 0) { "$PolicyAction Rules: $($RuleActions -join '; ')." } else { $PolicyAction } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + return $Result } catch { $ErrorMessage = Get-CippException -Exception $_ $msg = "Could not deploy DLP compliance policy '$PolicyName' to $($TenantFilter): $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 index acc974c464a03..e40c721b030aa 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 @@ -11,21 +11,13 @@ Function Invoke-AddDlpCompliancePolicyTemplate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - $AllowedFields = @( - 'Name', 'Comment', 'Mode', 'Priority', - 'ExchangeLocation', 'ExchangeLocationException', - 'SharePointLocation', 'SharePointLocationException', - 'OneDriveLocation', 'OneDriveLocationException', - 'TeamsLocation', 'TeamsLocationException', - 'EndpointDlpLocation', 'EndpointDlpLocationException', - 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', - 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', - 'PowerBIDlpLocation', 'PowerBIDlpLocationException', - 'ModernGroupLocation', 'ModernGroupLocationException', - 'RuleParams' - ) - - $LocationFields = $AllowedFields | Where-Object { $_ -like '*Location*' } + # Allowlists come from the single shared source so template creation, deploy, and drift comparison + # never diverge. 'RuleParams' is template-only (added so a PowerShellCommand body that already carries + # RuleParams passes through). 'Policy' on rules is captured then stripped below (added at deploy time). + $Fields = Get-CIPPDlpComplianceFieldList + $AllowedFields = @($Fields.Policy) + 'RuleParams' + $RuleAllowedFields = $Fields.Rule + $LocationFields = $Fields.Location try { $GUID = (New-Guid).GUID @@ -36,8 +28,44 @@ Function Invoke-AddDlpCompliancePolicyTemplate { [pscustomobject]$Request.Body } + # A policy that is pending deletion can't be redeployed, so templating it would only capture an + # undeployable snapshot - reject rather than store it. + if (($Source.Mode ?? '') -eq 'PendingDeletion') { + throw "DLP policy '$($Source.Name ?? $Source.name)' is pending deletion and cannot be saved as a template." + } + $Clean = Format-CIPPCompliancePolicyParams -Source $Source -AllowedFields $AllowedFields -LocationFields $LocationFields + # Defensive: drop any other Mode the cmdlets won't accept as input. + if ($Clean.ContainsKey('Mode') -and $Clean['Mode'] -notin $Fields.ValidPolicyModes) { + $Clean.Remove('Mode') | Out-Null + } + + # Capture the policy's detection rules into RuleParams so the template carries the actual + # DLP logic (sensitive info types, severity, notifications) rather than just the policy shell. + # The list endpoint surfaces these as AssociatedRules; a policy can have more than one. + $AssociatedRules = @($Source.AssociatedRules) | Where-Object { $_ } + if ($AssociatedRules.Count -gt 0) { + $RuleParams = foreach ($Rule in $AssociatedRules) { + $RuleClean = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $RuleAllowedFields + $RuleClean.Remove('Policy') | Out-Null # added at deploy time, not stored + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleClean.ContainsKey($SitField)) { + $RuleClean[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleClean[$SitField]) + } + } + # Get-* returns IncidentReportContent as a comma-joined string; store it as the array + # the New-/Set-* cmdlets expect (a ReportContentOption[]). + if ($RuleClean.ContainsKey('IncidentReportContent') -and $RuleClean['IncidentReportContent'] -is [string]) { + $RuleClean['IncidentReportContent'] = @($RuleClean['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + [pscustomobject]$RuleClean + } + $Clean['RuleParams'] = @($RuleParams) + } elseif ($Source.RuleParams) { + $Clean['RuleParams'] = $Source.RuleParams + } + $Ordered = [ordered]@{ name = $Clean['Name'] ?? $Source.Name ?? $Source.name comments = $Source.Comment ?? $Source.comments diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 index 6c3f4b2d7b0c0..08587a91f1cd8 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 @@ -50,37 +50,53 @@ function Invoke-CIPPStandardDlpCompliancePolicyTemplate { return } - if ($Settings.remediate -eq $true) { - foreach ($Template in @($Templates)) { - $null = Set-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template -APIName 'Standards' + # Compare each template against the live policy + rules. Remediate only what actually drifts (or is + # missing) - an in-sync policy is left untouched. After a successful remediation we re-compare so the + # report/alert reflect the corrected state. A PendingDeletion policy can't be modified, so it is + # surfaced as non-compliant rather than redeployed (the deploy would just fail). + $Comparisons = foreach ($Template in @($Templates)) { + $Comparison = Compare-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template + + if ($Settings.remediate -eq $true -and $Comparison.State -in @('Missing', 'Drift')) { + $DeployResult = Set-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template -APIName 'Standards' + if ($DeployResult -match '^(Could not deploy|Failed)') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $DeployResult -sev Error + $Comparison | Add-Member -NotePropertyName DeployError -NotePropertyValue "$DeployResult" -Force + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Remediated DLP policy '$($Comparison.Name)' ($($Comparison.State)): $DeployResult" -sev Info + $Comparison = Compare-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template + } } + $Comparison } - # Determine which templated policies are present in the tenant for alert/report modes - $ExistingPolicyNames = try { - @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object -ExpandProperty Name) - } catch { @() } - - $MissingPolicies = @(foreach ($Template in @($Templates)) { - $TemplateName = $Template.Name ?? $Template.name - if ($ExistingPolicyNames -notcontains $TemplateName) { $TemplateName } - }) + $NonCompliant = @($Comparisons | Where-Object { $_.State -ne 'InSync' }) if ($Settings.alert -eq $true) { - if ($MissingPolicies.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected DLP compliance policy templates are deployed.' -sev Info + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected DLP compliance policy templates are deployed and in sync.' -sev Info } else { - $AlertMessage = "DLP compliance policies not deployed in tenant: $($MissingPolicies -join ', ')" - Write-StandardsAlert -message $AlertMessage -object @{ MissingPolicies = $MissingPolicies } -tenant $Tenant -standardName 'DlpCompliancePolicyTemplate' -standardId $Settings.standardId + $Summary = $NonCompliant | ForEach-Object { + if ($_.State -eq 'Drift') { + $Fields = @($_.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) -join ', ' + "$($_.Name): drift in $Fields" + } else { + "$($_.Name): $($_.State)" + } + } + $AlertMessage = "DLP compliance policy templates not in sync: $($Summary -join '; ')" + Write-StandardsAlert -message $AlertMessage -object @{ NonCompliantPolicies = $NonCompliant } -tenant $Tenant -standardName 'DlpCompliancePolicyTemplate' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } } if ($Settings.report -eq $true) { - $CurrentValue = @{ MissingPolicies = $MissingPolicies } - $ExpectedValue = @{ MissingPolicies = @() } + # Expose the actual drift (per policy: state + the differing fields with expected vs current + # values) rather than just a list of missing names. + $CurrentValue = @{ NonCompliantPolicies = $NonCompliant } + $ExpectedValue = @{ NonCompliantPolicies = @() } Set-CIPPStandardsCompareField -FieldName 'standards.DlpCompliancePolicyTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant - Add-CIPPBPAField -FieldName 'DlpCompliancePolicyTemplate' -FieldValue ($MissingPolicies.Count -eq 0) -StoreAs bool -Tenant $Tenant + Add-CIPPBPAField -FieldName 'DlpCompliancePolicyTemplate' -FieldValue ($NonCompliant.Count -eq 0) -StoreAs bool -Tenant $Tenant } } From 03ae94ec934f943fbb4b632d742d64232e0bacf3 Mon Sep 17 00:00:00 2001 From: Tim Fournet Date: Fri, 26 Jun 2026 13:52:32 -0500 Subject: [PATCH 095/150] Add typed-response + operationId enrichment for openapi.json The generated openapi.json types every request body but leaves each 200 response as the generic StandardResults envelope, and carries no operationId on any operation. That makes the spec hard to consume: OpenAPI importers that key on operationId skip every operation, and downstream tools get no typed output fields to map against. This adds a deterministic post-processing stage that enriches the generated spec without replacing the upstream generator: - operationId injection: bare endpoint name per operation, method-prefixed only where a single path carries multiple methods. Existing operationIds are preserved; collisions are a hard failure. - typed 200 responses: derived from two checked-in sources, the frontend shape baselines (Tests/Shapes/*.json) and page simpleColumns declarations. The { Results, Metadata } envelope is preserved as the API actually returns it. The enriched spec is published as the openapi.enriched.json asset on each GitHub Release. A PR check workflow runs the test suite and strictly lints the enriched spec against a committed ignore-baseline that pins pre-existing upstream findings, so any new finding fails CI. Pure function of the two repositories; same input produces byte-identical output. Stage and runner are PowerShell with a Pester suite (50 tests). --- .build/Add-OpenApiResponseSchemas.ps1 | 349 +++++++++++++ .build/README.md | 38 ++ .github/workflows/openapi-enriched-check.yml | 83 +++ .../workflows/openapi-enriched-release.yml | 86 +++ .redocly.lint-ignore.yaml | 12 + .../Add-OpenApiResponseSchemas.Tests.ps1 | 488 ++++++++++++++++++ Tests/Build/Invoke-BuildTests.ps1 | 46 ++ redocly.yaml | 5 + 8 files changed, 1107 insertions(+) create mode 100644 .build/Add-OpenApiResponseSchemas.ps1 create mode 100644 .build/README.md create mode 100644 .github/workflows/openapi-enriched-check.yml create mode 100644 .github/workflows/openapi-enriched-release.yml create mode 100644 .redocly.lint-ignore.yaml create mode 100644 Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 create mode 100644 Tests/Build/Invoke-BuildTests.ps1 create mode 100644 redocly.yaml diff --git a/.build/Add-OpenApiResponseSchemas.ps1 b/.build/Add-OpenApiResponseSchemas.ps1 new file mode 100644 index 0000000000000..3e90a214a1b13 --- /dev/null +++ b/.build/Add-OpenApiResponseSchemas.ps1 @@ -0,0 +1,349 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Enriches a CIPP openapi.json with typed 200 response schemas derived by static + analysis of the API and frontend repositories. + +.DESCRIPTION + The generated CIPP spec types every request body but leaves every 200 response + as the generic StandardResults envelope. This stage fills typed per-endpoint + response schemas for the read surface, using two deterministic sources that are + already checked into the repositories (no live API calls): + + 1. Captured response shape baselines (CIPP/Tests/Shapes/*.json) - carry real + field types and nesting. Preferred when present. + 2. Frontend table column declarations (simpleColumns in CIPP/src pages) - + carry field names only. Used when no baseline exists; fields are typed as + string and marked x-cipp-field-source: frontend so consumers know the type + is a name-only inference, not a verified type. + + Endpoints with neither source keep the StandardResults envelope, which is the + correct shape for write/exec operations. Output is deterministic: the same input + repositories always produce a byte-identical spec. + +.PARAMETER InputSpec + Path to the source openapi.json. Defaults to the repo-root spec relative to this + script (.build/.. ). + +.PARAMETER OutputSpec + Path to write the enriched spec. Defaults to InputSpec (in-place rewrite). + +.PARAMETER FrontendRepoPath + Path to a checkout of the CIPP frontend repository. Provides both the shape + baselines (Tests/Shapes) and the page column declarations (src). + +.PARAMETER PassThru + Return the enriched spec object instead of only writing it. Used by tests. + +.EXAMPLE + ./Add-OpenApiResponseSchemas.ps1 -FrontendRepoPath ../CIPP + + Rewrites the repo-root openapi.json in place with typed response schemas. +#> +[CmdletBinding()] +param( + [string]$InputSpec = (Join-Path $PSScriptRoot '..' 'openapi.json'), + [string]$OutputSpec, + [string]$FrontendRepoPath, + [switch]$PassThru +) + +$ErrorActionPreference = 'Stop' + +$script:CippHttpMethods = @('get', 'post', 'put', 'patch', 'delete') + +function ConvertFrom-ShapeNode { + <# + .SYNOPSIS + Converts one node of a captured shape tree into an OpenAPI schema fragment. + #> + param($Node) + + if ($Node -is [string]) { + switch ($Node) { + 'string' { return @{ type = 'string' } } + 'number' { return @{ type = 'number' } } + 'bool' { return @{ type = 'boolean' } } + 'datetime' { return [ordered]@{ type = 'string'; format = 'date-time' } } + # 'null' (captured as null at sample time) and 'truncated' (below the + # capture depth limit) carry no reliable type, so stay permissive. + default { return @{} } + } + } + + if ($Node -is [System.Collections.IDictionary]) { + if ($Node['_type'] -eq 'array') { + return [ordered]@{ type = 'array'; items = (ConvertFrom-ShapeNode -Node $Node['_element']) } + } + $properties = [ordered]@{} + foreach ($key in ($Node.Keys | Sort-Object)) { + $properties[[string]$key] = ConvertFrom-ShapeNode -Node $Node[$key] + } + return [ordered]@{ type = 'object'; properties = $properties } + } + + return @{} +} + +function Get-ShapeBaselineMap { + <# + .SYNOPSIS + Maps endpoint name -> per-record OpenAPI schema, from captured shape baselines. + .DESCRIPTION + Reads only files carrying both _metadata and shape; the sibling + test-results.json and any non-baseline file is skipped. The per-record schema + is the baseline shape itself (the CIPP envelope's Results[] element). + #> + param([string]$ShapesDir) + + $map = @{} + if (-not (Test-Path $ShapesDir)) { + Write-Warning "Shapes directory not found: $ShapesDir" + return $map + } + + foreach ($file in (Get-ChildItem -Path $ShapesDir -Filter '*.json' | Sort-Object -Property FullName)) { + $doc = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + if (-not ($doc -is [System.Collections.IDictionary] -and $doc.ContainsKey('_metadata') -and $doc.ContainsKey('shape'))) { + continue + } + $endpoint = $doc['_metadata']['endpoint'] + if (-not $endpoint) { continue } + $map[$endpoint] = ConvertFrom-ShapeNode -Node $doc['shape'] + } + return $map +} + +function Get-FrontendColumnMap { + <# + .SYNOPSIS + Maps endpoint name -> sorted unique field names, from page simpleColumns. + .DESCRIPTION + Intent: skips conditional simpleColumns arrays to avoid non-column branch strings; false negatives beat junk fields. + Scans frontend page sources for files that pair an /api/ reference + with a simpleColumns array, and unions the declared column names per endpoint. + Field names are deterministic; their types are not, so callers type them as + string with a provenance marker. + #> + param([string]$SrcDir) + + $map = @{} + if (-not (Test-Path $SrcDir)) { + Write-Warning "Frontend src directory not found: $SrcDir" + return $map + } + + $endpointPattern = [regex]'/api/([A-Za-z0-9_]+)' + $columnsPattern = [regex]'(?s)\bsimpleColumns\s*(?:=|:)\s*(?:\{\s*)?\[(?[^\]]*)\]' + $stringPattern = [regex]'"([^"]+)"|''([^'']+)''' + + $files = Get-ChildItem -Path $SrcDir -Recurse -File -Include '*.js', '*.jsx' + foreach ($file in $files) { + $text = Get-Content -LiteralPath $file.FullName -Raw + if ([string]::IsNullOrEmpty($text) -or $text -notmatch 'simpleColumns') { continue } + + $endpoints = $endpointPattern.Matches($text) | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique + if (-not $endpoints) { continue } + + $columns = foreach ($colMatch in $columnsPattern.Matches($text)) { + foreach ($strMatch in $stringPattern.Matches($colMatch.Groups['columns'].Value)) { + $value = if ($strMatch.Groups[1].Success) { $strMatch.Groups[1].Value } else { $strMatch.Groups[2].Value } + if ($value) { $value } + } + } + if (-not $columns) { continue } + + foreach ($endpoint in $endpoints) { + if (-not $map.ContainsKey($endpoint)) { $map[$endpoint] = [System.Collections.Generic.HashSet[string]]::new() } + foreach ($column in $columns) { [void]$map[$endpoint].Add($column) } + } + } + return $map +} + +function ConvertTo-ColumnRecordSchema { + <# + .SYNOPSIS + Builds a per-record object schema from a set of frontend column names. + #> + param([System.Collections.Generic.HashSet[string]]$Columns) + + $properties = [ordered]@{} + foreach ($column in ($Columns | Sort-Object)) { + $properties[$column] = [ordered]@{ type = 'string'; 'x-cipp-field-source' = 'frontend' } + } + return [ordered]@{ type = 'object'; properties = $properties } +} + +function ConvertTo-ResponseEnvelopeSchema { + <# + .SYNOPSIS + Wraps a per-record schema in the CIPP { Results: [...], Metadata: {...} } envelope. + #> + param($RecordSchema) + + return [ordered]@{ + type = 'object' + properties = [ordered]@{ + Results = [ordered]@{ type = 'array'; items = $RecordSchema } + Metadata = [ordered]@{ type = 'object' } + } + } +} + + +function Get-CippOperationId { + <# + .SYNOPSIS + Builds the deterministic operationId for one CIPP path and method. + .DESCRIPTION + Riftwing imports OpenAPI operations by operationId. CIPP upstream does not + currently emit operationIds, so this keeps importer keys stable without + depending on display labels or external data. + #> + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string[]]$PathMethods + ) + + $endpointName = $Path -replace '^/api/', '' + if ($PathMethods.Count -eq 1) { + return $endpointName + } + + $methodName = [System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($Method.ToLowerInvariant()) + return "$methodName$endpointName" +} + +function Add-CippOperationId { + <# + .SYNOPSIS + Injects missing operationIds and fails on duplicate operationIds. + .DESCRIPTION + Existing non-empty operationIds are preserved so this pass can retire itself + when upstream starts emitting operationIds. Duplicate operationIds are fatal + because importers commonly key operations by operationId. + #> + param([Parameter(Mandatory)][System.Collections.IDictionary]$Spec) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $injectedCount = 0 + $operationIds = @{} + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $pathMethods = @($pathEntry.Value.Keys | Where-Object { $_ -in $script:CippHttpMethods }) + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + + $operationCount++ + $operation = $methodEntry.Value + $operationId = $operation['operationId'] + if ([string]::IsNullOrWhiteSpace([string]$operationId)) { + $operationId = Get-CippOperationId -Path $pathEntry.Key -Method $methodEntry.Key -PathMethods $pathMethods + $operation['operationId'] = $operationId + $injectedCount++ + } + + if ($operationIds.ContainsKey($operationId)) { + throw "Duplicate operationId found: $operationId" + } + $operationIds[$operationId] = $true + } + } + + return [pscustomobject]@{ Operations = $operationCount; Injected = $injectedCount; Unique = $operationIds.Count } +} + +function Resolve-SpecResponse { + <# + .SYNOPSIS + Adds typed 200 response schemas to a parsed spec, in place, and returns counts. + .DESCRIPTION + The pure core of this stage: operates on an already-parsed spec hashtable and + the two endpoint maps, with no file or repository access, so it is unit + testable. Only existing 200 responses on get/post/put/patch/delete operations + are touched; everything else (including operations with no matching source) is + left exactly as found. + #> + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Spec, + [Parameter(Mandatory)][hashtable]$BaselineMap, + [Parameter(Mandatory)][hashtable]$ColumnMap + ) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $typedCount = 0 + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $endpoint = $pathEntry.Key -replace '^/api/', '' + + $recordSchema = $null + if ($BaselineMap.ContainsKey($endpoint)) { + $recordSchema = $BaselineMap[$endpoint] + } elseif ($ColumnMap.ContainsKey($endpoint)) { + $recordSchema = ConvertTo-ColumnRecordSchema -Columns $ColumnMap[$endpoint] + } + + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + $operationCount++ + if ($null -eq $recordSchema) { continue } + + $responses = $methodEntry.Value['responses'] + if ($null -eq $responses) { continue } + + $okResponse = $responses['200'] + if (-not $okResponse) { continue } + + $okResponse['content'] = [ordered]@{ + 'application/json' = [ordered]@{ schema = (ConvertTo-ResponseEnvelopeSchema -RecordSchema $recordSchema) } + } + $typedCount++ + } + } + + return [pscustomobject]@{ + Operations = $operationCount + Typed = $typedCount + } +} + +function Add-CippResponseSchema { + <# + .SYNOPSIS + File-level orchestration: read spec + repo sources, enrich, write output. + #> + param( + [Parameter(Mandatory)][string]$InputSpec, + [Parameter(Mandatory)][string]$OutputSpec, + [Parameter(Mandatory)][string]$FrontendRepoPath, + [switch]$PassThru + ) + + if (-not (Test-Path $InputSpec)) { throw "Input spec not found: $InputSpec" } + + $spec = Get-Content -LiteralPath $InputSpec -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $baselineMap = Get-ShapeBaselineMap -ShapesDir (Join-Path $FrontendRepoPath 'Tests' 'Shapes') + $columnMap = Get-FrontendColumnMap -SrcDir (Join-Path $FrontendRepoPath 'src') + + $operationIdResult = Add-CippOperationId -Spec $spec + $result = Resolve-SpecResponse -Spec $spec -BaselineMap $baselineMap -ColumnMap $columnMap + Write-Information "Operations: $($result.Operations) | typed responses added: $($result.Typed) | operationIds injected: $($operationIdResult.Injected) | unique operationIds: $($operationIdResult.Unique)" -InformationAction Continue + + # Serialization is deterministic for the object this stage builds, but it does not globally canonicalize pre-existing spec keys. + [System.IO.File]::WriteAllText($OutputSpec, ($spec | ConvertTo-Json -Depth 100)) + + if ($PassThru) { return $spec } +} + +# Run orchestration only when invoked as a script, not when dot-sourced for testing. +if ($MyInvocation.InvocationName -ne '.') { + if (-not $FrontendRepoPath) { throw 'FrontendRepoPath is required when running the script.' } + if (-not $OutputSpec) { $OutputSpec = $InputSpec } + Add-CippResponseSchema -InputSpec $InputSpec -OutputSpec $OutputSpec -FrontendRepoPath $FrontendRepoPath -PassThru:$PassThru +} diff --git a/.build/README.md b/.build/README.md new file mode 100644 index 0000000000000..81d8ce08a6dd6 --- /dev/null +++ b/.build/README.md @@ -0,0 +1,38 @@ +# OpenAPI enrichment + +`Add-OpenApiResponseSchemas.ps1` post-processes the generated CIPP `openapi.json`. It adds deterministic operationIds and typed `200` response schemas where response shape data can be derived from the CIPP frontend repository. It does not replace the upstream OpenAPI generator. + +The enriched spec is published on each GitHub Release as the `openapi.enriched.json` release asset. + +The PR check and release workflow strictly lint the CI-generated `openapi.enriched.json` with Redocly. The committed `.redocly.lint-ignore.yaml` baseline pins findings that already exist in the generated enriched spec because of upstream `openapi.json` issues. Any new Redocly error or warning that is not in the baseline fails CI. + +To regenerate locally, check out the CIPP frontend repository and run: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +``` + +If upstream `openapi.json` legitimately changes and the pinned Redocly findings must be refreshed, regenerate the enriched spec first, then regenerate the ignore baseline from that enriched output: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --generate-ignore-file +``` + +Do not generate the baseline from the base `openapi.json`. The lint subject is always the generated `openapi.enriched.json`. + +## Known limitations + +- Only `get`, `post`, `put`, `patch`, and `delete` operations are processed. `head`, `options`, and `trace` are not present in the current spec. +- Paths are assumed to start with `/api/`. All 580 current paths do. +- When a typed `200` response is added, it replaces the existing `200.content`. Today that content is only the generic `StandardResults` envelope. +- Conditional/ternary `simpleColumns` expressions are intentionally not parsed. + +## Release workflow notes + +- `openapi-enriched-release.yml` builds and uploads from the same tag. On `workflow_dispatch`, the `tag` input is checked out and used as the upload target. On `release: published`, the release tag is checked out and used as the upload target. +- `.github/workflows/` is gitignored in this repository, so the OpenAPI workflow files require `git add -f` when they are intentionally added or updated. diff --git a/.github/workflows/openapi-enriched-check.yml b/.github/workflows/openapi-enriched-check.yml new file mode 100644 index 0000000000000..cb93afbf9aba2 --- /dev/null +++ b/.github/workflows/openapi-enriched-check.yml @@ -0,0 +1,83 @@ +name: OpenAPI Enriched Spec Check + +on: + pull_request: + paths: + - '.build/**' + - '.github/workflows/openapi-enriched-check.yml' + - '.github/workflows/openapi-enriched-release.yml' + - '.redocly.lint-ignore.yaml' + - 'redocly.yaml' + - 'Tests/Build/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + openapi-enriched-check: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Checkout CIPP-API + uses: actions/checkout@v4 + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Install PowerShell test modules + shell: pwsh + run: | + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + + - name: Run build tests + shell: pwsh + run: ./Tests/Build/Invoke-BuildTests.ps1 + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE diff --git a/.github/workflows/openapi-enriched-release.yml b/.github/workflows/openapi-enriched-release.yml new file mode 100644 index 0000000000000..853e5542bc1a9 --- /dev/null +++ b/.github/workflows/openapi-enriched-release.yml @@ -0,0 +1,86 @@ +name: Publish Enriched OpenAPI Spec + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to upload the enriched spec to + required: true + type: string + +permissions: + contents: write + +jobs: + openapi-enriched-release: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Select release tag + id: release_tag + env: + RESOLVED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + run: printf 'tag=%s\n' "$RESOLVED_TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout CIPP-API + uses: actions/checkout@v4 + with: + ref: ${{ steps.release_tag.outputs.tag }} + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE + + - name: Upload enriched OpenAPI spec to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} + run: gh release upload "$RELEASE_TAG" openapi.enriched.json --clobber diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000000000..2aaa3434d8bf2 --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,12 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redocly.com/docs/cli/ for more information. +openapi.enriched.json: + info-license: + - '#/info' + no-schema-type-mismatch: + - >- + #/paths/~1api~1DeployContactTemplates/post/requestBody/content/application~1json/schema/properties/TemplateList/items + no-unused-components: + - '#/components/parameters/selectedTenants' + - '#/components/schemas/LabelValueNumber' + - '#/components/schemas/DynamicExtensionFields' diff --git a/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 new file mode 100644 index 0000000000000..ee1d7ebc8fdfd --- /dev/null +++ b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 @@ -0,0 +1,488 @@ +#Requires -Version 7.0 +<# + Pester tests for Add-OpenApiResponseSchemas.ps1. + + Covers the pure core (Resolve-SpecResponse + the schema/source converters) with + hand-built fixtures, plus the sad paths that matter for a generator stage: + malformed input, non-baseline files, missing sources, and operations the stage + must leave untouched. Dot-sources the script so only its functions load. +#> + +BeforeAll { + # Test lives at /Tests/Build/; the script lives at /.build/. + $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $ScriptPath = Join-Path $RepoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' + . $ScriptPath + + # Minimal spec factory: one path with the given methods, each carrying a bare 200. + function Get-TestSpec { + param([hashtable]$Paths) + return [ordered]@{ openapi = '3.1.0'; paths = $Paths } + } + function Get-Operation { + param( + [switch]$NoOkResponse, + [string]$OperationId + ) + $responses = if ($NoOkResponse) { @{ '500' = @{ description = 'err' } } } else { @{ '200' = @{ description = 'ok' } } } + $operation = @{ responses = $responses } + if ($OperationId) { $operation['operationId'] = $OperationId } + return $operation + } +} + +Describe 'ConvertFrom-ShapeNode' { + Context 'Leaf tokens' { + It 'maps string' { (ConvertFrom-ShapeNode -Node 'string').type | Should -Be 'string' } + It 'maps number' { (ConvertFrom-ShapeNode -Node 'number').type | Should -Be 'number' } + It 'maps bool to boolean' { (ConvertFrom-ShapeNode -Node 'bool').type | Should -Be 'boolean' } + It 'maps datetime to string+format' { + $r = ConvertFrom-ShapeNode -Node 'datetime' + $r.type | Should -Be 'string' + $r.format | Should -Be 'date-time' + } + It 'leaves null permissive (no type)' { (ConvertFrom-ShapeNode -Node 'null').Keys.Count | Should -Be 0 } + It 'leaves truncated permissive (no type)' { (ConvertFrom-ShapeNode -Node 'truncated').Keys.Count | Should -Be 0 } + It 'leaves unknown tokens permissive' { (ConvertFrom-ShapeNode -Node 'mystery').Keys.Count | Should -Be 0 } + } + + Context 'Nested structures' { + It 'maps an array node to type array with typed items' { + $node = @{ _type = 'array'; _element = 'string' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'array' + $r.items.type | Should -Be 'string' + } + It 'maps an object node and sorts its properties' { + $node = [ordered]@{ zeta = 'string'; alpha = 'number' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'object' + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } + It 'maps an array of objects' { + $node = @{ _type = 'array'; _element = @{ id = 'string'; count = 'number' } } + $r = ConvertFrom-ShapeNode -Node $node + $r.items.type | Should -Be 'object' + $r.items.properties.id.type | Should -Be 'string' + } + } +} + +Describe 'ConvertTo-ColumnRecordSchema' { + It 'types every column as string with frontend provenance' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('mail'); [void]$cols.Add('displayName') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + $r.type | Should -Be 'object' + $r.properties.mail.type | Should -Be 'string' + $r.properties.mail.'x-cipp-field-source' | Should -Be 'frontend' + } + It 'sorts columns for deterministic output' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('zeta'); [void]$cols.Add('alpha') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } +} + +Describe 'ConvertTo-ResponseEnvelopeSchema' { + It 'wraps a record schema in the Results/Metadata envelope' { + $record = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } + $r = ConvertTo-ResponseEnvelopeSchema -RecordSchema $record + $r.type | Should -Be 'object' + $r.properties.Results.type | Should -Be 'array' + $r.properties.Results.items.properties.id.type | Should -Be 'string' + $r.properties.Metadata.type | Should -Be 'object' + } +} + + +Describe 'Add-CippOperationId' { + It 'injects the bare endpoint name for a single-method operation with no operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'ListMailboxes' + $r.Injected | Should -Be 1 + $r.Unique | Should -Be 1 + } + + It 'keeps single-method endpoint names bare even when they start with their method word' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/ListX' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/PatchUser']['patch']['operationId'] | Should -Be 'PatchUser' + $spec['paths']['/api/ListX']['get']['operationId'] | Should -Be 'ListX' + } + + It 'gives a multi-method path two distinct operationIds' { + $spec = Get-TestSpec -Paths @{ '/api/ExecCSPLicense' = @{ get = (Get-Operation); post = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ExecCSPLicense']['get']['operationId'] | Should -Be 'GetExecCSPLicense' + $spec['paths']['/api/ExecCSPLicense']['post']['operationId'] | Should -Be 'PostExecCSPLicense' + $r.Unique | Should -Be 2 + } + + It 'preserves a pre-existing operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation -OperationId 'AlreadyThere') } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'AlreadyThere' + $r.Injected | Should -Be 0 + } + + It 'throws when a synthetic duplicate operationId is present' { + $spec = Get-TestSpec -Paths @{ + '/api/DuplicateA' = @{ get = (Get-Operation -OperationId 'SameOperation') } + '/api/DuplicateB' = @{ post = (Get-Operation -OperationId 'SameOperation') } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameOperation*' + } + + It 'keeps visibly different endpoint names distinct without hidden normalization' { + $spec = Get-TestSpec -Paths @{ + '/api/User' = @{ get = (Get-Operation) } + '/api/ListUsers' = @{ get = (Get-Operation) } + '/api/List-Users' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/User']['get']['operationId'] | Should -Be 'User' + $spec['paths']['/api/ListUsers']['get']['operationId'] | Should -Be 'ListUsers' + $spec['paths']['/api/List-Users']['get']['operationId'] | Should -Be 'List-Users' + } + + It 'throws when disambiguated derivation creates an actual duplicate' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/User' = @{ get = (Get-Operation); patch = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: PatchUser*' + } + + It 'throws when two single-method paths have the same bare endpoint name' { + $spec = Get-TestSpec -Paths @{ + '/api/SameName' = @{ get = (Get-Operation) } + 'SameName' = @{ post = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameName*' + } + + It 'ignores non-method path item keys when injecting operationIds' { + $spec = Get-TestSpec -Paths @{ + '/api/ListThings' = [ordered]@{ + get = (Get-Operation) + parameters = @(@{ name = 'tenant'; in = 'query' }) + summary = 'path summary' + '$ref' = '#/components/pathItems/ListThings' + description = 'path description' + } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + $spec['paths']['/api/ListThings']['parameters'][0].ContainsKey('operationId') | Should -BeFalse + $spec['paths']['/api/ListThings']['summary'] | Should -Be 'path summary' + $spec['paths']['/api/ListThings']['$ref'] | Should -Be '#/components/pathItems/ListThings' + $spec['paths']['/api/ListThings']['description'] | Should -Be 'path description' + } + + It 'yields one unique operationId per operation on the full real spec' { + $specPath = Join-Path $RepoRoot 'openapi.json' + $spec = Get-Content -LiteralPath $specPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $operationTotal = 0 + foreach ($pathEntry in $spec['paths'].GetEnumerator()) { + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -in @('get', 'post', 'put', 'patch', 'delete')) { $operationTotal++ } + } + } + $r = Add-CippOperationId -Spec $spec + Write-Information "Full real spec operationIds: $($r.Unique)" -InformationAction Continue + $r.Operations | Should -Be $operationTotal + $r.Unique | Should -Be $r.Operations + } +} + +Describe 'Resolve-SpecResponse - happy paths' { + It 'types a baseline-backed endpoint and counts it' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.properties.id.type | Should -Be 'string' + } + + It 'prefers the baseline when an endpoint is in both maps' { + $spec = Get-TestSpec -Paths @{ '/api/Both' = @{ get = (Get-Operation) } } + $baseline = @{ Both = [ordered]@{ type = 'object'; properties = [ordered]@{ fromBaseline = @{ type = 'string' } } } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('fromColumns') + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{ Both = $cols } | Out-Null + $props = $spec['paths']['/api/Both']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.Keys | Should -Contain 'fromBaseline' + $props.Keys | Should -Not -Contain 'fromColumns' + } + + + + It 'does not inject operationIds while typing responses' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} | Out-Null + $spec['paths']['/api/ListThings']['get'].ContainsKey('operationId') | Should -BeFalse + } + + It 'types a columns-only endpoint with provenance markers' { + $spec = Get-TestSpec -Paths @{ '/api/ListCols' = @{ get = (Get-Operation) } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('displayName') + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{ ListCols = $cols } | Out-Null + $props = $spec['paths']['/api/ListCols']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.displayName.'x-cipp-field-source' | Should -Be 'frontend' + } +} + +Describe 'Resolve-SpecResponse - sad paths and invariants' { + It 'leaves an endpoint with no matching source untouched' { + $spec = Get-TestSpec -Paths @{ '/api/AddUser' = @{ post = (Get-Operation) } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + $spec['paths']['/api/AddUser']['post']['responses']['200'].ContainsKey('content') | Should -BeFalse + } + + It 'does not type an operation that has no 200 response' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = (Get-Operation -NoOkResponse) } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 0 + } + + It 'does not throw on an operation with no responses block' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = @{} } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + } + + It 'types a baseline-backed endpoint even when the record schema is permissive' { + $spec = Get-TestSpec -Paths @{ '/api/ListUnknown' = @{ get = (Get-Operation) } } + $baseline = @{ ListUnknown = @{} } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListUnknown']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.Keys.Count | Should -Be 0 + } + + It 'counts only get/post/put/patch/delete, ignoring parameters/summary keys' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = [ordered]@{ get = (Get-Operation); parameters = @(); summary = 'x' } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + } + + It 'preserves the operation set (adds nothing, removes nothing)' { + $spec = Get-TestSpec -Paths @{ + '/api/ListA' = @{ get = (Get-Operation) } + '/api/AddB' = @{ post = (Get-Operation) } + } + $before = $spec['paths'].Keys | Sort-Object + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} | Out-Null + ($spec['paths'].Keys | Sort-Object) | Should -Be $before + } + + It 'throws on a spec with no paths' { + { Resolve-SpecResponse -Spec ([ordered]@{ openapi = '3.1.0' }) -BaselineMap @{} -ColumnMap @{} } | + Should -Throw '*no paths*' + } + + It 'is stable on a second core pass (same count, same typed schema)' { + # Production never re-mutates an in-memory spec; it reads fresh each run. The + # guarantee that matters is that re-applying yields the same meaningful output, + # not that an unordered Hashtable serialises in identical key order. + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r1 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema1 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema2 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2.Typed | Should -Be $r1.Typed + $schema2 | Should -Be $schema1 + } +} + +Describe 'Get-ShapeBaselineMap - file ingestion sad paths' { + BeforeEach { + $script:ShapesDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-shapes-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:ShapesDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:ShapesDir -ErrorAction SilentlyContinue } + + It 'ingests a valid baseline' { + @{ _metadata = @{ endpoint = 'ListX' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'ListX.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.ContainsKey('ListX') | Should -BeTrue + } + + It 'skips a file that lacks _metadata/shape (e.g. test-results.json)' { + @{ results = @(@{ status = 'PASS' }) } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'test-results.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.Count | Should -Be 0 + } + + It 'returns empty for a missing directory without throwing' { + $map = Get-ShapeBaselineMap -ShapesDir (Join-Path $script:ShapesDir 'does-not-exist') + $map.Count | Should -Be 0 + } +} + +Describe 'Get-FrontendColumnMap - parsing sad paths' { + BeforeEach { + $script:SrcDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-src-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:SrcDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:SrcDir -ErrorAction SilentlyContinue } + + It 'extracts columns paired with an api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ["displayName", "mail"]; + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListMailboxes'] | Should -Contain 'displayName' + $map['ListMailboxes'] | Should -Contain 'mail' + } + + It 'handles mixed single and double quotes in the column array' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ['mail', "displayName"]; +apiUrl="/api/ListThings" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListThings'].Count | Should -Be 2 + } + + It 'handles JSX simpleColumns arrays split after the opening brace' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListSplit'] | Should -Contain 'displayName' + $map['ListSplit'] | Should -Contain 'mail' + } + + + + It 'does not leak scalar ternary branch strings near simpleColumns' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const statusLabel = enabled ? "yes" : "no"; +const simpleColumns = ["displayName", "mail"]; +apiUrl="/api/ListTernarySafe" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListTernarySafe'] | Should -Contain 'displayName' + $map['ListTernarySafe'] | Should -Contain 'mail' + $map['ListTernarySafe'] | Should -Not -Contain 'yes' + $map['ListTernarySafe'] | Should -Not -Contain 'no' + } + + It 'does not capture branch strings when simpleColumns is a ternary of arrays' { + # This lightweight parser only accepts direct array literals. Conditional + # simpleColumns values are ignored rather than risking scalar branch leaks. + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = hasScope + ? ['RowKey', 'Value', 'Description'] + : ['RowKey', 'Value', 'Description', 'Scope']; +const label = hasScope ? "yes" : "no"; +apiUrl="/api/ListCustomVariables" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.ContainsKey('ListCustomVariables') | Should -BeFalse + foreach ($columns in $map.Values) { + $columns | Should -Not -Contain 'yes' + $columns | Should -Not -Contain 'no' + } + } + + It 'ignores a file with simpleColumns but no api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') 'const simpleColumns = ["x"];' + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.Count | Should -Be 0 + } + + It 'does not crash on an empty file' { + Set-Content (Join-Path $script:SrcDir 'empty.jsx') '' + { Get-FrontendColumnMap -SrcDir $script:SrcDir } | Should -Not -Throw + } + + It 'returns empty for a missing src directory without throwing' { + $map = Get-FrontendColumnMap -SrcDir (Join-Path $script:SrcDir 'nope') + $map.Count | Should -Be 0 + } +} + +Describe 'Add-CippResponseSchema - end to end on a temp spec' { + It 'throws when the input spec does not exist' { + { Add-CippResponseSchema -InputSpec 'X:\nope.json' -OutputSpec 'X:\out.json' -FrontendRepoPath '.' } | + Should -Throw '*not found*' + } + + It 'throws when the input spec contains malformed JSON' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-bad-json-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Set-Content -LiteralPath $specPath -Value '{ "openapi": "3.1.0", "paths": ' + + { Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp } | + Should -Throw + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'reads, enriches, and writes a real file round-trip' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-e2e-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $tmp | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp | Out-Null + $out = Get-Content $outPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $out['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties.id.type | + Should -Be 'string' + $out['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'is byte-identical when run twice on the same sources (file-level idempotency)' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-idem-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string'; when = 'datetime' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + $out1 = Join-Path $tmp 'o1.json' + $out2 = Join-Path $tmp 'o2.json' + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $out1 -FrontendRepoPath $tmp | Out-Null + Add-CippResponseSchema -InputSpec $out1 -OutputSpec $out2 -FrontendRepoPath $tmp | Out-Null + (Get-Content $out2 -Raw) | Should -Be (Get-Content $out1 -Raw) + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} diff --git a/Tests/Build/Invoke-BuildTests.ps1 b/Tests/Build/Invoke-BuildTests.ps1 new file mode 100644 index 0000000000000..5c7b7f96b8fd4 --- /dev/null +++ b/Tests/Build/Invoke-BuildTests.ps1 @@ -0,0 +1,46 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs static analysis and the OpenAPI response schema Pester suite. + +.DESCRIPTION + Conventional one-command verification for the response schema build stage. + Checks PSScriptAnalyzer warning and error findings for the stage script and + its tests, then runs the focused Pester 5 suite. Exits non-zero on failure. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$scriptPath = Join-Path $repoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' +$testPath = Join-Path $PSScriptRoot 'Add-OpenApiResponseSchemas.Tests.ps1' + +Write-Information 'Running PSScriptAnalyzer...' -InformationAction Continue +$analysisFindings = @( + foreach ($path in @($scriptPath, $testPath)) { + Invoke-ScriptAnalyzer -Path $path -Severity Warning, Error + } +) + +if ($analysisFindings.Count -gt 0) { + $analysisFindings | Format-Table -AutoSize | Out-String | Write-Information -InformationAction Continue +} + +Write-Information "PSScriptAnalyzer Warning/Error findings: $($analysisFindings.Count)" -InformationAction Continue + +Write-Information 'Running Pester...' -InformationAction Continue +$pesterConfig = New-PesterConfiguration +$pesterConfig.Run.Path = $testPath +$pesterConfig.Run.PassThru = $true +$pesterConfig.Run.Exit = $false +$pesterConfig.Output.Verbosity = 'Detailed' + +$pesterResult = Invoke-Pester -Configuration $pesterConfig + +Write-Information "Pester: Passed=$($pesterResult.PassedCount) Failed=$($pesterResult.FailedCount) Skipped=$($pesterResult.SkippedCount)" -InformationAction Continue + +if ($analysisFindings.Count -gt 0 -or $pesterResult.FailedCount -gt 0) { + exit 1 +} diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000000000..481010ed7caed --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,5 @@ +apis: + enriched: + root: ./openapi.enriched.json +extends: + - recommended From 40ca72e83117f648190ff124e75a6bc894aa2570 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 26 Jun 2026 18:05:40 -0400 Subject: [PATCH 096/150] chore: update version to 10.5.5 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 3d53421632b03..67e853b0f3cf9 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.4", + "defaultVersion": "10.5.5", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} \ No newline at end of file +} diff --git a/version_latest.txt b/version_latest.txt index 927fa80836fbe..23b7528bc2089 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.4 +10.5.5 From 2a7c7a784442405069645a1c979cc72df71c2285 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:12:08 +0800 Subject: [PATCH 097/150] Ignore mail.onmicrosoft domains for DMARC enablement --- .../Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 index 7ed2feadb7563..8c3ef3c619c99 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardAddDMARCToMOERA { try { $DomainsResponse = New-GraphGetRequest -TenantID $Tenant -Uri 'https://graph.microsoft.com/beta/domains' Write-Warning ($DomainsResponse | ConvertTo-Json -Depth 5) - $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' } | ForEach-Object { $_.id }) + $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' -and $_.id -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { $_.id }) Write-Information "Detected $($Domains.Count) MOERA domains: $($Domains -join ', ')" $CurrentInfo = foreach ($Domain in $Domains) { From a2f29dd77c41d95050c9e156681a6f8b69d85b15 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:07:49 +0200 Subject: [PATCH 098/150] feat: direction-scoped Intune group assignment Add ExcludeGroupIds and AssignmentDirection so exclude groups are targeted by ID and a Replace edits only one direction, preserving the other plus All Users/All Devices. Exclude-only requests no longer error or wipe assignments, and exclusion targets drop the settings block that Graph rejects. --- .../Get-CIPPIntuneApplicationReport.ps1 | 2 +- .../Public/Set-CIPPAssignedApplication.ps1 | 130 +++++++++++------- .../Public/Set-CIPPAssignedPolicy.ps1 | 112 ++++++++------- .../Applications/Invoke-ExecAssignApp.ps1 | 43 +++++- .../Endpoint/Applications/Invoke-ListApps.ps1 | 2 +- .../Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 | 36 ++++- 6 files changed, 221 insertions(+), 104 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 index a53717c8bb595..d6f53a245e65e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 @@ -65,7 +65,7 @@ function Get-CIPPIntuneApplicationReport { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index fe55f61476386..007f52b8e11c8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -3,12 +3,14 @@ function Set-CIPPAssignedApplication { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, $Intent, $AppType, $ApplicationId, $TenantFilter, $GroupIds, $AssignmentMode = 'replace', + $AssignmentDirection, $APIName = 'Assign Application', $Headers, $AssignmentFilterName, @@ -106,7 +108,7 @@ function Set-CIPPAssignedApplication { $resolvedGroupIds = @() if ($PSBoundParameters.ContainsKey('GroupIds') -and $GroupIds) { $resolvedGroupIds = $GroupIds - } else { + } elseif ($GroupName) { $GroupNames = $GroupName.Split(',') $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ @@ -119,8 +121,10 @@ function Set-CIPPAssignedApplication { Write-Information "found $($resolvedGroupIds) groups" } - # We ain't found nothing so we panic - if (-not $resolvedGroupIds) { + # Only panic when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if (-not $resolvedGroupIds -and $IncludeRequested) { throw 'No matching groups resolved for assignment request.' } @@ -138,20 +142,34 @@ function Set-CIPPAssignedApplication { } } + # Normalize to an array so appending exclusions appends an element rather than + # merging hashtable keys (a single include group makes the switch return a scalar). + # Filter nulls so an exclude-only assignment doesn't carry an empty placeholder. + $MobileAppAssignment = @($MobileAppAssignment | Where-Object { $_ }) + # Add exclusion group assignments - if ($ExcludeGroup) { - Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { - $Group = $_ - foreach ($SingleName in $ExcludeGroupNames) { - if ($Group.displayName -like $SingleName) { - $Group.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding group(s) by id from application assignment: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { + $Group = $_ + foreach ($SingleName in $ExcludeGroupNames) { + if ($Group.displayName -like $SingleName) { + $Group.id + } } } } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { + # Graph rejects 'settings' on exclusion targets: + # "Exclusion assignment does not support MobileAppAssignment Settings." $MobileAppAssignment += @{ '@odata.type' = '#microsoft.graph.mobileAppAssignment' target = @{ @@ -159,7 +177,6 @@ function Set-CIPPAssignedApplication { groupId = $egid } intent = $Intent - settings = $assignmentSettings } } } @@ -176,59 +193,70 @@ function Set-CIPPAssignedApplication { } } - # If we're appending, we need to get existing assignments - if ($AssignmentMode -eq 'append') { + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + + $ExistingAssignments = @() + if ($PreserveExisting) { try { $ExistingAssignments = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assignments" -tenantid $TenantFilter } catch { - Write-Warning "Unable to retrieve existing assignments for $ApplicationId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $ApplicationId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $KeptAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $MobileAppAssignment.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in @($ExistingAssignments)) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + default { $ExistingType -notin $MobileAppAssignment.target.'@odata.type' } } } + if ($Keep) { + $KeptAssignments.Add($ExistingAssignment) + } } } $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - $ExistingAssignments | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) + if ($PreserveExisting) { + # Rebuild each assignment, omitting 'settings' on exclusion targets (Graph rejects it). + $AddAssignment = { + param($a) + $entry = @{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' + target = $a.target + intent = $a.intent + } + if ($a.target.'@odata.type' -ne '#microsoft.graph.exclusionGroupAssignmentTarget' -and $null -ne $a.settings) { + $entry.settings = $a.settings + } + $FinalAssignments.Add($entry) } - $MobileAppAssignment | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) - } + $KeptAssignments | ForEach-Object { & $AddAssignment $_ } + $MobileAppAssignment | ForEach-Object { & $AddAssignment $_ } } else { $FinalAssignments = $MobileAppAssignment } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index c616658100f63..715e1a4844ba3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -3,6 +3,7 @@ function Set-CIPPAssignedPolicy { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, $PolicyId, $Type, $TenantFilter, @@ -13,7 +14,8 @@ function Set-CIPPAssignedPolicy { $AssignmentFilterType = 'include', $GroupIds, $GroupNames, - $AssignmentMode = 'replace' + $AssignmentMode = 'replace', + $AssignmentDirection ) Write-Host "Assigning policy $PolicyId ($PlatformType/$Type) to $GroupName" @@ -93,7 +95,10 @@ function Set-CIPPAssignedPolicy { } } - if (-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) { + # Only error when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if ((-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) -and $IncludeRequested) { $ErrorMessage = "No groups found matching the specified name(s): $GroupName. Policy not assigned." Write-LogMessage -headers $Headers -API $APIName -message $ErrorMessage -sev 'Warning' -tenant $TenantFilter throw $ErrorMessage @@ -111,19 +116,26 @@ function Set-CIPPAssignedPolicy { } } } - if ($ExcludeGroup) { - Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | - ForEach-Object { - foreach ($SingleName in $ExcludeGroupNames) { - if ($_.displayName -like $SingleName) { - $_.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding custom group(s) by id: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | + ForEach-Object { + foreach ($SingleName in $ExcludeGroupNames) { + if ($_.displayName -like $SingleName) { + $_.id + } } } - } + } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { $assignmentsList.Add( @{ target = @{ @@ -147,50 +159,51 @@ function Set-CIPPAssignedPolicy { } } - # If we're appending, we need to get existing assignments + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + $ExistingAssignments = @() - if ($AssignmentMode -eq 'append') { + if ($PreserveExisting) { try { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assignments" $ExistingAssignments = New-GraphGetRequest -uri $uri -tenantid $TenantFilter Write-Host "Found $($ExistingAssignments.Count) existing assignments for policy $PolicyId" } catch { - Write-Warning "Unable to retrieve existing assignments for $PolicyId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $PolicyId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $FinalAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments -and $ExistingAssignments.Count -gt 0) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $assignmentsList.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in $ExistingAssignments) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + default { $ExistingType -notin $assignmentsList.target.'@odata.type' } } } - } - } - - # Build final assignments list - $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - foreach ($existing in $ExistingAssignments) { - $FinalAssignments.Add(@{ - target = $existing.target - }) + if ($Keep) { + $FinalAssignments.Add(@{ target = $ExistingAssignment.target }) + } } } @@ -222,9 +235,14 @@ function Set-CIPPAssignedPolicy { 'specified groups' } - if ($ExcludeGroup) { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" + $ExcludedGroupsDisplay = if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + ($ExcludeGroupIds -join ', ') + } else { + $ExcludeGroup + } + if ($ExcludedGroupsDisplay) { + Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter + return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" } else { Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter return "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 84b12b647b16a..58d543e45bfa8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -24,6 +24,8 @@ function Invoke-ExecAssignApp { $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $AssignmentDirection = $Request.Body.assignmentDirection $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } @@ -36,6 +38,17 @@ function Invoke-ExecAssignApp { } } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + function Get-StandardizedAssignmentList { param($InputObject) @@ -53,9 +66,24 @@ function Invoke-ExecAssignApp { $GroupNames = Get-StandardizedAssignmentList -InputObject $GroupNamesRaw $GroupIds = Get-StandardizedAssignmentList -InputObject $GroupIdsRaw + $ExcludeGroupIds = Get-StandardizedAssignmentList -InputObject $ExcludeGroupIdsRaw + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') - if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) { - throw 'No assignment target provided. Supply AssignTo, GroupNames, or GroupIds.' + if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0 -and $ExcludeGroupIds.Count -eq 0 -and [string]::IsNullOrWhiteSpace($ExcludeGroup) -and -not $IsClearExclusions) { + throw 'No assignment target provided. Supply AssignTo, GroupNames, GroupIds, or an exclude group.' + } + + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment, so + # force preserve. The Custom Group action sends assignmentDirection and uses direction-scoped + # Replace instead, so it is exempt. + $IsExcludeOnly = (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) -and + ($ExcludeGroupIds.Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + Write-Information 'Exclude-only assignment requested; forcing append mode to preserve existing assignments.' + $AssignmentMode = 'append' } # Try to get the application type if not provided. Mostly just useful for ppl using the API that dont know the application type. @@ -78,7 +106,8 @@ function Invoke-ExecAssignApp { } elseif ($GroupIds.Count -gt 0) { "GroupIds: $($GroupIds -join ',')" } else { - 'CustomGroupAssignment' + # Exclude-only assignment: no include target, so the helper must not try to resolve one + $null } $setParams = @{ @@ -110,6 +139,14 @@ function Invoke-ExecAssignApp { $setParams.ExcludeGroup = $ExcludeGroup } + if ($ExcludeGroupIds.Count -gt 0) { + $setParams.ExcludeGroupIds = $ExcludeGroupIds + } + + if ($AssignmentDirection) { + $setParams.AssignmentDirection = $AssignmentDirection + } + try { $Result = Set-CIPPAssignedApplication @setParams $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 index f2d0dc06d28cb..9a401a15a400f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 @@ -67,7 +67,7 @@ function Invoke-ListApps { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index ce22cb30d03f5..1b6973bf81827 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -18,9 +18,11 @@ function Invoke-ExecAssignPolicy { $AssignTo = $Request.Body.AssignTo $PlatformType = $Request.Body.platformType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds $GroupIdsRaw = $Request.Body.GroupIds $GroupNamesRaw = $Request.Body.GroupNames $AssignmentMode = $Request.Body.assignmentMode + $AssignmentDirection = $Request.Body.assignmentDirection $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType @@ -41,6 +43,7 @@ function Invoke-ExecAssignPolicy { $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw $GroupNames = Get-StandardizedList -InputObject $GroupNamesRaw + $ExcludeGroupIds = Get-StandardizedList -InputObject $ExcludeGroupIdsRaw # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { @@ -49,8 +52,31 @@ function Invoke-ExecAssignPolicy { $AssignTo = if ($AssignTo -ne 'on') { $AssignTo } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') + + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment. The + # Custom Group action sends assignmentDirection and uses direction-scoped Replace instead. + $IsExcludeOnly = (-not $AssignTo -and @($GroupIds).Count -eq 0 -and @($GroupNames).Count -eq 0) -and + (@($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + $AssignmentMode = 'append' + } + $Results = try { - if ($AssignTo -or @($GroupIds).Count -gt 0) { + if ($AssignTo -or @($GroupIds).Count -gt 0 -or @($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup) -or $IsClearExclusions) { $params = @{ PolicyId = $ID TenantFilter = $TenantFilter @@ -76,6 +102,14 @@ function Invoke-ExecAssignPolicy { $params.ExcludeGroup = $ExcludeGroup } + if (@($ExcludeGroupIds).Count -gt 0) { + $params.ExcludeGroupIds = @($ExcludeGroupIds) + } + + if ($AssignmentDirection) { + $params.AssignmentDirection = $AssignmentDirection + } + if (-not [string]::IsNullOrWhiteSpace($AssignmentFilterName)) { $params.AssignmentFilterName = $AssignmentFilterName } From dae979bb5613acf6196553e8aee27b58b13635d7 Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:57:01 +0100 Subject: [PATCH 099/150] Fix Check extension alerts repeating on every run (watermark never advanced) Get-CIPPAlertCheckExtension reads a per-tenant watermark from the AlertLastRun table to only fetch alerts newer than the previous run, but never wrote it back. So $Since always fell back to the default 24h window and the same alerts were re-sent on every scheduled run. Capture the run start, read/store an explicit LastRunTime watermark (falling back to the existing Timestamp for compatibility), and upsert it after processing so each run only sends alerts created since the last run. Fixes #6216 --- .../Alerts/Get-CIPPAlertCheckExtension.ps1 | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 index 79ae2f3a8b41c..0d0fb4281d13d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 @@ -16,9 +16,16 @@ function Get-CIPPAlertCheckExtension { $LastRunTable = Get-CippTable -tablename 'AlertLastRun' $LastRunKey = "$TenantFilter-Get-CIPPAlertCheckExtension" - # Get the last run timestamp for this tenant to only fetch new alerts + # Capture the start of this run. The watermark is advanced to this value + # after processing so the next run only fetches alerts from this point + # onward, including any that arrive while this run is still processing. + $RunStart = (Get-Date).ToUniversalTime() + + # Get the last run timestamp for this tenant to only fetch new alerts. $LastRun = Get-CIPPAzDataTableEntity @LastRunTable -Filter "PartitionKey eq 'AlertLastRun' and RowKey eq '$LastRunKey'" | Select-Object -First 1 - $Since = if ($LastRun.Timestamp) { + $Since = if ($LastRun.LastRunTime) { + [datetime]::Parse($LastRun.LastRunTime, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) + } elseif ($LastRun.Timestamp) { $LastRun.Timestamp.UtcDateTime } else { (Get-Date).AddDays(-1).ToUniversalTime() @@ -45,6 +52,16 @@ function Get-CIPPAlertCheckExtension { if ($AlertData) { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + # Advance the watermark so the next run only picks up alerts newer than + # this run. Without this, $Since always fell back to the default window + # and previously sent alerts were re-sent on every run. + $LastRunEntity = @{ + PartitionKey = 'AlertLastRun' + RowKey = $LastRunKey + LastRunTime = $RunStart.ToString('o') + } + $null = Add-CIPPAzDataTableEntity @LastRunTable -Entity $LastRunEntity -Force } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "Check Extension alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage From 9a88ec07e955561ff656f12b960017e316e67e79 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:07:19 +0200 Subject: [PATCH 100/150] feat: add support for ExcludeGroupNames in assignment functions --- .../Public/Set-CIPPAssignedApplication.ps1 | 35 +++++++++++++++++-- .../Public/Set-CIPPAssignedPolicy.ps1 | 30 +++++++++++----- .../Applications/Invoke-ExecAssignApp.ps1 | 6 ++++ .../Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 | 6 ++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 007f52b8e11c8..01cb05ec8ec77 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -4,6 +4,7 @@ function Set-CIPPAssignedApplication { $GroupName, $ExcludeGroup, $ExcludeGroupIds, + $ExcludeGroupNames, $Intent, $AppType, $ApplicationId, @@ -266,12 +267,40 @@ function Set-CIPPAssignedApplication { $FinalAssignments ) } - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId") + if ($ShouldProcess) { Start-Sleep -Seconds 1 $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assign" -tenantid $TenantFilter -type POST -body ($DefaultAssignmentObject | ConvertTo-Json -Compress -Depth 10) - Write-LogMessage -headers $Headers -API $APIName -message "Assigned Application $ApplicationId to $($GroupName)" -Sev 'Info' -tenant $TenantFilter } - return "Assigned Application $ApplicationId to $($GroupName)" + + $AssignedGroupsDisplay = if ($GroupName) { + $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' + } + + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + @($ExcludeGroupNames) -join ', ' + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + @($ExcludeGroupIds) -join ', ' + } else { + $ExcludeGroup + } + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay excluding group '$ExcludedGroupsDisplay'" + } elseif ($ExcludedGroupsDisplay) { + "Updated exclusions for Application $ApplicationId to group '$ExcludedGroupsDisplay'" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Cleared exclusions for Application $ApplicationId" + } else { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter + } + return $ResultMessage } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Could not assign application $ApplicationId to $GroupName. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index 715e1a4844ba3..3aa9d3530e2c3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -4,6 +4,7 @@ function Set-CIPPAssignedPolicy { $GroupName, $ExcludeGroup, $ExcludeGroupIds, + $ExcludeGroupNames, $PolicyId, $Type, $TenantFilter, @@ -222,7 +223,8 @@ function Set-CIPPAssignedPolicy { $assignmentsObject = @{ $AssignmentPropertyName = @($FinalAssignments) } $AssignJSON = ConvertTo-Json -InputObject $assignmentsObject -Depth 10 -Compress - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId") + if ($ShouldProcess) { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assign" $null = New-GraphPOSTRequest -uri $uri -tenantid $TenantFilter -type POST -body $AssignJSON @@ -231,22 +233,34 @@ function Set-CIPPAssignedPolicy { ($GroupNames -join ', ') } elseif ($GroupName) { $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' } else { - 'specified groups' + $null } - $ExcludedGroupsDisplay = if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + ($ExcludeGroupNames -join ', ') + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { ($ExcludeGroupIds -join ', ') } else { $ExcludeGroup } - if ($ExcludedGroupsDisplay) { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($ExcludedGroupsDisplay) { + "Successfully updated exclusions to group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Successfully cleared exclusions on Policy $PolicyId" } else { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter } + return $ResultMessage } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 58d543e45bfa8..1f6e1fcb6edef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -25,6 +25,7 @@ function Invoke-ExecAssignApp { $AssignmentFilterType = $Request.Body.AssignmentFilterType $ExcludeGroup = $Request.Body.excludeGroup $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames $AssignmentDirection = $Request.Body.assignmentDirection $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } @@ -67,6 +68,7 @@ function Invoke-ExecAssignApp { $GroupNames = Get-StandardizedAssignmentList -InputObject $GroupNamesRaw $GroupIds = Get-StandardizedAssignmentList -InputObject $GroupIdsRaw $ExcludeGroupIds = Get-StandardizedAssignmentList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedAssignmentList -InputObject $ExcludeGroupNamesRaw # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') @@ -143,6 +145,10 @@ function Invoke-ExecAssignApp { $setParams.ExcludeGroupIds = $ExcludeGroupIds } + if ($ExcludeGroupNames.Count -gt 0) { + $setParams.ExcludeGroupNames = $ExcludeGroupNames + } + if ($AssignmentDirection) { $setParams.AssignmentDirection = $AssignmentDirection } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index 1b6973bf81827..722a5f37cdc04 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -19,6 +19,7 @@ function Invoke-ExecAssignPolicy { $PlatformType = $Request.Body.platformType $ExcludeGroup = $Request.Body.excludeGroup $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames $GroupIdsRaw = $Request.Body.GroupIds $GroupNamesRaw = $Request.Body.GroupNames $AssignmentMode = $Request.Body.assignmentMode @@ -44,6 +45,7 @@ function Invoke-ExecAssignPolicy { $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw $GroupNames = Get-StandardizedList -InputObject $GroupNamesRaw $ExcludeGroupIds = Get-StandardizedList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedList -InputObject $ExcludeGroupNamesRaw # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { @@ -106,6 +108,10 @@ function Invoke-ExecAssignPolicy { $params.ExcludeGroupIds = @($ExcludeGroupIds) } + if (@($ExcludeGroupNames).Count -gt 0) { + $params.ExcludeGroupNames = @($ExcludeGroupNames) + } + if ($AssignmentDirection) { $params.AssignmentDirection = $AssignmentDirection } From 2f162230ee431bdac6da082311f7fce2c95a53e1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:18:22 +0800 Subject: [PATCH 101/150] Sensitivity Rule Templating and standard overhaul --- .../Public/Compare-CIPPSensitiveInfoType.ps1 | 154 ++++++++++++++++++ .../Public/Get-CIPPSitSinglePackXml.ps1 | 92 +++++++++++ .../Public/New-CIPPSitRulePackXml.ps1 | 22 ++- .../Public/Set-CIPPSensitiveInfoType.ps1 | 56 ++++--- .../Invoke-AddSensitiveInfoTypeTemplate.ps1 | 93 +++++++---- ...nvoke-ListSensitiveInfoTypeRulePackage.ps1 | 47 ++++++ .../Invoke-RemoveSensitiveInfoType.ps1 | 19 ++- ...-CIPPStandardSensitiveInfoTypeTemplate.ps1 | 52 ++++-- 8 files changed, 458 insertions(+), 77 deletions(-) create mode 100644 Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSitSinglePackXml.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeRulePackage.ps1 diff --git a/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 new file mode 100644 index 0000000000000..426bec77f51ef --- /dev/null +++ b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 @@ -0,0 +1,154 @@ +function ConvertTo-CIPPSitComparable { + <# + .SYNOPSIS + Reduce a SIT rule pack XML to a semantic, comparable structure (ignoring volatile ids/versions). + .DESCRIPTION + A rule pack XML carries GUIDs (RulePack/Publisher/Entity/Regex ids) and a version that change + between deploys and are assigned by Microsoft, so a raw XML compare always looks like drift. This + extracts only the meaningful content per entity - name, description, recommended confidence, + patterns proximity, and each pattern's confidence level with its resolved regex/keyword content - + keyed by entity name. Parsing is namespace-agnostic (local-name()) so the 2011 and 2018 schemas + both work. + .OUTPUTS + Hashtable of entityName -> ordered hashtable { confidence, proximity, description, patterns }. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory)][AllowNull()][string]$Xml) + + $result = @{} + if ([string]::IsNullOrWhiteSpace($Xml)) { return $result } + try { [xml]$doc = $Xml } catch { return $result } + + # Resolve referenced detection elements: regex id -> pattern text, keyword id -> sorted terms. + $regexMap = @{} + foreach ($r in $doc.SelectNodes("//*[local-name()='Regex']")) { + if ($r.id) { $regexMap[[string]$r.id] = ([string]$r.InnerText).Trim() } + } + $keywordMap = @{} + foreach ($k in $doc.SelectNodes("//*[local-name()='Keyword']")) { + $terms = @($k.SelectNodes(".//*[local-name()='Term']") | ForEach-Object { ([string]$_.InnerText).Trim() }) | Sort-Object + if ($k.id) { $keywordMap[[string]$k.id] = ($terms -join '|') } + } + + # entity id -> localized name/description + $resMap = @{} + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + if (-not $res.idRef) { continue } + $nameNode = $res.SelectSingleNode("*[local-name()='Name']") + $descNode = $res.SelectSingleNode("*[local-name()='Description']") + $resMap[[string]$res.idRef] = @{ + Name = if ($nameNode) { ([string]$nameNode.InnerText).Trim() } else { '' } + Description = if ($descNode) { ([string]$descNode.InnerText).Trim() } else { '' } + } + } + + foreach ($ent in $doc.SelectNodes("//*[local-name()='Entity']")) { + $eid = [string]$ent.id + $name = if ($resMap.ContainsKey($eid) -and $resMap[$eid].Name) { $resMap[$eid].Name } else { $eid } + + $patterns = @(foreach ($p in $ent.SelectNodes("*[local-name()='Pattern']")) { + $matches = @($p.SelectNodes(".//*[@idRef]") | ForEach-Object { + $ref = [string]$_.idRef + if ($regexMap.ContainsKey($ref)) { "regex:$($regexMap[$ref])" } + elseif ($keywordMap.ContainsKey($ref)) { "keyword:$($keywordMap[$ref])" } + else { "ref:$ref" } + }) | Sort-Object + [ordered]@{ level = [string]$p.confidenceLevel; matches = @($matches) } + }) + + $result[$name] = [ordered]@{ + confidence = [string]$ent.recommendedConfidence + proximity = [string]$ent.patternsProximity + description = if ($resMap.ContainsKey($eid)) { $resMap[$eid].Description } else { '' } + patterns = @($patterns) + } + } + return $result +} + +function Compare-CIPPSensitiveInfoType { + <# + .SYNOPSIS + Compare a stored SIT template against the live custom SIT in a tenant and report drift. + .DESCRIPTION + Resolves the template's intended rule pack XML (advanced FileDataBase64, or synthesized from a + simple Pattern), fetches the live SIT's rule pack XML, reduces both to a semantic structure via + ConvertTo-CIPPSitComparable, and diffs each templated entity (matched by name). Returns the state + and the specific differing fields with expected (template) vs current (tenant) values. + .OUTPUTS + PSCustomObject: Name, State ('Missing' | 'BuiltIn' | 'Invalid' | 'InSync' | 'Drift'), Differences. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantFilter, + [Parameter(Mandatory)] $Template + ) + + $Name = $Template.Name ?? $Template.name + + # Resolve the template's intended rule pack XML. + $WantXml = $null + if ($Template.FileDataBase64) { + try { $Bytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { $Bytes = $null } + if ($Bytes) { + $WantXml = [System.Text.Encoding]::Unicode.GetString($Bytes) + if ($WantXml -notmatch ' wrapper. + Works for regex (Entity -> Pattern -> Regex/Keyword) and fingerprint (Affinity -> Evidence -> + Fingerprint -> AdvancedFingerprint) alike. Namespace-agnostic. + .PARAMETER PackXml + The full rule pack XML (e.g. from Get-DlpSensitiveInformationTypeRulePackage). + .PARAMETER EntityId + The SIT entity id (the SIT's Id). If blank, resolved from EntityName via the Resource. + .PARAMETER EntityName + The SIT name, used to resolve the entity id when EntityId is blank. + .OUTPUTS + A single-SIT rule pack XML string (utf-16 declared). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PackXml, + [string]$EntityId, + [string]$EntityName + ) + + [xml]$doc = $PackXml + + # Resolve the entity id from the SIT name if needed (Resource.Name -> Resource.idRef). + if ([string]::IsNullOrWhiteSpace($EntityId) -and $EntityName) { + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + $nm = $res.SelectSingleNode("*[local-name()='Name']") + if ($nm -and ([string]$nm.InnerText).Trim() -eq $EntityName) { $EntityId = [string]$res.idRef; break } + } + } + if ([string]::IsNullOrWhiteSpace($EntityId)) { throw 'Could not resolve the SIT entity id.' } + + # Map every element that carries an id (anywhere - entities can be nested in a Version wrapper). + $byId = @{} + foreach ($el in $doc.SelectNodes("//*[@id]")) { $byId[[string]$el.id] = $el } + if (-not $byId.ContainsKey($EntityId)) { throw "Entity '$EntityId' not found in the rule package." } + + # Transitive closure of ids the target entity needs. + $keep = New-Object 'System.Collections.Generic.HashSet[string]' + [void]$keep.Add($EntityId) + $stack = New-Object System.Collections.Stack + $stack.Push($byId[$EntityId]) + while ($stack.Count -gt 0) { + $el = $stack.Pop() + $walk = New-Object System.Collections.Stack + $walk.Push($el) + while ($walk.Count -gt 0) { + $n = $walk.Pop() + foreach ($a in @($n.Attributes)) { + if ($a.Name -in @('idRef', 'linkedProcessorIdRef')) { + $rid = [string]$a.Value + if ($byId.ContainsKey($rid) -and -not $keep.Contains($rid)) { + [void]$keep.Add($rid) + $stack.Push($byId[$rid]) + } + } + } + foreach ($ch in $n.ChildNodes) { if ($ch.NodeType -eq 'Element') { $walk.Push($ch) } } + } + } + + # Remove other entities, the detection elements they (and not the target) own, and other Resources. + $toRemove = New-Object System.Collections.ArrayList + foreach ($e in $doc.SelectNodes("//*[local-name()='Entity' or local-name()='Affinity']")) { + if ([string]$e.id -ne $EntityId) { [void]$toRemove.Add($e) } + } + foreach ($e in $doc.SelectNodes("//*[local-name()='Regex' or local-name()='Keyword' or local-name()='Fingerprint' or local-name()='AdvancedFingerprint']")) { + if (-not $keep.Contains([string]$e.id)) { [void]$toRemove.Add($e) } + } + foreach ($r in $doc.SelectNodes("//*[local-name()='Resource']")) { + if ([string]$r.idRef -ne $EntityId) { [void]$toRemove.Add($r) } + } + foreach ($node in $toRemove) { [void]$node.ParentNode.RemoveChild($node) } + + # Fresh RulePack id so this deploys as a new custom pack (not the fixed managed pack id). + $rulePack = $doc.SelectSingleNode("//*[local-name()='RulePack']") + if ($rulePack -and $rulePack.Attributes['id']) { $rulePack.id = (New-Guid).Guid } + + return '' + $doc.DocumentElement.OuterXml +} diff --git a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 index a202c9b89511d..59ecf84ec9410 100644 --- a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 @@ -3,9 +3,17 @@ function New-CIPPSitRulePackXml { .SYNOPSIS Synthesize a Microsoft Purview Sensitive Information Type rule pack XML from simple inputs. .DESCRIPTION - New-DlpSensitiveInformationType only accepts a rule pack XML via -FileData (byte array). - For simple regex-based SITs, this helper builds a minimal valid rule pack with fresh GUIDs - so callers can pass it to the cmdlet without hand-authoring XML. + New-DlpSensitiveInformationTypeRulePackage imports a custom SIT *rule package* (regex/keyword + based, Type=Entity). It requires the 2011 'mce' schema namespace and UTF-16 encoded bytes - the + 2018 'search.external' namespace is rejected with a schema-validation error. + + For simple regex-based SITs this helper builds a minimal valid rule pack with fresh GUIDs so + callers can hand it to the cmdlet without authoring XML. (NOTE: the singular + New-DlpSensitiveInformationType cmdlet is a *document-fingerprint* primitive and must NOT be used + for regex SITs - it stores the FileData as a fingerprint and discards the regex.) + .NOTES + The returned string declares encoding="utf-16"; callers must encode it with + [System.Text.Encoding]::Unicode (UTF-16LE, no BOM) so the bytes match the declaration. .FUNCTIONALITY Internal #> @@ -16,7 +24,7 @@ function New-CIPPSitRulePackXml { [Parameter(Mandatory)][string]$Pattern, [int]$Confidence = 85, [int]$PatternsProximity = 300, - [string]$Locale = 'en-us', + [string]$Locale = 'en-US', [string]$PublisherName = 'CIPP' ) @@ -27,10 +35,10 @@ function New-CIPPSitRulePackXml { $esc = { param($s) [System.Security.SecurityElement]::Escape([string]$s) } return @" - - + + - +
diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 index 5a86bc1674567..6860bb48a8c15 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 @@ -4,8 +4,14 @@ function Set-CIPPSensitiveInfoType { Deploy or update a single custom Sensitive Information Type in a tenant from a template object. .DESCRIPTION Single source of truth for SIT deployment, shared by the HTTP deploy endpoint and the standard. - Supports simple mode (Pattern → backend synthesizes rule pack XML) and advanced mode (caller- - supplied FileDataBase64 rule pack). Microsoft built-in SITs are skipped. + Imports a custom SIT *rule package* (regex/keyword based, Type=Entity) via + New-/Set-DlpSensitiveInformationTypeRulePackage. Supports simple mode (Pattern -> backend + synthesizes the rule pack XML) and advanced mode (caller-supplied FileDataBase64 rule pack, + e.g. captured from an existing SIT). Microsoft built-in SITs are skipped. + + IMPORTANT: this uses the rule-*package* cmdlets, NOT New-/Set-DlpSensitiveInformationType - the + latter is a document-fingerprint primitive that stores -FileData as a fingerprint and discards + the regex. The rule pack XML must use the 2011 'mce' schema and be UTF-16 encoded. .FUNCTIONALITY Internal #> @@ -19,53 +25,63 @@ function Set-CIPPSensitiveInfoType { $Name = $Template.Name - # Build FileData byte array from either advanced (FileDataBase64) or simple (Pattern) mode - $FileDataBytes = $null + # Resolve the rule pack XML from advanced (FileDataBase64) or simple (Pattern) mode. + $XmlString = $null if ($Template.FileDataBase64) { try { - $FileDataBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) + $RawBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { - $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) — skipping in $TenantFilter." + $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) - skipping in $TenantFilter." Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $msg -sev Error return $msg } + # Captured/synthesized packs are UTF-16; fall back to UTF-8 if that doesn't look like the XML. + $XmlString = [System.Text.Encoding]::Unicode.GetString($RawBytes) + if ($XmlString -notmatch ' + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $RulePackId = $Request.Query.RulePackId ?? $Request.Body.RulePackId + + try { + if ([string]::IsNullOrWhiteSpace($RulePackId)) { throw 'RulePackId is required.' } + + $Pack = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $RulePackId } -Compliance | + Select-Object * -ExcludeProperty *odata*, *data.type* | Select-Object -First 1 + $Xml = [string]$Pack.ClassificationRuleCollectionXml + + # Reuse the drift comparer's semantic parser to expose a friendly rule configuration. + $Configuration = if (-not [string]::IsNullOrWhiteSpace($Xml)) { ConvertTo-CIPPSitComparable -Xml $Xml } else { @{} } + + $Result = [ordered]@{ + RulePackId = $RulePackId + RuleCollectionName = $Pack.RuleCollectionName + Publisher = $Pack.Publisher + Version = $Pack.Version + Configuration = $Configuration + Xml = $Xml + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Result = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Result + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 index 3fa5502ee294d..88edcad8111a7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 @@ -13,12 +13,25 @@ Function Invoke-RemoveSensitiveInfoType { $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $FingerprintPackId = '00000000-0000-0000-0001-000000000001' try { - $Params = @{ - Identity = $Identity + $Sit = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | + Where-Object { $_.Name -eq $Identity -or $_.Id -eq $Identity -or $_.Identity -eq $Identity } | Select-Object -First 1 + if (-not $Sit) { + throw "Sensitive Information Type '$Identity' not found." + } + if ($Sit.Publisher -like 'Microsoft*') { + throw "SIT '$($Sit.Name)' is a Microsoft built-in and cannot be deleted." + } + + # Regex/keyword SITs are their own rule package and must be removed at the package level - the + # singular Remove-DlpSensitiveInformationType only removes a SIT from the shared fingerprint pack. + if ($Sit.RulePackId -and $Sit.RulePackId -ne $FingerprintPackId -and $Sit.Type -ne 'Fingerprint') { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $Sit.RulePackId } -Compliance -useSystemMailbox $true + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams @{ Identity = $Identity } -Compliance -useSystemMailbox $true } - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true $Result = "Deleted Sensitive Information Type $Identity" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 index d61620b079365..b7bd969e0e982 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 @@ -50,36 +50,52 @@ function Invoke-CIPPStandardSensitiveInfoTypeTemplate { return } - if ($Settings.remediate -eq $true) { - foreach ($Template in @($Templates)) { - $null = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + # Compare each template against the live SIT's rule pack and remediate only what drifts (or is + # missing). After a successful remediation, re-compare so the report/alert reflect the fixed state. + $Comparisons = foreach ($Template in @($Templates)) { + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + + if ($Settings.remediate -eq $true -and $Comparison.State -in @('Missing', 'Drift')) { + $DeployResult = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + if ($DeployResult -match '^(Created|Updated)') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Remediated SIT '$($Comparison.Name)' ($($Comparison.State)): $DeployResult" -sev Info + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $DeployResult -sev Error + $Comparison | Add-Member -NotePropertyName DeployError -NotePropertyValue "$DeployResult" -Force + } } + $Comparison } - $ExistingSitNames = try { - @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | Select-Object -ExpandProperty Name) - } catch { @() } - - $MissingSits = @(foreach ($Template in @($Templates)) { - $TemplateName = $Template.Name ?? $Template.name - if ($ExistingSitNames -notcontains $TemplateName) { $TemplateName } - }) + # Non-compliant when the SIT is missing, drifted, or the template is invalid. Built-in and in-sync + # SITs are compliant. + $NonCompliant = @($Comparisons | Where-Object { $_.State -in @('Missing', 'Drift', 'Invalid') }) if ($Settings.alert -eq $true) { - if ($MissingSits.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed.' -sev Info + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed and in sync.' -sev Info } else { - $AlertMessage = "Sensitive Information Types not deployed in tenant: $($MissingSits -join ', ')" - Write-StandardsAlert -message $AlertMessage -object @{ MissingSensitiveInfoTypes = $MissingSits } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId + $Summary = $NonCompliant | ForEach-Object { + if ($_.State -eq 'Drift') { + $Fields = @($_.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) -join ', ' + "$($_.Name): drift in $Fields" + } else { + "$($_.Name): $($_.State)" + } + } + $AlertMessage = "Sensitive Information Type templates not in sync: $($Summary -join '; ')" + Write-StandardsAlert -message $AlertMessage -object @{ NonCompliantSensitiveInfoTypes = $NonCompliant } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } } if ($Settings.report -eq $true) { - $CurrentValue = @{ MissingSensitiveInfoTypes = $MissingSits } - $ExpectedValue = @{ MissingSensitiveInfoTypes = @() } + # Expose the actual drift (per SIT: state + the differing fields with expected vs current values). + $CurrentValue = @{ NonCompliantSensitiveInfoTypes = $NonCompliant } + $ExpectedValue = @{ NonCompliantSensitiveInfoTypes = @() } Set-CIPPStandardsCompareField -FieldName 'standards.SensitiveInfoTypeTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant - Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($MissingSits.Count -eq 0) -StoreAs bool -Tenant $Tenant + Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($NonCompliant.Count -eq 0) -StoreAs bool -Tenant $Tenant } } From d52ec9b1af79b0230b6df622c47f48840d11b3de Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:12:27 +0100 Subject: [PATCH 102/150] Resolve tenant variables in Win32 Custom App detection script The detection script of a Win32 Custom Application was base64-encoded from the raw input, so %tenantid% and other Get-CIPPTextReplacement tokens were not resolved (they reached Intune as literal text), unlike the install and uninstall scripts. Run the detection script through Get-CIPPTextReplacement first, matching the install/uninstall handling. Fixes KelvinTegelaar/CIPP#6226 --- Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 77518b24d11d7..62b40dc56f3ba 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -74,7 +74,9 @@ function Add-CIPPW32ScriptApplication { # Build detection rules — detection script takes priority, then file detection, then marker file fallback if ($Properties.detectionScript) { # PowerShell script detection: script should write to STDOUT and exit 0 when detected - $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) + # Resolve %tenantid%/%tenantfilter%/etc. for consistency with the install/uninstall scripts below + $ReplacedDetectionScript = Get-CIPPTextReplacement -Text $Properties.detectionScript -TenantFilter $TenantFilter + $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ReplacedDetectionScript)) $DetectionRules = @( @{ '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' From 38b18bc091cabd594c9ee467a3bb3020e4fd01b2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:31:23 +0800 Subject: [PATCH 103/150] Audit log rework for better resilience --- Config/CIPPTimers.json | 24 +-- .../AuditLogs/Get-CippAuditLogNextAttempt.ps1 | 27 ++++ .../Get-CippAuditLogPlannedWindows.ps1 | 95 ++++++++++++ .../Get-CippAuditLogReconciliationWindows.ps1 | 68 +++++++++ .../AuditLogs/New-CippAuditLogSearchV2.ps1 | 86 +++++++++++ .../Start-AuditLogIngestionV2.ps1 | 82 ++++++++++ .../Start-AuditLogPlannerV2.ps1 | 38 +++++ .../Start-AuditLogSearchCreationV2.ps1 | 123 +++++++++++++++ .../GraphHelper/New-GraphPOSTRequest.ps1 | 11 +- .../CIPPCore/Public/Invoke-CIPPRestMethod.ps1 | 2 +- .../Webhooks/Push-AuditLogDownloadV2.ps1 | 125 ++++++++++++++++ .../Webhooks/Push-AuditLogProcessV2.ps1 | 53 +++++++ .../Push-AuditLogProcessingBatchV2.ps1 | 66 ++++++++ .../Push-AuditLogSearchCreationV2.ps1 | 141 ++++++++++++++++++ .../Webhooks/Push-AuditLogTenantProcessV2.ps1 | 104 +++++++++++++ .../Alerts/Invoke-ListAuditLogCoverage.ps1 | 135 +++++++++++++++++ Shared/CIPPSharp/CIPPRestClient.cs | 82 ++++++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 43008 -> 44544 bytes 18 files changed, 1236 insertions(+), 26 deletions(-) create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index 63203c303ab83..ac1ce7ba5b1b6 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -19,34 +19,14 @@ }, { "Id": "44a40668-ed71-403c-8c26-b32e320086ad", - "Command": "Start-AuditLogOrchestrator", - "Description": "Orchestrator to download audit logs", + "Command": "Start-AuditLogPlannerV2", + "Description": "Audit log V2 planner: create searches, download and process", "Cron": "0 */15 * * * *", "Priority": 2, "RunOnProcessor": true, "PreferredProcessor": "auditlog", "IsSystem": true }, - { - "Id": "01cd512a-15c4-44a9-b8cb-1e5d879cfd2d", - "Command": "Start-AuditLogProcessingOrchestrator", - "Description": "Orchestrator to process audit logs", - "Cron": "0 */15 * * * *", - "Priority": 3, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, - { - "Id": "03475c86-4314-4d7b-90f2-5a0639e3899b", - "Command": "Start-AuditLogSearchCreation", - "Description": "Timer to create audit log searches", - "Cron": "0 */30 * * * *", - "Priority": 4, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, { "Id": "5ff6c500-e420-4a3b-8532-ace2e4da4f7d", "Command": "Start-ApplicationOrchestrator", diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 new file mode 100644 index 0000000000000..d7c6c49840733 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 @@ -0,0 +1,27 @@ +function Get-CippAuditLogNextAttempt { + <# + .SYNOPSIS + Compute the next-attempt UTC time for a retried audit-log coverage row (exponential backoff). + .DESCRIPTION + Used by the V2 audit-log pipeline to schedule retries of failed search creations and + downloads in the AuditLogCoverage ledger. Exponential backoff with jitter, capped. Because + the timers run every 15 minutes, small delays effectively mean "retry next run"; larger ones + defer a persistently failing window before it is eventually dead-lettered by the caller. + .PARAMETER Attempts + The attempt count that has just been consumed (1 = first failure). + .OUTPUTS + [datetime] (UTC) when the row becomes eligible to retry. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [int]$Attempts, + [int]$BaseMinutes = 5, + [int]$CapMinutes = 240 + ) + $exp = [Math]::Max(0, $Attempts - 1) + $delay = [Math]::Min($BaseMinutes * [Math]::Pow(2, $exp), $CapMinutes) + $jitter = Get-Random -Minimum 0.8 -Maximum 1.2 + return (Get-Date).ToUniversalTime().AddMinutes($delay * $jitter) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 new file mode 100644 index 0000000000000..a33fffb19acdb --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 @@ -0,0 +1,95 @@ +function Get-CippAuditLogPlannedWindows { + <# + .SYNOPSIS + Compute the 35-minute audit-log search windows a tenant is missing (gaps + the newest settled). + .DESCRIPTION + Pure helper for the V2 audit-log pipeline. Windows are 35 minutes long on a 30-minute stride, + so consecutive windows overlap by 5 minutes (covers boundary stragglers; alerting dedups by + record id). Window ENDS sit on the 30-minute grid minus the settle (i.e. :25 / :55), which is + exactly `floor_to_30min(now) - settle`. With the planner timer firing at :00/:15/:30/:45 and a + 5-minute settle, a fresh window becomes creatable exactly at a :00/:30 tick - no tick delay - + and the :15/:45 ticks naturally have no new window (they do retries + download/process). + + Backfill of older gaps is bounded by -HorizonHours and capped at -MaxPerRun per call (oldest + first). A brand-new tenant is seeded with only the newest settled window. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Reconciliation rows (RowKey 'RECON-*') are ignored + here; only regular 14-digit window keys are considered. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleMinutes = 5, + [int]$WindowMinutes = 35, + [int]$StrideMinutes = 30, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest window end: floor to the 30-min grid, minus the settle (lands on :25 / :55). + $FloorMinute = $Now.Minute - ($Now.Minute % $StrideMinutes) + $Floor = [datetime]::new($Now.Year, $Now.Month, $Now.Day, $Now.Hour, $FloorMinute, 0, [System.DateTimeKind]::Utc) + $NewestEnd = $Floor.AddMinutes(-$SettleMinutes) + + $HorizonStart = $Now.AddHours(-$HorizonHours) + + # Existing regular window keys (ignore reconciliation rows). + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ExistingStarts = [System.Collections.Generic.List[datetime]]::new() + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -notmatch '^\d{14}$') { continue } + [void]$ExistingKeys.Add([string]$Row.RowKey) + if ($null -ne $Row.WindowStart) { + try { $ExistingStarts.Add(([datetimeoffset]$Row.WindowStart).UtcDateTime) } catch {} + } + } + + # Brand-new tenant: seed only the newest settled window. + if ($ExistingStarts.Count -eq 0) { + $Start = $NewestEnd.AddMinutes(-$WindowMinutes) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ + RowKey = $Start.ToString('yyyyMMddHHmmss') + WindowStart = $Start + WindowEnd = $NewestEnd + }) + } + + # Established tenant: backfill missing windows from the lower bound up to NewestEnd (oldest first). + $EarliestExisting = ($ExistingStarts | Measure-Object -Minimum).Minimum + $LowerEnd = if ($EarliestExisting -gt $HorizonStart) { $EarliestExisting.AddMinutes($WindowMinutes) } else { $HorizonStart.AddMinutes($WindowMinutes) } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $LowerEnd) { + $Start = $End.AddMinutes(-$WindowMinutes) + $Key = $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddMinutes(-$StrideMinutes) + } + + # $Owed is newest-first from the loop; reorder to oldest-first ([0]=oldest, [-1]=newest). + $Owed.Reverse() + if ($Owed.Count -le $MaxPerRun) { + return @($Owed) + } + + # Backlog exceeds the per-run cap: always include the NEWEST window so the live period can + # be created promptly (current-first, see Push-AuditLogSearchCreationV2), plus the oldest + # (MaxPerRun-1) so historical gaps still drain - oldest first - before they age out of the + # horizon. Without seeding the newest here it would never be Planned during a backlog. + $Newest = $Owed[$Owed.Count - 1] + $Backfill = @($Owed[0..($MaxPerRun - 2)]) + return @($Backfill + $Newest) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 new file mode 100644 index 0000000000000..6ffa7f1c4c026 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 @@ -0,0 +1,68 @@ +function Get-CippAuditLogReconciliationWindows { + <# + .SYNOPSIS + Compute the 12-hour reconciliation audit-log windows a tenant is missing. + .DESCRIPTION + The fast 35-minute path searches each period soon after it closes, so late-landing / backfilled + audit events (Microsoft can publish them hours later) can be missed. This helper produces wide + catch-all windows aligned to 00:00-12:00 and 12:00-00:00 UTC, each created 3 hours after the + block closes (a generous settle so backfilled data has landed). They flow through the normal + download/process path; alerting dedups by record id, so overlap with the fast path is harmless. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Only reconciliation rows (RowKey 'RECON-*') are + considered when finding gaps. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. RowKey is + 'RECON-'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleHours = 3, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest 12h block end (00:00 / 12:00 UTC) whose close is at least SettleHours in the past. + $T = $Now.AddHours(-$SettleHours) + $BoundaryHour = if ($T.Hour -lt 12) { 0 } else { 12 } + $NewestEnd = [datetime]::new($T.Year, $T.Month, $T.Day, $BoundaryHour, 0, 0, [System.DateTimeKind]::Utc) + $HorizonStart = $Now.AddHours(-$HorizonHours) + + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -like 'RECON-*') { [void]$ExistingKeys.Add([string]$Row.RowKey) } + } + + # No reconciliation history: seed only the newest settled block (avoid a first-run backfill spike; + # the fast 35-min path already covers recent history). Established tenants backfill gaps below. + if ($ExistingKeys.Count -eq 0) { + $Start = $NewestEnd.AddHours(-12) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ RowKey = 'RECON-' + $Start.ToString('yyyyMMddHHmmss'); WindowStart = $Start; WindowEnd = $NewestEnd }) + } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $HorizonStart) { + $Start = $End.AddHours(-12) + $Key = 'RECON-' + $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddHours(-12) + } + + $Owed.Reverse() + if ($Owed.Count -gt $MaxPerRun) { + return @($Owed[0..($MaxPerRun - 1)]) + } + return @($Owed) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 new file mode 100644 index 0000000000000..babdc345f9328 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 @@ -0,0 +1,86 @@ +function New-CippAuditLogSearchV2 { + <# + .SYNOPSIS + Create a Microsoft Graph audit-log search for the V2 pipeline and return a classified result. + .DESCRIPTION + Thin wrapper over New-GraphPOSTRequest (which now honours 429 backoff). Unlike the V1 + New-CippAuditLogSearch, this writes to NO table - the AuditLogCoverage ledger is updated by + the caller. Failures are classified so the caller can decide whether to retry (transient) or + stop (auditing disabled). + .PARAMETER TenantFilter + Tenant default domain or customerId. + .PARAMETER StartTime + Window start (inclusive). + .PARAMETER EndTime + Window end (exclusive). + .PARAMETER RecordTypeFilters + Record types to capture. Defaults to the four the V1 pipeline used. + .OUTPUTS + [pscustomobject]@{ Id; Status; Outcome; Message } Outcome in 'Created','AuditingDisabled','Transient'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][datetime]$StartTime, + [Parameter(Mandatory = $true)][datetime]$EndTime, + [string[]]$RecordTypeFilters = @('exchangeAdmin', 'azureActiveDirectory', 'azureActiveDirectoryAccountLogon', 'azureActiveDirectoryStsLogon'), + [int]$MaxAttempts = 3, + [string]$DisplayName = ('CIPP Audit Search V2 - ' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) + ) + + $Body = @{ + displayName = $DisplayName + filterStartDateTime = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + filterEndDateTime = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + recordTypeFilters = @($RecordTypeFilters) + } | ConvertTo-Json -Compress + + if (-not $PSCmdlet.ShouldProcess($TenantFilter, 'Create audit log search')) { + return [pscustomobject]@{ Id = $null; Status = 'WhatIf'; Outcome = 'Transient'; Message = 'WhatIf'; Throttled = $false } + } + + for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { + try { + # maxRetries 1 = no retry inside the Graph helper; this function owns retry/backoff. + $Query = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/security/auditLog/queries' -body $Body -tenantid $TenantFilter -AsApp $true -maxRetries 1 + return [pscustomobject]@{ Id = $Query.id; Status = $Query.status; Outcome = 'Created'; Message = $null; Throttled = $false } + } catch { + $Raw = $_.Exception.Data['RawErrorBody'] + if (-not $Raw) { $Raw = $_.ErrorDetails.Message } + if (-not $Raw) { $Raw = $_.Exception.Message } + $Parsed = $null + if ($Raw) { try { $Parsed = ([string]$Raw) | ConvertFrom-Json -ErrorAction Stop } catch {} } + + # AuditingDisabledTenant can be top-level Status or nested as JSON inside error.message. + $AuditStatus = $Parsed.Status + if (-not $AuditStatus) { + $Inner = $Parsed.error.message ?? $Parsed.message + if ($Inner -is [string]) { try { $AuditStatus = ($Inner | ConvertFrom-Json -ErrorAction Stop).Status } catch {} } + } + if ($AuditStatus -eq 'AuditingDisabledTenant') { + return [pscustomobject]@{ Id = $null; Status = 'AuditingDisabledTenant'; Outcome = 'AuditingDisabled'; Message = 'Unified auditing is disabled for this tenant.'; Throttled = $false } + } + + $Code = $Parsed.error.code ?? $Parsed.code + $Msg = $Parsed.error.message ?? $Parsed.message ?? $_.Exception.Message + $StatusCode = $null + try { $StatusCode = [int]$_.Exception.Response.StatusCode } catch {} + + # 429 = the tenant's ~10 concurrent-search cap is full. Retrying in-process won't clear it, + # so return immediately and let the planner defer this + remaining windows to next cycle. + if (($Code -eq 'TooManyRequests') -or ($StatusCode -eq 429)) { + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'TooManyRequests')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $true } + } + + # Other transient (UnknownError, 5xx, gateway, timeout): usually a momentary EXO-backend + # blip that clears on a quick re-submit. Retry in-process with >1s jitter before giving up. + if ($Attempt -lt $MaxAttempts) { + Start-Sleep -Seconds (Get-Random -Minimum 1.5 -Maximum 4.0) + continue + } + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'Error')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $false } + } + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 new file mode 100644 index 0000000000000..9b47577ec4c87 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 @@ -0,0 +1,82 @@ +function Start-AuditLogIngestionV2 { + <# + .SYNOPSIS + V2 audit-log ingestion timer. Drives both download and processing, decoupled so that pending + logs are processed even when there is nothing new to download. + .DESCRIPTION + Runs offset 15 minutes from the creation timer and fans out two kinds of work: + + 1. Download tenants - AuditLogCoverage rows in state 'Created' (a search was created and is + awaiting download) and due (not in backoff). Each gets a per-tenant orchestrator: + Batch = AuditLogDownloadV2 (download succeeded searches -> CacheWebhooks) + PostExecution = AuditLogProcessV2 (enqueue processing if any cache rows are pending) + + 2. Process-only tenants - tenants that have rows sitting in CacheWebhooks (downloaded but + not yet processed, e.g. left behind by a worker crash mid-processing) but no pending + download. These get a processing orchestrator fanned out DIRECTLY, skipping the no-op + download orchestration. + + This makes processing self-healing: a crashed/partial processing run is retried on the next + cycle off the cache contents, not gated behind a fresh download. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + + # --- Download tenants: searches awaiting download (State = Created, due) --- + $Created = @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'") + $DueCreated = $Created | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $DownloadTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($DueCreated | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$DownloadTenants.Add([string]$Name) } + } + + # --- Process-only tenants: rows pending in the webhook cache (downloaded, not yet processed) --- + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $CacheRows = @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey', 'RowKey')) + $CacheTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($CacheRows | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$CacheTenants.Add([string]$Name) } + } + + if ($DownloadTenants.Count -eq 0 -and $CacheTenants.Count -eq 0) { + Write-Information 'AuditLogV2: nothing to download or process' + return + } + + # 1) Download tenants -> download + post-exec processing in one orchestration. + foreach ($TenantFilter in $DownloadTenants) { + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Download + process audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogIngestV2-$TenantFilter" + Batch = @([PSCustomObject]@{ FunctionName = 'AuditLogDownloadV2'; TenantFilter = $TenantFilter }) + PostExecution = @{ FunctionName = 'AuditLogProcessV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + # 2) Process-only tenants (pending cache, no pending download) -> process directly. + $ProcessOnlyCount = 0 + foreach ($TenantFilter in $CacheTenants) { + if ($DownloadTenants.Contains($TenantFilter)) { continue } + $ProcessOnlyCount++ + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Process pending audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ FunctionName = 'AuditLogProcessingBatchV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + Write-Information "AuditLogV2: ingestion fan-out - $($DownloadTenants.Count) download tenant(s), $ProcessOnlyCount process-only tenant(s)" + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error in audit log ingestion orchestrator (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 ingestion error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 new file mode 100644 index 0000000000000..4e8ad995b155b --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 @@ -0,0 +1,38 @@ +function Start-AuditLogPlannerV2 { + <# + .SYNOPSIS + Single timer entrypoint for the V2 audit-log pipeline. Runs every 15 minutes and drives both + stages: plan + create searches, then download + process. + .DESCRIPTION + Replaces the separate Start-AuditLogSearchCreationV2 and Start-AuditLogIngestionV2 timers with + one planner so the whole pipeline ticks together: + + Stage 1 (create) - Start-AuditLogSearchCreationV2: seeds owed 35-min windows (5-min settle, + ends on the :25/:55 grid so a fresh window is creatable exactly at :00/:30 with no tick + delay) plus 12-hour reconciliation windows, then creates the oldest <= 6 due windows per + tenant with auto-retry disabled and manual 429 back-off. + + Stage 2 (ingest) - Start-AuditLogIngestionV2: downloads searches created in PRIOR ticks that + are now ready, processes them, and re-processes any tenant with leftover cache rows. + + The two stages operate on different windows (stage 1 queues new searches; stage 2 consumes + searches from earlier ticks once Graph has finished them), so running them in one tick simply + pipelines the work. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param() + try { + Start-AuditLogSearchCreationV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: search creation stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (create) error: {0}' -f $_.Exception.Message) + } + try { + Start-AuditLogIngestionV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: ingestion stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (ingest) error: {0}' -f $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..7e89abe335f1c --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,123 @@ +function Start-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + V2 audit-log search creation timer. Plans non-overlapping 60-minute windows per tenant in the + AuditLogCoverage ledger and fans out creation only to tenants that owe a window or have a + retry due. + .DESCRIPTION + Replaces Start-AuditLogSearchCreation. Tenant selection is unchanged (WebhookRules Webhookv2, + minus excluded, minus auditing-disabled). The key differences: + * Windows are clock-aligned, 60 minutes, NON-overlapping (tracked in AuditLogCoverage). + * Failed creations are recorded as Planned/Retry ledger rows, so they are retried (and gaps + backfilled) instead of being silently dropped. + * "First check what tenants need searches created" - the timer scans the ledger once and + only fans out per-tenant activities for tenants that owe a window or have a due retry. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + # --- Tenant selection (same source as V1) --- + $ConfigTable = Get-CippTable -TableName 'WebhookRules' + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | ForEach-Object { + $ConfigEntry = $_ + if (!$ConfigEntry.excludedTenants) { + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value @() -Force + } else { + $ConfigEntry.excludedTenants = $ConfigEntry.excludedTenants | ConvertFrom-Json + } + $ConfigEntry.Tenants = $ConfigEntry.Tenants | ConvertFrom-Json + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'ExpandedTenants' -Value (Expand-CIPPTenantGroups -TenantFilter ($ConfigEntry.Tenants)).value -Force + $ConfigEntry + } + if (($ConfigEntries | Measure-Object).Count -eq 0) { + Write-Information 'AuditLogV2: no webhook rules defined; nothing to create' + return + } + + $TenantList = Get-Tenants -IncludeErrors + + # Auditing-disabled skip set (reuse existing table + expiry semantics) + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + $NowUnix = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + $AuditDisabledTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($DisabledRow in @(Get-CIPPAzDataTableEntity @AuditDisabledTable -Filter "PartitionKey eq 'AuditDisabledTenant'")) { + [int64]$ExpiresAtUnix = 0 + if ([int64]::TryParse([string]$DisabledRow.ExpiresAtUnix, [ref]$ExpiresAtUnix) -and $ExpiresAtUnix -gt $NowUnix) { + [void]$AuditDisabledTenants.Add([string]$DisabledRow.RowKey) + } + } + + $InScope = foreach ($Tenant in $TenantList) { + if ($AuditDisabledTenants.Contains($Tenant.defaultDomainName) -or $AuditDisabledTenants.Contains([string]$Tenant.customerId)) { continue } + $Match = $false + foreach ($ConfigEntry in $ConfigEntries) { + if ($ConfigEntry.excludedTenants.value -contains $Tenant.defaultDomainName) { continue } + if ($ConfigEntry.ExpandedTenants -contains $Tenant.defaultDomainName -or $ConfigEntry.ExpandedTenants -contains 'AllTenants') { $Match = $true; break } + } + if ($Match) { $Tenant } + } + $InScope = @($InScope) + if ($InScope.Count -eq 0) { + Write-Information 'AuditLogV2: no in-scope tenants' + return + } + + # --- Scan ledger once, group by tenant --- + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + # Cover the reconciliation horizon (48h) plus slack so the fan-out check sees existing recon rows. + $HorizonIso = (Get-Date).AddHours(-50).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $AllRows = Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp ge datetime'$HorizonIso'" + $ByTenant = @{} + foreach ($Row in $AllRows) { + if (-not $ByTenant.ContainsKey($Row.PartitionKey)) { $ByTenant[$Row.PartitionKey] = [System.Collections.Generic.List[object]]::new() } + $ByTenant[$Row.PartitionKey].Add($Row) + } + + $Now = (Get-Date).ToUniversalTime() + $Batch = foreach ($Tenant in $InScope) { + $Rows = if ($ByTenant.ContainsKey($Tenant.defaultDomainName)) { $ByTenant[$Tenant.defaultDomainName] } else { @() } + $Owed = Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now + $OwedRecon = Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now + $DuePlanned = @($Rows | Where-Object { $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) }) + if (($Owed.Count -gt 0) -or ($OwedRecon.Count -gt 0) -or ($DuePlanned.Count -gt 0)) { + [PSCustomObject]@{ + FunctionName = 'AuditLogSearchCreationV2' + TenantFilter = $Tenant.defaultDomainName + TenantId = [string]$Tenant.customerId + } + } + } + $Batch = @($Batch) + + if ($Batch.Count -gt 0) { + Write-Information "AuditLogV2: $($Batch.Count) tenant(s) need search creation" + if ($PSCmdlet.ShouldProcess('Start-AuditLogSearchCreationV2', 'Create audit log searches')) { + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'AuditLogSearchCreationV2' + Batch = @($Batch) + SkipLog = $true + } + Start-CIPPOrchestrator -InputObject $InputObject + } + } else { + Write-Information 'AuditLogV2: no tenants need searches this run' + } + + # --- Best-effort retention: drop ledger rows older than 7 days (all states; active windows are < 26h old) --- + try { + $CutoffIso = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Stale = @(Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp le datetime'$CutoffIso'" -Property PartitionKey, RowKey) + if ($Stale.Count -gt 0) { + Remove-AzDataTableEntity @Ledger -Entity $Stale -Force + Write-Information "AuditLogV2: cleaned $($Stale.Count) stale ledger row(s)" + } + } catch { + Write-Information "AuditLogV2: ledger cleanup skipped - $($_.Exception.Message)" + } + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error creating audit log searches (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 create error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index c69c34636766c..904972d98fe65 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -55,6 +55,7 @@ function New-GraphPOSTRequest { } catch { $ShouldRetry = $false $WaitTime = 0 + $RetryReason = '' $RawErrorBody = $_.ErrorDetails.Message $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message @@ -67,20 +68,24 @@ function New-GraphPOSTRequest { $RetryAfterHeader = $_.Exception.Response.Headers['Retry-After'] if ($RetryAfterHeader) { $WaitTime = [int]$RetryAfterHeader - Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" - $ShouldRetry = $true + $RetryReason = 'Rate limited (429).' + } else { + $WaitTime = Get-Random -Minimum 1.1 -Maximum 4.1 + $RetryReason = 'Rate limited (429) with no Retry-After header.' } + $ShouldRetry = $true } # Check for "Resource temporarily unavailable" elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 - Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $RetryReason = 'Resource temporarily unavailable.' $ShouldRetry = $true } if ($ShouldRetry) { $RetryCount++ if ($RetryCount -lt $maxRetries) { + Write-Warning "$RetryReason Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" Start-Sleep -Seconds $WaitTime } } else { diff --git a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 88d7e2bc5ba5e..1c3d7345c4177 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -243,7 +243,7 @@ function Invoke-CIPPRestMethod { # ------------------------------------------------------------------ if (-not $SkipHttpErrorCheck -and -not $Result.IsSuccess) { $ErrorMessage = "Response status code does not indicate success: $($Result.StatusCode)" - $Exception = [System.Net.Http.HttpRequestException]::new($ErrorMessage) + $Exception = [CIPP.CIPPHttpRequestException]::new($ErrorMessage, [int]$Result.StatusCode, $Result.ResponseHeaders, $Result.Content) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new($Exception, 'WebCmdletWebResponseException', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Uri) if (-not [string]::IsNullOrWhiteSpace($Result.Content)) { $ErrorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($Result.Content) diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 new file mode 100644 index 0000000000000..8a729938833ae --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 @@ -0,0 +1,125 @@ +function Push-AuditLogDownloadV2 { + <# + .SYNOPSIS + Per-tenant audit-log download activity (V2). Polls created searches, downloads succeeded ones + to CacheWebhooks, and advances the AuditLogCoverage ledger. + .DESCRIPTION + For the tenant's ledger rows in state 'Created' (and due): + * bulk-poll Graph search status + * succeeded -> download records to CacheWebhooks, mark Downloaded (+ RecordCount) + * failed -> re-plan a fresh search (State = Planned, clear SearchId); dead-letter at cap + * running/notStarted -> leave Created; if stuck > 4h, re-plan + * download error -> increment Attempts + backoff; dead-letter at cap (NOT terminal) + Returns a summary the PostExecution (AuditLogProcessV2) uses to decide whether to process. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $MaxAttempts = 6 + $StuckHours = 4 + $Downloaded = 0 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Created'") + $Rows = $Rows | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $Rows = @($Rows) + if ($Rows.Count -eq 0) { + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = 0 } + } + + # Bulk-poll Graph search status for this tenant's created searches + $Requests = foreach ($Row in $Rows) { + if ($Row.SearchId) { @{ id = [string]$Row.SearchId; url = "security/auditLog/queries/$($Row.SearchId)"; method = 'GET' } } + } + $Requests = @($Requests) + $StatusById = @{} + if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -AsApp $true -TenantId $TenantFilter + foreach ($Response in $Responses) { + if ($Response.body -and $Response.body.id) { $StatusById[[string]$Response.body.id] = [string]$Response.body.status } + } + } + + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + + foreach ($Row in $Rows) { + $SearchId = [string]$Row.SearchId + $Status = $StatusById[$SearchId] + $CreatedAgeHours = if ($Row.CreatedUtc) { ($Now - ([datetimeoffset]$Row.CreatedUtc).UtcDateTime).TotalHours } else { 999 } + + if ($Status -eq 'succeeded') { + try { + $Results = @(Get-CippAuditLogSearchResults -TenantFilter $TenantFilter -QueryId $SearchId) + foreach ($SearchResult in $Results) { + Add-CIPPAzDataTableEntity @CacheTable -Entity @{ + RowKey = [string]$SearchResult.id + PartitionKey = [string]$TenantFilter + SearchId = $SearchId + JSON = [string]($SearchResult | ConvertTo-Json -Depth 10 -Compress) + CippProcessing = $false + CippProcessingStarted = '' + } -Force + } + $Downloaded += $Results.Count + # Empty windows have nothing to process - mark them Processed directly so they + # don't sit at Downloaded forever. Windows with records go to Downloaded and are + # advanced to Processed by Push-AuditLogTenantProcessV2 once their rows are drained. + $DownloadState = if ($Results.Count -eq 0) { 'Processed' } else { 'Downloaded' } + $LedgerUpdate = @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = $DownloadState + RecordCount = [int]$Results.Count; DownloadedUtc = $Now; Attempts = 0 + SearchStatus = 'succeeded'; LastPolledUtc = $Now + } + if ($DownloadState -eq 'Processed') { + $LedgerUpdate.ProcessedUtc = $Now + $LedgerUpdate.MatchedCount = 0 + } + Add-CIPPAzDataTableEntity @Ledger -Entity $LedgerUpdate -OperationType UpsertMerge + Write-Information "AuditLogV2: downloaded $($Results.Count) record(s) for $TenantFilter window $($Row.RowKey)" + } catch { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + Write-Information "AuditLogV2: download error for $TenantFilter window $($Row.RowKey): $($_.Exception.Message)" + } + } elseif ($Status -eq 'failed') { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = 'failed'; LastPolledUtc = $Now; LastError = 'Graph search failed'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = ''; LastPolledUtc = $Now; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = 'Graph search failed; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } elseif ($Status -in @('running', 'notStarted')) { + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search stuck; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + # Not ready yet: leave Created, but persist the live Graph search status so a pending + # window shows WHY it is still in-flight (e.g. 'running') rather than looking stuck. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = [string]$Status; LastPolledUtc = $Now } -OperationType UpsertMerge + } + } else { + # Unknown / search no longer present on Graph + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search not found; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = $(if ($Status) { [string]$Status } else { 'unknown' }); LastPolledUtc = $Now } -OperationType UpsertMerge + } + } + } + + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = $Downloaded } + } catch { + Write-Information ('Push-AuditLogDownloadV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + return @{ TenantFilter = $TenantFilter; Success = $false; Downloaded = $Downloaded; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 new file mode 100644 index 0000000000000..7c52ccf09c766 --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 @@ -0,0 +1,53 @@ +function Push-AuditLogProcessV2 { + <# + .SYNOPSIS + PostExecution step of the V2 ingestion orchestrator. If the per-tenant download succeeded, + enqueues a per-tenant processing orchestrator (post-exec style). + .DESCRIPTION + Receives the download orchestrator's aggregated results ($Item.Results) and the tenant filter + ($Item.Parameters.TenantFilter). When at least one record was downloaded, it starts a + per-tenant processing orchestrator whose QueueFunction (AuditLogProcessingBatchV2) pages that + tenant's CacheWebhooks rows into batches handled by the existing AuditLogTenantProcess engine. + If nothing was downloaded, processing is skipped. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + try { + $TenantFilter = $Item.Parameters.TenantFilter + if (-not $TenantFilter) { + $TenantFilter = (@($Item.Results) | Where-Object { $_.TenantFilter } | Select-Object -First 1).TenantFilter + } + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessV2: no tenant filter resolved; skipping' + return @{ Success = $false } + } + + # Fire processing whenever the tenant has rows pending in the cache - records just downloaded + # this cycle OR rows left behind by an earlier crash. Not gated on the download count, so a + # crashed/partial processing run is retried on the next cycle. The batch builder is the + # authoritative gate (claims claimable rows; returns nothing if there's truly no work). + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $Pending = @(Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey')) + if ($Pending.Count -eq 0) { + Write-Information "AuditLogProcessV2: no pending cache rows for $TenantFilter; nothing to process" + return @{ Success = $true; Processed = $false } + } + + Write-Information "AuditLogProcessV2: enqueueing processing for $TenantFilter ($($Pending.Count) pending cache row(s))" + $InputObject = [PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ + FunctionName = 'AuditLogProcessingBatchV2' + Parameters = @{ TenantFilter = $TenantFilter } + } + SkipLog = $true + } + $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject + return @{ Success = $true; Processed = $true; InstanceId = $InstanceId } + } catch { + Write-Information ('Push-AuditLogProcessV2 error: {0}' -f $_.Exception.Message) + return @{ Success = $false; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 new file mode 100644 index 0000000000000..fad2047ad5c9d --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 @@ -0,0 +1,66 @@ +function Push-AuditLogProcessingBatchV2 { + <# + .SYNOPSIS + QueueFunction for the per-tenant V2 processing orchestrator. Builds processing batches from a + single tenant's CacheWebhooks rows. + .DESCRIPTION + Tenant-scoped variant of Push-AuditLogProcessingBatch. Pages the CacheWebhooks rows for the + tenant supplied via the QueueFunction Parameters, claims unclaimed (or stale > 2h) rows by + stamping CippProcessing = true, and returns 500-row batch items routed to the + AuditLogTenantProcessV2 activity (which runs Test-CIPPAuditLogRules and advances the ledger). + Scoping to one tenant avoids cross-tenant scans and claim races when many tenants process + concurrently. The 2h stale window lets a crashed processing run be re-claimed and retried. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter ?? $Item.TenantFilter + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessingBatchV2: no tenant filter; nothing to process' + return @() + } + + $WebhookCacheTable = Get-CippTable -TableName 'CacheWebhooks' + $AllBatchItems = [System.Collections.Generic.List[object]]::new() + $NowUtc = (Get-Date).ToUniversalTime() + $StaleThreshold = $NowUtc.AddHours(-2) + + $Rows = @(Get-CIPPAzDataTableEntity @WebhookCacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey', 'ETag', 'Timestamp', 'CippProcessing')) + $Claimable = @($Rows | Where-Object { + -not $_.CippProcessing -or ($_.Timestamp -and $_.Timestamp.UtcDateTime -lt $StaleThreshold) + }) + if ($Claimable.Count -eq 0) { + Write-Information "AuditLogProcessingBatchV2: no claimable rows for $TenantFilter" + return @() + } + + $RowIds = @($Claimable.RowKey) + foreach ($Row in $Claimable) { + Add-CIPPAzDataTableEntity @WebhookCacheTable -Entity ([PSCustomObject]@{ + PartitionKey = $TenantFilter + RowKey = $Row.RowKey + CippProcessing = $true + }) -OperationType UpsertMerge + } + + for ($i = 0; $i -lt $RowIds.Count; $i += 500) { + $BatchRowIds = $RowIds[$i..([Math]::Min($i + 499, $RowIds.Count - 1))] + $AllBatchItems.Add([PSCustomObject]@{ + TenantFilter = $TenantFilter + RowIds = $BatchRowIds + FunctionName = 'AuditLogTenantProcessV2' + }) + } + + if ($AllBatchItems.Count -gt 0) { + $ProcessQueue = New-CippQueueEntry -Name "Audit Logs Process V2 - $TenantFilter" -Reference 'AuditLogsProcessV2' -TotalTasks $RowIds.Count + foreach ($BatchItem in $AllBatchItems) { + $BatchItem | Add-Member -MemberType NoteProperty -Name QueueId -Value $ProcessQueue.RowKey -Force + } + Write-Information "AuditLogProcessingBatchV2: $($AllBatchItems.Count) batch item(s) across $($RowIds.Count) row(s) for $TenantFilter" + } + + return $AllBatchItems.ToArray() +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..70085030edaaf --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,141 @@ +function Push-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + Per-tenant audit-log search creation activity (V2). Seeds owed regular (35-min) and 12-hour + reconciliation windows into the AuditLogCoverage ledger, then creates Graph searches for the + due ones - oldest first, capped per cycle, with manual throttle handling. + .DESCRIPTION + 1. Seed owed regular windows (Get-CippAuditLogPlannedWindows) and reconciliation windows + (Get-CippAuditLogReconciliationWindows) as Planned ledger rows. + 2. Take the oldest <= MaxPerCycle (6) due Planned windows (regular + reconciliation combined) + and create a Graph search for each, with auto-retry DISABLED (New-CippAuditLogSearchV2 -> + maxRetries 1). + 3. Throttling: the createSearch 429 is a per-tenant cap of ~10 concurrent searches, not a rate + limit, so on a 429 we stop and defer the current window AND all remaining queued windows to + the next cycle (no Attempts increment - a cap is not a failure, so it never dead-letters). + Other transient errors (UnknownError, 5xx, gateway) retry the individual window with backoff + and dead-letter at MaxAttempts. AuditingDisabled caches the tenant for 24h and stops. + + Capping at 6/cycle keeps us well under the ~10 concurrent-search ceiling (they complete within + the cycle and free slots). Oldest-first means gaps/backlog/reconciliation drain before the + freshest window; in steady state only the one new window is due, so latency is unaffected. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $TenantId = $Item.TenantId + $MaxPerCycle = 6 + $MaxAttempts = 8 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter'") + $Now = (Get-Date).ToUniversalTime() + + # 1) Seed owed regular + reconciliation windows as Planned. + foreach ($Window in (Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Window'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + foreach ($Window in (Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Reconciliation'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + + # 2) Build the create batch: the freshest regular window FIRST (so live events alert + # fast even during a backlog), then the oldest of whatever remains - regular + + # reconciliation - to drain gaps before they age out. Reconciliation windows are + # never "current"; they flow through the oldest-first backfill slots unchanged. + $Due = @($Rows | Where-Object { + $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) + } | Sort-Object @{ Expression = { ([datetimeoffset]$_.WindowStart).UtcDateTime } }) + if ($Due.Count -eq 0) { + Write-Information "AuditLogV2: no due windows for $TenantFilter" + return $true + } + # Newest regular (14-digit RowKey) window = the live period; reconciliation (RECON-*) is never current. + $CurrentWindow = @($Due | Where-Object { [string]$_.RowKey -match '^\d{14}$' } | Select-Object -Last 1) + if ($CurrentWindow.Count -gt 0) { + $CurrentKey = [string]$CurrentWindow[0].RowKey + $Backfill = @($Due | Where-Object { [string]$_.RowKey -ne $CurrentKey } | Select-Object -First ($MaxPerCycle - 1)) + $Batch = @($CurrentWindow[0]) + $Backfill + } else { + # Only reconciliation windows are due: oldest-first, capped. + $Batch = @($Due | Select-Object -First $MaxPerCycle) + } + + # 3) Create searches (no auto-retry). On 429, defer current + remaining to next cycle. + $Bail = $false + foreach ($Row in $Batch) { + if ($Bail) { + # Deferred (not attempted): just set NextAttemptUtc, leave Attempts/State as Planned. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Deferred: tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + continue + } + + $Start = ([datetimeoffset]$Row.WindowStart).UtcDateTime + $End = ([datetimeoffset]$Row.WindowEnd).UtcDateTime + $Result = New-CippAuditLogSearchV2 -TenantFilter $TenantFilter -StartTime $Start -EndTime $End + $Attempts = [int]$Row.Attempts + 1 + + if ($Result.Outcome -eq 'Created' -and $Result.Id) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Created' + SearchId = [string]$Result.Id; Attempts = 0; CreatedUtc = $Now + SearchStatus = [string]$Result.Status; LastPolledUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: created search for $TenantFilter window $($Row.RowKey)" + } elseif ($Result.Throttled) { + # 429 = tenant cap full. Defer this window (no Attempts bump - a cap isn't a failure) and bail. + $Bail = $true + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: 429 for $TenantFilter - deferring this + remaining windows to next cycle" + } elseif ($Result.Outcome -eq 'AuditingDisabled') { + $Bail = $true + try { + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + Add-CIPPAzDataTableEntity @AuditDisabledTable -Entity @{ + PartitionKey = 'AuditDisabledTenant'; RowKey = [string]$TenantFilter; TenantFilter = [string]$TenantFilter + Status = 'AuditingDisabledTenant'; ExpiresAtUnix = [int64]([datetimeoffset]::UtcNow.AddHours(24).ToUnixTimeSeconds()) + } -Force + } catch {} + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Skipped'; LastError = 'AuditingDisabledTenant'; LastErrorUtc = $Now } -OperationType UpsertMerge + Write-Information "AuditLogV2: auditing disabled for $TenantFilter; skipping" + } else { + # Other transient: retry this window next cycle; dead-letter at cap. + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } + } + return $true + } catch { + Write-Information ('Push-AuditLogSearchCreationV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + Write-Information $_.InvocationInfo.PositionMessage + return $false + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 new file mode 100644 index 0000000000000..e04ec02ca7c5e --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 @@ -0,0 +1,104 @@ +function Push-AuditLogTenantProcessV2 { + <# + .SYNOPSIS + Per-batch audit-log processing activity (V2). Processes a batch of cached rows via the + existing Test-CIPPAuditLogRules engine, then advances the AuditLogCoverage ledger to + 'Processed' for any SearchId whose rows are now fully drained from the cache. + .DESCRIPTION + Same processing as the V1 Push-AuditLogTenantProcess (reads the RowIds from CacheWebhooks + and runs Test-CIPPAuditLogRules, which removes processed rows). Additionally: + * captures the distinct SearchIds represented by this batch's rows + * after processing, for each of those SearchIds with zero remaining CacheWebhooks rows, + marks the matching ledger window State = 'Processed' (ProcessedUtc + MatchedCount) + Because the mark is gated on "no rows left for this SearchId", a search split across + multiple 500-row batches is only marked Processed when its final batch completes. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $RowIds = $Item.RowIds + + try { + $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' + $SearchIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $Rows = foreach ($RowId in $RowIds) { + $CacheEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$RowId'" + if ($CacheEntity) { + if ($CacheEntity.SearchId) { [void]$SearchIds.Add([string]$CacheEntity.SearchId) } + $CacheEntity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + } + } + + if ($Rows.Count -eq 0) { + Write-Information "AuditLogV2: no rows found in cache for the provided row IDs ($TenantFilter)" + return $false + } + + Write-Information "AuditLogV2: processing $($Rows.Count) row(s) for $TenantFilter" + $Result = Test-CIPPAuditLogRules -TenantFilter $TenantFilter -Rows $Rows + $MatchedLogs = [int]($Result.MatchedLogs ?? 0) + + # Advance the ledger to Processed for any SearchId now fully drained from the cache. + if ($SearchIds.Count -gt 0) { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $SingleSearch = ($SearchIds.Count -eq 1) + $Now = (Get-Date).ToUniversalTime() + foreach ($SearchId in $SearchIds) { + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + + $LedgerRows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'") + foreach ($LedgerRow in $LedgerRows) { + $Update = @{ + PartitionKey = $TenantFilter + RowKey = $LedgerRow.RowKey + State = 'Processed' + ProcessedUtc = $Now + } + # Only attribute matched count when this batch was a single search (unambiguous). + if ($SingleSearch) { $Update.MatchedCount = $MatchedLogs } + Add-CIPPAzDataTableEntity @Ledger -Entity $Update -OperationType UpsertMerge + Write-Information "AuditLogV2: marked window $($LedgerRow.RowKey) Processed for $TenantFilter (search $SearchId)" + } + } + } + + # Sweep orphaned Downloaded windows. Once this batch's rows are processed, re-scan every + # window left at 'Downloaded' for the tenant and cross-check it against the cache by SearchId. + # If no CacheWebhooks rows remain for that search, the records were already processed - often + # under an OVERLAPPING window's search, because CacheWebhooks is keyed by record id, so a 5-min + # window overlap (or a legacy 60-min window sharing record ids) overwrites the SearchId and the + # per-batch marking above never sees this window's id. Mark it Processed. Windows whose search + # still has cache rows are left as-is; they get picked up on the next process round. + try { + $SweepLedger = Get-CippTable -TableName 'AuditLogCoverage' + $SweepNow = (Get-Date).ToUniversalTime() + $DownloadedRows = @(Get-CIPPAzDataTableEntity @SweepLedger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Downloaded'") + foreach ($DownRow in $DownloadedRows) { + $Sid = [string]$DownRow.SearchId + if (-not $Sid) { continue } + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$Sid'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + Add-CIPPAzDataTableEntity @SweepLedger -Entity @{ + PartitionKey = $TenantFilter + RowKey = $DownRow.RowKey + State = 'Processed' + ProcessedUtc = $SweepNow + MatchedCount = 0 + } -OperationType UpsertMerge + Write-Information "AuditLogV2: swept window $($DownRow.RowKey) to Processed for $TenantFilter (search $Sid drained, no cache rows)" + } + } catch { + Write-Information ('Push-AuditLogTenantProcessV2 sweep error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + } + + return $true + } catch { + Write-Information ('Push-AuditLogTenantProcessV2: Error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + return $false + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 new file mode 100644 index 0000000000000..2944d6810ae6b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 @@ -0,0 +1,135 @@ +function Invoke-ListAuditLogCoverage { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Alert.Read + .DESCRIPTION + Lists the V2 audit-log coverage ledger (AuditLogCoverage) - one row per tenant + 60-minute + search window with its state (Planned / Created / Downloaded / Retry / DeadLetter / Skipped), + record count, attempts and last error. Honours the tenant selector (a specific tenant or + AllTenants) and CIPP tenant access control. Accepts the same date filters as the Saved Logs + view: RelativeTime (e.g. 48h, 7d) or StartDate/EndDate. Defaults to the last 48 hours. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # --- Date range (mirrors Invoke-ListAuditLogs) --- + $RelativeTime = $Request.Query.RelativeTime ?? $Request.Body.RelativeTime + $StartDate = $Request.Query.StartDate ?? $Request.Body.StartDate + $EndDate = $Request.Query.EndDate ?? $Request.Body.EndDate + + $EndUtc = (Get-Date).ToUniversalTime() + $StartUtc = $EndUtc.AddHours(-48) + + if (-not $RelativeTime -and -not $StartDate -and -not $EndDate) { + $RelativeTime = '48h' + } + + if ($RelativeTime -and $RelativeTime -match '(\d+)([dhm])') { + $Interval = [int]$Matches[1] + switch ($Matches[2]) { + 'd' { $StartUtc = $EndUtc.AddDays(-$Interval) } + 'h' { $StartUtc = $EndUtc.AddHours(-$Interval) } + 'm' { $StartUtc = $EndUtc.AddMinutes(-$Interval) } + } + } elseif ($StartDate) { + if ([string]$StartDate -match '^\d+$') { + $StartUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).UtcDateTime + } else { + try { $StartUtc = ([datetimeoffset]$StartDate).UtcDateTime } catch {} + } + if ($EndDate) { + if ([string]$EndDate -match '^\d+$') { + $EndUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).UtcDateTime + } else { + try { $EndUtc = ([datetimeoffset]$EndDate).UtcDateTime } catch {} + } + } + } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + + # When access is scoped, resolve the caller's allowed tenants to both domain + id for matching. + $AllowedDomains = $null + $AllowedIds = $null + if ($AllowedTenants -notcontains 'AllTenants') { + $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } + $AllowedDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $AllowedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Tenant in $TenantList) { + if ($Tenant.defaultDomainName) { [void]$AllowedDomains.Add([string]$Tenant.defaultDomainName) } + if ($Tenant.customerId) { [void]$AllowedIds.Add([string]$Tenant.customerId) } + } + } + + function ConvertTo-Utc { + param($Value) + if (-not $Value) { return $null } + try { return ([datetimeoffset]$Value).UtcDateTime } catch { return $null } + } + + $Table = Get-CIPPTable -TableName 'AuditLogCoverage' + $Rows = Get-CIPPAzDataTableEntity @Table + + $Results = foreach ($Row in $Rows) { + $WindowStart = ConvertTo-Utc $Row.WindowStart + # Date range filter on the window start + if ($WindowStart) { + if ($WindowStart -lt $StartUtc -or $WindowStart -gt $EndUtc) { continue } + } + + # Tenant selector filter (match on default domain or customer id) + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + if (($Row.PartitionKey -ne $TenantFilter) -and ([string]$Row.TenantId -ne [string]$TenantFilter)) { continue } + } + + # Access control when not AllTenants + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not ($AllowedDomains.Contains([string]$Row.PartitionKey) -or $AllowedIds.Contains([string]$Row.TenantId))) { continue } + } + + [PSCustomObject]@{ + Tenant = $Row.PartitionKey + TenantId = $Row.TenantId + Type = if ($Row.Type) { $Row.Type } elseif ($Row.RowKey -like 'RECON-*') { 'Reconciliation' } else { 'Window' } + WindowStart = $WindowStart + WindowEnd = ConvertTo-Utc $Row.WindowEnd + State = $Row.State + RecordCount = if ($null -ne $Row.RecordCount) { [int]$Row.RecordCount } else { $null } + Attempts = if ($null -ne $Row.Attempts) { [int]$Row.Attempts } else { 0 } + RetryCount = if ($null -ne $Row.RetryCount) { [int]$Row.RetryCount } else { 0 } + ThrottleCount = if ($null -ne $Row.ThrottleCount) { [int]$Row.ThrottleCount } else { 0 } + SearchId = $Row.SearchId + SearchStatus = $Row.SearchStatus + NextAttemptUtc = ConvertTo-Utc $Row.NextAttemptUtc + LastError = $Row.LastError + LastErrorUtc = ConvertTo-Utc $Row.LastErrorUtc + LastPolledUtc = ConvertTo-Utc $Row.LastPolledUtc + CreatedUtc = ConvertTo-Utc $Row.CreatedUtc + DownloadedUtc = ConvertTo-Utc $Row.DownloadedUtc + ProcessedUtc = ConvertTo-Utc $Row.ProcessedUtc + MatchedCount = if ($null -ne $Row.MatchedCount) { [int]$Row.MatchedCount } else { $null } + LastUpdated = ConvertTo-Utc $Row.Timestamp + } + } + + $Results = @($Results | Sort-Object -Property @{ Expression = 'Tenant' }, @{ Expression = 'WindowStart'; Descending = $true }) + + $Body = @{ + Results = @($Results) + Metadata = @{ + TenantFilter = $TenantFilter + StartDate = $StartUtc.ToString('o') + EndDate = $EndUtc.ToString('o') + Total = $Results.Count + } + } | ConvertTo-Json -Depth 10 -Compress + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 08c5a60fde372..555e16aed89d3 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -36,6 +36,88 @@ public sealed class HttpResult public Dictionary ResponseHeaders { get; init; } = new(); } + // ===================================================================== + // CIPPResponseHeaders / CIPPHttpResponse / CIPPHttpRequestException + // ===================================================================== + // When a request returns a non-success status, the PowerShell wrapper + // (Invoke-CIPPRestMethod) throws a CIPPHttpRequestException. CIPP's Graph + // helpers were written against Invoke-RestMethod's HttpResponseException + // and read: + // $_.Exception.Response.StatusCode -eq 429 + // $_.Exception.Response.Headers['Retry-After'] + // The pooled client previously threw a bare HttpRequestException with no + // .Response, so those branches were dead. These types restore the expected + // shape: a .Response with a StatusCode (HttpStatusCode, so `-eq 429` works) + // and Headers that support case-insensitive string indexing returning a + // scalar value (so ['Retry-After'] works), matching the old behaviour. + // ===================================================================== + + /// + /// Case-insensitive response-header view exposed to PowerShell. The string + /// indexer returns the (comma-joined) value for a header, or null if absent, + /// mirroring how WebHeaderCollection / HttpResponseHeaders were consumed in + /// CIPP via $response.Headers['Header-Name']. + /// + public sealed class CIPPResponseHeaders + { + private readonly Dictionary _headers; + + public CIPPResponseHeaders(Dictionary? headers) + { + _headers = headers is not null + ? new Dictionary(headers, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// $resp.Headers['Retry-After'] -> scalar string (joined), or null. + public string? this[string key] + => key is not null && _headers.TryGetValue(key, out var v) ? string.Join(", ", v) : null; + + public bool Contains(string key) => key is not null && _headers.ContainsKey(key); + public string[]? GetValues(string key) => key is not null && _headers.TryGetValue(key, out var v) ? v : null; + public IEnumerable Keys => _headers.Keys; + public int Count => _headers.Count; + } + + /// + /// Lightweight stand-in for the response object CIPP code reaches through + /// $_.Exception.Response. Carries the status code (as HttpStatusCode so + /// `-eq 429` works), the headers (string-indexable), and the raw body. + /// + public sealed class CIPPHttpResponse + { + public HttpStatusCode StatusCode { get; } + public int StatusCodeValue { get; } + public CIPPResponseHeaders Headers { get; } + public string Content { get; } + + public CIPPHttpResponse(int statusCode, Dictionary? headers, string? content) + { + StatusCode = (HttpStatusCode)statusCode; + StatusCodeValue = statusCode; + Headers = new CIPPResponseHeaders(headers); + Content = content ?? string.Empty; + } + } + + /// + /// HttpRequestException subclass carrying a .Response (CIPPHttpResponse). + /// Subclassing keeps existing `catch [System.Net.Http.HttpRequestException]` + /// and `$_.Exception.Message` / `$_.ErrorDetails.Message` handling intact, + /// while restoring `$_.Exception.Response.StatusCode` / + /// `$_.Exception.Response.Headers['Retry-After']`. + /// + public sealed class CIPPHttpRequestException : HttpRequestException + { + public CIPPHttpResponse Response { get; } + + public CIPPHttpRequestException(string message, int statusCode, Dictionary? headers, string? content) + : base(message, null, (HttpStatusCode)statusCode) + { + Response = new CIPPHttpResponse(statusCode, headers, content); + } + } + // ===================================================================== // CIPPRestClient // ===================================================================== diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 242e6458a8033fa17149cdb26863d007254a2571..9f352615961ffbce098cba92da1f221d9bf52536 100644 GIT binary patch literal 44544 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 xRJrT@+?{lZO%+0R360y-793*j zp{s1_DvkW3bfZmuplAE{((N|&I~V!K=^mTvcawjD?zgFf?ri^M^lh7J@Q{BWeb1(D z0(Ch(Yg4a!vi(=mOE!g@UH`}FA8jfJzFb9b+SEoZ+kZ8^Yg0djFW1oTZ0fu4@)PJ zO??Wo&(Kqfl2LFA{ZuG53T~mdGGy-xbs63hTjsxoey3!<_d@O7Tj|d>^=Hi6TglVK zUSHw+vUi35Rzk`a>UNuH*I(sylHhxXenK9|zy4IiIQy^1p|jZO)(H~i74yPWwCmhzeGEr4!% zp@L7*x+?ix;RT#FW$M3xeWy;#AkE~DpsY@n;JfH~vEv2qp$gbH=;<;(4~>B1qw9cY zmSukwXEJpB)biQ>C#2pV!3T}56HYq*0X%on-%A@=igSwmKmgwQ0|6KAO#%To?o$E* z5AIL`0lqg01iZLU2?Tt&qxtXXPxic6;+%^!L>Ik><3Tr7gX5tXI2!#5KIn9rMDia9zhmxw9+4{5#c zkr*;(a^(MCab|njL!Wr~x8TKiaO#W}#x+X8|0#aDO@$xN@*ncJb_F@+nUUl+DvnOs z=w%l@B%HYdFUZfs4lsak7Cnn2Jx7Obp)Vdl**mqU{$reRJwU&PW}EiQz*hn9t$7fzGyjm* z2g?EK(;vn?XsiB@;g_`sQR*$NRez-HW$jVmf6|_$p9Qk?=LG+e;IGlSB@6VoXt-*H z{x1D8&<)5o-=)s{LH)O)xl6yV_HzACHvWF?L;5a#Z}@WkD*Zs1=-9G{}(5`RibL&1{#jvvAT6e2{j{kG+2lUsf?{Ux8>cih~KcF?&J_1-j z<&gU!{j}Prz+V9VZ0(l(R=|f#e(#>I{Y&)E8P@KrdDL@AudI5;b42X)YDa3{_EhW5wZ8)VM!|nT z=hd1IJ$vBi3cXia@qm7s?+Vyi7=9blH){;))#mvE23spO`lXG%8pm*0V86gtePiVu zqgT7Q@-$EP%59xFL|6&}{7EF29cuCJK(7Z3{f0*L+{!zO) z;P-x?YNzG{E}mKjcrx&>NR68QaLMW3Ed5`q+ri;lz9QBR=)WjE6PgPG3Gbt#`Knes z^=9v@T2pvGINYOOR9QbgRL2cUa3vThXX=8%(p7mW~>4S7gUqlbm7hFpL z=efUxQ(B(UQPj00>ra4}y8nb8IWNnN_-x6_(eJAq%Zh4s6(7&iTxS$q2OW-sTShWR z;HIo|wWmFIX6@1s7nZy75!)|km7(kky;Xk``Jq*RA-vktsvitLk`;7)v-VIH # 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 Date: Mon, 29 Jun 2026 16:10:15 +0800 Subject: [PATCH 104/150] External Storage Account Backup Replication --- Modules/CIPPCore/Public/New-CIPPBackup.ps1 | 4 + .../Public/Push-CIPPBackupReplication.ps1 | 83 +++++++++++++ .../Invoke-ExecBackupReplicationConfig.ps1 | 115 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index f1f155657c970..741037aa50a9e 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -161,6 +161,10 @@ function New-CIPPBackup { # If building full URL fails, fall back to resource path $blobUrl = $resourcePath } + + # Best-effort off-site replication to an external storage account. + $ReplType = if ($backupType -eq 'CIPP') { 'Core' } else { 'Tenant' } + Push-CIPPBackupReplication -BackupType $ReplType -BlobName $blobName -Content $BackupData -Headers $Headers } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 new file mode 100644 index 0000000000000..20d40026c41bc --- /dev/null +++ b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 @@ -0,0 +1,83 @@ +function Push-CIPPBackupReplication { + <# + .SYNOPSIS + Replicates a CIPP backup blob to an external storage account using a container SAS URL. + + .DESCRIPTION + After a backup blob is written to the CIPP-bound storage account, this helper uploads an + identical copy to an external Azure Storage container. The destination is described by a + container-level SAS URL (with write+create permission) stored in Key Vault, so the secret + never lands in table storage or the browser. + + There are two independent replication targets: + Core -> KV secret 'BackupReplicationCore' (Config RowKey 'Core') for CIPP backups + Tenant -> KV secret 'BackupReplicationTenant' (Config RowKey 'Tenant') for scheduled tenant backups + + Replication is best-effort: any failure is logged but never thrown, so a replication problem + can never abort the underlying backup. + + .PARAMETER BackupType + 'Core' for CIPP backups, 'Tenant' for scheduled tenant backups. + + .PARAMETER BlobName + The blob file name to write (e.g. 'CIPPBackup_2024-01-15-1430.json'). + + .PARAMETER Content + The backup payload (JSON string) to upload. + + .PARAMETER Headers + Request headers passed through to Write-LogMessage for attribution. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Core', 'Tenant')] + [string]$BackupType, + + [Parameter(Mandatory = $true)] + [string]$BlobName, + + [Parameter(Mandatory = $true)] + [string]$Content, + + $Headers + ) + + $SecretName = "BackupReplication$BackupType" + + try { + # Only replicate when explicitly enabled for this scope. + $Table = Get-CIPPTable -TableName 'Config' + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$BackupType'" + if (-not $Config -or $Config.Enabled -ne $true) { + return + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $SasUrl = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$BackupType' and RowKey eq 'BackupReplication$BackupType'").SASUrl + } + else { + $SasUrl = Get-CippKeyVaultSecret -Name $SecretName -AsPlainText + } + + if ([string]::IsNullOrWhiteSpace($SasUrl)) { + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "$BackupType backup replication is enabled but no SAS URL is stored" -Sev 'Warning' + return + } + + # Insert the blob name into the container SAS URL, before the query string. + $UrlParts = $SasUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $Target = "$BaseUrl/$BlobName" + if ($UrlParts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($UrlParts[1])) { + $Target = "$Target`?$($UrlParts[1])" + } + + $null = Invoke-CIPPRestMethod -Uri $Target -Method 'PUT' -Body $Content -ContentType 'application/json; charset=utf-8' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Replicated $BackupType backup '$BlobName' to external storage" -Sev 'Debug' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Failed to replicate $BackupType backup '$BlobName' to external storage: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 new file mode 100644 index 0000000000000..1d71ffc2be0cd --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 @@ -0,0 +1,115 @@ +function Invoke-ExecBackupReplicationConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Table = Get-CIPPTable -TableName Config + $Scopes = @('Core', 'Tenant') + + # Returns whether a SAS URL secret currently exists for the given scope, without ever exposing it. + function Get-ReplicationSecretIsSet { + param([string]$Scope) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$Scope' and RowKey eq 'BackupReplication$Scope'").SASUrl + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + else { + $Secret = Get-CippKeyVaultSecret -Name "BackupReplication$Scope" -AsPlainText + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + } catch { + return $null + } + } + + $results = try { + if ($Request.Query.List) { + $Output = @{} + foreach ($Scope in $Scopes) { + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$Scope'" + $Output[$Scope] = @{ + Enabled = [bool]($Config.Enabled) + IsSet = Get-ReplicationSecretIsSet -Scope $Scope + } + } + [pscustomobject]$Output + } else { + $BackupType = $Request.Body.BackupType + if ($BackupType -notin $Scopes) { + throw "BackupType must be one of: $($Scopes -join ', ')" + } + + $SASUrl = $Request.Body.SASUrl + $Enabled = if ($null -ne $Request.Body.Enabled) { [bool]$Request.Body.Enabled } else { $true } + + # Only update the stored secret when a real new value is supplied (the UI sends the + # 'SentToKeyVault' sentinel when the existing, masked secret is left untouched). + if (-not [string]::IsNullOrWhiteSpace($SASUrl) -and $SASUrl -ne 'SentToKeyVault') { + $ParsedUri = $SASUrl -as [uri] + if (-not $ParsedUri -or $ParsedUri.Query -notmatch 'sig=') { + throw 'SAS URL must contain a SAS token (sig=...)' + } + + # Confirm the SAS actually grants write+create by writing and removing a tiny probe blob. + $guid = [guid]::NewGuid().ToString() + $UrlParts = $SASUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $ProbeUrl = "$BaseUrl/.cipp-replication-test-$guid`?$($UrlParts[1])" + try { + $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'PUT' -Body "cipp-replication-test-$guid" -ContentType 'text/plain' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + try { $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'DELETE' -Headers @{} } catch { } + } catch { + $ProbeError = Get-CippException -Exception $_ + throw "SAS URL validation failed (could not write to the container): $($ProbeError.NormalizedError)" + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = [PSCustomObject]@{ + 'PartitionKey' = "BackupReplication$BackupType" + 'RowKey' = "BackupReplication$BackupType" + 'SASUrl' = $SASUrl + } + Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null + } + else { + Set-CippKeyVaultSecret -Name "BackupReplication$BackupType" -SecretValue (ConvertTo-SecureString -String $SASUrl -AsPlainText -Force) | Out-Null + } + } + + $Config = @{ + 'PartitionKey' = 'BackupReplication' + 'RowKey' = $BackupType + 'Enabled' = $Enabled + } + Add-CIPPAzDataTableEntity @Table -Entity $Config -Force | Out-Null + + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Updated $BackupType backup replication settings (Enabled: $Enabled)" -Sev 'Info' + "Successfully updated $BackupType backup replication settings" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to update backup replication configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + "Failed to update configuration: $($ErrorMessage.NormalizedError)" + } + + $body = [pscustomobject]@{'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} From 1b8c0c1d8b48c0c05490c049a65f1ff41ff36a51 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:22:19 +0800 Subject: [PATCH 105/150] Fixes for CA template editing dropping the package tag --- .../HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 index bd5cd4d8e0112..7beffb2959103 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 @@ -12,7 +12,7 @@ function Invoke-ExecEditTemplate { try { $Table = Get-CippTable -tablename 'templates' $guid = $request.Body.id ? $request.Body.id : $request.Body.GUID - $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID) + $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID, source, isSynced, package) $Type = $request.Query.Type ?? $Request.Body.Type if ($Type -eq 'IntuneTemplate') { @@ -63,13 +63,14 @@ function Invoke-ExecEditTemplate { } Set-CIPPIntuneTemplate @IntuneTemplate } else { - $Table.Force = $true - Add-CIPPAzDataTableEntity @Table -Entity @{ + $Entity = @{ JSON = "$JSON" RowKey = "$GUID" PartitionKey = "$Type" GUID = "$GUID" + SHA = '' } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType 'UpsertMerge' Write-LogMessage -headers $Request.Headers -API $APINAME -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Debug' } $body = [pscustomobject]@{ 'Results' = 'Successfully saved the template' } From b995698f00f01df682a25dd73d52af0d73e3d8e9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:22:53 +0800 Subject: [PATCH 106/150] Fix CA template list and table side filter when ID/GUID provided --- .../Conditional/Invoke-ListCAtemplates.ps1 | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 index 769a3e7c43b8a..7c08fb3741ed4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 @@ -9,7 +9,7 @@ function Invoke-ListCAtemplates { #> [CmdletBinding()] param($Request, $TriggerMetadata) - Write-Host $Request.query.id + $GUID = $Request.query.id ?? $Request.query.ID ?? $Request.query.guid ?? $Request.query.GUID #Migrating old policies whenever you do a list $Table = Get-CippTable -tablename 'templates' $Imported = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'settings'" @@ -31,10 +31,10 @@ function Invoke-ListCAtemplates { } #List new policies $Table = Get-CippTable -tablename 'templates' - $Filter = "PartitionKey eq 'CATemplate'" - $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) if ($Request.query.mode -eq 'Tag') { + $Filter = "PartitionKey eq 'CATemplate'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) #when the mode is tag, show all the potential tags, return the object with: label: tag, value: tag, count: number of templates with that tag, unique only $Templates = @($RawTemplates | Where-Object { $_.Package } | Group-Object -Property Package | ForEach-Object { $package = $_.Name @@ -59,6 +59,14 @@ function Invoke-ListCAtemplates { } } | Sort-Object -Property label) } else { + if ($GUID) { + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid + $Filter = "PartitionKey eq 'CATemplate' and GUID eq '$SafeGUID'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + } + else { + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate'") + } $Templates = $RawTemplates | ForEach-Object { try { $row = $_ @@ -74,8 +82,6 @@ function Invoke-ListCAtemplates { } | Sort-Object -Property displayName } - if ($Request.query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.id } - $Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100 return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK From 20eb3d65cbc081e574eb580af74504d220a62945 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:27:52 +0800 Subject: [PATCH 107/150] Remove standards rows from table when the template is deleted --- .../Standards/Invoke-RemoveStandardTemplate.ps1 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 index 94444123db7f2..8302317f89a53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 @@ -35,11 +35,26 @@ function Invoke-RemoveStandardTemplate { Remove-AzDataTableEntity -Force @ScheduledTasksTable -Entity $DriftTask Write-LogMessage -Headers $Headers -API $APIName -message "Removed drift remediation scheduled task: $($DriftTask.Name)" -Sev Info } + $StandardsReportsTable = Get-CIPPTable -TableName 'CippStandardsReports' + $RemovedTemplateIds = @(@($Entities.RowKey) + $ID | Where-Object { $_ } | Select-Object -Unique) + $OrphanedReports = [System.Collections.Generic.List[object]]::new() + foreach ($RemovedTemplateId in $RemovedTemplateIds) { + $SafeTemplateId = ConvertTo-CIPPODataFilterValue -Value $RemovedTemplateId -Type Guid + $Rows = Get-CIPPAzDataTableEntity @StandardsReportsTable -Filter "TemplateId eq '$SafeTemplateId'" + foreach ($Row in $Rows) { $OrphanedReports.Add($Row) } + } + if ($OrphanedReports.Count -gt 0) { + Remove-AzDataTableEntity -Force @StandardsReportsTable -Entity @($OrphanedReports) + Write-LogMessage -Headers $Headers -API $APIName -message "Removed $($OrphanedReports.Count) orphaned standards comparison row(s) for template id: $($ID)" -Sev Info + } $Result = "Removed Standards Template named: '$($TemplateName)' with id: $($ID)" if ($DriftTasks) { $Result += ". Also removed $(@($DriftTasks).Count) associated drift remediation scheduled task(s)." } + if ($OrphanedReports.Count -gt 0) { + $Result += " Cleaned up $($OrphanedReports.Count) orphaned standards comparison row(s)." + } Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK } catch { From bbd52dca39883db7e2751a25d665d67186e4f446 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:34:01 +0800 Subject: [PATCH 108/150] Permission update fixes --- .../GraphHelper/Get-CippSamPermissions.ps1 | 76 +++++++--- .../SAMManifest/Update-CippSamPermissions.ps1 | 131 ++++++++---------- .../Settings/Invoke-ExecPermissionRepair.ps1 | 23 ++- 3 files changed, 132 insertions(+), 98 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 39b3e22721501..6408240f54042 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -12,9 +12,11 @@ function Get-CippSamPermissions { The effective set returned in .Permissions is therefore always manifest ∪ extras. Each permission is annotated with a 'required' boolean so the UI can lock the manifest-defined defaults. - Unless -NoDiff is used, the function also pulls the live CIPP-SAM application registration from the - partner tenant and diffs its requiredResourceAccess against the effective set, surfacing - permissions that need to be added to (MissingPermissions) and removed from (PartnerAppDiff) the app. + Unless -NoDiff is used, the function also reads what is actually granted on the CIPP-SAM enterprise + application (service principal) in the partner tenant - appRoleAssignments (application/Role) and + oauth2PermissionGrants (delegated/Scope) - and diffs those grants against the effective set, + surfacing permissions that need to be granted (MissingPermissions) and grants that are present but + not in the effective set (PartnerAppDiff). The app registration's requiredResourceAccess is not used. .EXAMPLE Get-CippSamPermissions @@ -195,38 +197,68 @@ function Get-CippSamPermissions { } } - # Diff the effective set against the live CIPP-SAM application registration in the partner tenant. - # MissingPermissions = effective perms not yet on the app (need to be added). - # PartnerAppDiff also surfaces extra perms on the app that are not in the effective set (need to be removed). + # Diff the effective set against what is actually GRANTED on the partner CIPP-SAM enterprise + # application (service principal): appRoleAssignments for application (Role) permissions and + # oauth2PermissionGrants for delegated (Scope) permissions. The app registration's + # requiredResourceAccess is intentionally NOT used - permissions are applied as SP grants, so the + # grants are the real source of truth for what the app can do. + # MissingPermissions = effective perms not yet granted on the SP (need to be added). + # PartnerAppDiff also surfaces extra grants on the SP that are not in the effective set. $MissingPermissions = @{} $PartnerAppDiff = @{} if (!$NoDiff.IsPresent) { try { - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true + $PartnerSP = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$($env:ApplicationID)')?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true + $AppRoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/appRoleAssignments?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + $OAuthGrants = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/oauth2PermissionGrants?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + + # Grants reference the resource SP's object id; map it back to the resource appId the + # effective set is keyed on. Use $UsedServicePrincipals - it carries both id and appId + # ($ServicePrincipals is selected without id, so its .id is null). + $ResourceIdToAppId = @{} + foreach ($SP in $UsedServicePrincipals) { if ($SP.id) { $ResourceIdToAppId[$SP.id] = $SP.appId } } + + # Granted application roles (GUIDs) per resource appId. + $GrantedRoleIdsByApp = @{} + foreach ($Assignment in $AppRoleAssignments) { + $ResAppId = $ResourceIdToAppId[$Assignment.resourceId] + if (!$ResAppId -or !$Assignment.appRoleId) { continue } + if (-not $GrantedRoleIdsByApp.ContainsKey($ResAppId)) { $GrantedRoleIdsByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + $GrantedRoleIdsByApp[$ResAppId].Add([string]$Assignment.appRoleId) + } + + # Granted delegated scope NAMES per resource appId (oauth2 grants store space-delimited names). + $GrantedScopesByApp = @{} + foreach ($Grant in $OAuthGrants) { + $ResAppId = $ResourceIdToAppId[$Grant.resourceId] + if (!$ResAppId) { continue } + if (-not $GrantedScopesByApp.ContainsKey($ResAppId)) { $GrantedScopesByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + foreach ($ScopeName in @(($Grant.scope -split ' ') | Where-Object { $_ })) { $GrantedScopesByApp[$ResAppId].Add($ScopeName) } + } + foreach ($AppId in $AllAppIds) { $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $AppRegResource = $PartnerApp.requiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId - $AppRegRoleIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Role' }).id) - $AppRegScopeIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Scope' }).id) + $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ }) + $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ }) - # Only GUID-based permissions live in the app registration's requiredResourceAccess. - # String-named scopes (e.g. the .Sdp AdditionalPermissions) are applied as direct grants, - # so excluding them here avoids permanent false-positive "missing" entries. + # Application (Role) permissions compare by GUID against appRoleAssignments. $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) - $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions | Where-Object { $_.id -match $GuidRegex }) + # Delegated (Scope) permissions compare by NAME (value) against oauth2 grant scopes - + # this covers both GUID-resolved scopes and the string-named AdditionalPermissions. + $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions) $EffAppIds = @($EffApp.id) - $EffDelIds = @($EffDel.id) + $EffDelNames = @($EffDel.value) - $MissingApp = @(foreach ($Permission in $EffApp) { if ($AppRegRoleIds -notcontains $Permission.id) { $Permission } }) - $MissingDel = @(foreach ($Permission in $EffDel) { if ($AppRegScopeIds -notcontains $Permission.id) { $Permission } }) - $ExtraApp = @(foreach ($Id in $AppRegRoleIds) { + $MissingApp = @(foreach ($Permission in $EffApp) { if ($GrantedRoleIds -notcontains $Permission.id) { $Permission } }) + $MissingDel = @(foreach ($Permission in $EffDel) { if ($Permission.value -and $GrantedScopeNames -notcontains $Permission.value) { $Permission } }) + $ExtraApp = @(foreach ($Id in ($GrantedRoleIds | Sort-Object -Unique)) { if ($EffAppIds -notcontains $Id) { [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } } }) - $ExtraDel = @(foreach ($Id in $AppRegScopeIds) { - if ($EffDelIds -notcontains $Id) { - [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Id).value) ?? $Id } + $ExtraDel = @(foreach ($Name in ($GrantedScopeNames | Sort-Object -Unique)) { + if ($EffDelNames -notcontains $Name) { + [PSCustomObject]@{ id = $Name; value = $Name } } }) @@ -246,7 +278,7 @@ function Get-CippSamPermissions { } } } catch { - Write-Information "Failed to retrieve partner app registration for permission diff: $($_.Exception.Message)" + Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index 226e9b9945422..7ed67f5be3111 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,15 +1,19 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Repairs the CIPP-SAM app registration permissions in the partner tenant. + Reconciles the saved CIPP-SAM additional-permission set in the AppPermissions table. .DESCRIPTION - Diffs the effective CIPP-SAM permission set (manifest defaults + saved extras) against the live - CIPP-SAM application registration in the partner tenant and ADDS any missing permissions to the - app registration's requiredResourceAccess. This is additive only: it never removes permissions, - so it cannot strip a legitimately-configured entry. Extra permissions found on the app that are - not part of the effective set are reported back so an admin can review/remove them manually. + The SAM manifest is the immutable permission base and is always layered in at read time by + Get-CippSamPermissions, so the AppPermissions table only ever needs to hold the EXTRA + permissions an admin layered on top. This function keeps that row clean: it drops any saved + entries the manifest now covers (e.g. legacy rows that stored the full manifest+extras set) + so the table stays "extras only". - Pushing these permissions out to customer tenants is handled separately by the CPV refresh. + It deliberately does NOT write the partner CIPP-SAM app registration's requiredResourceAccess. + Permissions reach the CIPP-SAM service principal(s) - partner and clients - through the grant + flow (Add-CIPPApplicationPermission / Add-CIPPDelegatedPermission, which read this table), not + through the app registration. Refreshing those grants is handled by the caller + (Invoke-ExecPermissionRepair for the partner, the per-tenant permission refresh for clients). .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -22,87 +26,70 @@ function Update-CippSamPermissions { ) try { - $CurrentPermissions = Get-CippSamPermissions - $PartnerAppDiff = $CurrentPermissions.PartnerAppDiff - $MissingPermissions = $CurrentPermissions.MissingPermissions + # Manifest base - always-required permissions that are layered in at read time, so they never + # need to live in the saved extras row. + $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions - $MissingAppIds = @($MissingPermissions.PSObject.Properties.Name) - $ExtraAppIds = @($PartnerAppDiff.PSObject.Properties.Name | Where-Object { - ($PartnerAppDiff.$_.extraApplicationPermissions | Measure-Object).Count -gt 0 -or - ($PartnerAppDiff.$_.extraDelegatedPermissions | Measure-Object).Count -gt 0 - }) - - if ($MissingAppIds.Count -eq 0) { - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - return "No missing permissions to add. The following extra permissions are present on the app and should be reviewed/removed manually: $($ExtraSummary -join '; ')" - } - return 'No permissions to update' + $Table = Get-CIPPTable -TableName 'AppPermissions' + $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if (-not $SavedRow.Permissions) { + return 'No additional permissions saved. CIPP default (manifest) permissions are always applied.' } - # Retrieve the live CIPP-SAM application registration in the partner tenant. - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=id,requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true - - $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Resource in $PartnerApp.requiredResourceAccess) { - $ResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Access in $Resource.resourceAccess) { - $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) - } - $RequiredResourceAccess.Add([PSCustomObject]@{ - resourceAppId = $Resource.resourceAppId - resourceAccess = $ResourceAccess - }) + try { + $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop + } catch { + return 'Saved additional permissions could not be parsed; nothing to reconcile.' } - $AddedPermissions = [System.Collections.Generic.List[string]]::new() - foreach ($AppId in $MissingAppIds) { - $Resource = $RequiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId | Select-Object -First 1 - if (!$Resource) { - $Resource = [PSCustomObject]@{ - resourceAppId = $AppId - resourceAccess = [System.Collections.Generic.List[object]]::new() + # Keep only the entries the manifest does NOT already cover. + $Extras = @{} + $RemovedCount = 0 + foreach ($AppId in $Saved.PSObject.Properties.Name) { + $ManifestApp = $ManifestPermissions.$AppId + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $ExtraApp = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $ExtraApp.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } - $RequiredResourceAccess.Add($Resource) } - $ExistingIds = @($Resource.resourceAccess.id) - - foreach ($Permission in $MissingPermissions.$AppId.applicationPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Role' }) - $AddedPermissions.Add("$($Permission.value) (Application)") + $ExtraDel = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $ExtraDel.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } } - foreach ($Permission in $MissingPermissions.$AppId.delegatedPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Scope' }) - $AddedPermissions.Add("$($Permission.value) (Delegated)") + + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $Extras.$AppId = @{ + applicationPermissions = @($ExtraApp) + delegatedPermissions = @($ExtraDel) } } } - if ($AddedPermissions.Count -eq 0) { - return 'No permissions to update' + if ($RemovedCount -eq 0) { + return 'Saved additional permissions already reconciled; no manifest-covered entries to remove.' } - $PatchBody = @{ requiredResourceAccess = @($RequiredResourceAccess) } | ConvertTo-Json -Depth 10 -Compress - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/applications/$($PartnerApp.id)" -tenantid $env:TenantID -body $PatchBody -type PATCH -NoAuthCheck $true - - Write-LogMessage -API 'UpdateCippSamPermissions' -message "CIPP-SAM app registration permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Added = $AddedPermissions } - - $Result = "Added $($AddedPermissions.Count) missing permission(s) to the CIPP-SAM app registration: $($AddedPermissions -join ', '). Run a CPV refresh to apply these to customer tenants." - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - $Result += " Extra permissions present on the app that should be reviewed/removed manually: $($ExtraSummary -join '; ')." + $Entity = @{ + 'PartitionKey' = 'CIPP-SAM' + 'RowKey' = 'CIPP-SAM' + 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'UpdatedBy' = $UpdatedBy } - return $Result + $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + $Plural = if ($RemovedCount -eq 1) { 'entry' } else { 'entries' } + return "Reconciled saved additional permissions: removed $RemovedCount $Plural now covered by the CIPP manifest." } catch { - throw "Failed to update permissions: $($_.Exception.Message)" + throw "Failed to reconcile permissions: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 513e0bd5aca07..8cb5eb1cda8ef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -1,9 +1,13 @@ function Invoke-ExecPermissionRepair { <# .SYNOPSIS - This endpoint will update the CIPP-SAM app permissions. + Reconciles the CIPP-SAM permissions and re-applies them to the partner service principal. .DESCRIPTION - Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM. + Reconciles the saved additional-permission set (Update-CippSamPermissions), then refreshes the + grants on the CIPP-SAM service principal in the PARTNER tenant so the current effective set + (manifest + extras) is consented. This never writes the app registration's requiredResourceAccess; + permissions are applied as service-principal grants, the same way the routine refresh does. + Client tenants pick up the same effective set through their own permission refresh. .FUNCTIONALITY Entrypoint .ROLE @@ -14,8 +18,19 @@ function Invoke-ExecPermissionRepair { try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') - $Body = @{'Results' = $Result } + $UpdatedBy = $User.UserDetails ?? 'CIPP-API' + + # 1) Reconcile the saved extras table (no app-registration write). + $TableResult = Update-CippSamPermissions -UpdatedBy $UpdatedBy + + # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set + # (manifest + extras, read from the table) is actually consented on the SP. + $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + + $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } + Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } + $Body = @{'Results' = ($Results -join [Environment]::NewLine) } } catch { $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" From d24f333f84b60b841a55c6a9d7a39ee05b1f8db0 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 29 Jun 2026 13:15:30 -0400 Subject: [PATCH 109/150] fix: token timer secret cleanup --- .../Start-UpdateTokensTimer.ps1 | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 037203bb99520..0a616087ae0fa 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -42,6 +42,7 @@ function Start-UpdateTokensTimer { $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + $PasswordCredentials = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending try { $AppPolicyStatus = Update-AppManagementPolicy @@ -60,24 +61,39 @@ function Start-UpdateTokensTimer { } if ($AppSecret) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Table = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - $Secret.ApplicationSecret = $AppSecret.secretText - Add-AzDataTableEntity @Table -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $Secret.ApplicationSecret = $AppSecret.secretText + Add-AzDataTableEntity @Table -Entity $Secret -Force + } else { + Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + } + Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' + } catch { + # Storing the new secret failed. It exists on the app registration but not where CIPP reads + # it, and as the newest credential it would suppress regeneration on the next run - leaving + # the stored secret permanently stale. Roll the new secret back off the app registration so + # state stays consistent and regeneration is retried on the next run. + Write-LogMessage -API 'Update Tokens' -message "Failed to store new application secret for $AppId. Rolling back the generated secret, see Log Data for details. Will try again in 7 days." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + try { + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($AppSecret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Rolled back unstored application secret with keyId $($AppSecret.keyId)." + } catch { + Write-LogMessage -API 'Update Tokens' -message "Failed to roll back unstored application secret with keyId $($AppSecret.keyId) for $AppId, see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + $AppSecret = $null } - Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' } # Clean up expired application secrets - $ExpiredSecrets = $PasswordCredentials.passwordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } + $ExpiredSecrets = $PasswordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } if ($ExpiredSecrets.Count -gt 0) { Write-Information "Found $($ExpiredSecrets.Count) expired application secrets for $AppId. Removing them." foreach ($Secret in $ExpiredSecrets) { try { - New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($PasswordCredentials.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop Write-Information "Removed expired application secret with keyId $($Secret.keyId)." } catch { Write-LogMessage -API 'Update Tokens' -message "Error removing expired application secret with keyId $($Secret.keyId), see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) From d30b2f4c4bac42cef87f077fea625c0fef47bf1a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 29 Jun 2026 13:15:44 -0400 Subject: [PATCH 110/150] fix: remove execsamsetup, no longer used --- .../CIPP/Setup/Invoke-ExecSAMSetup.ps1 | 236 ------------------ 1 file changed, 236 deletions(-) delete mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 deleted file mode 100644 index 76fe86b3af932..0000000000000 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 +++ /dev/null @@ -1,236 +0,0 @@ -function Invoke-ExecSAMSetup { - <# - .FUNCTIONALITY - Entrypoint,AnyTenant - .ROLE - CIPP.AppSettings.ReadWrite - .LEGACY - This function is a legacy function that was used to set up the CIPP application in Azure AD. It is not used in the current version of CIPP, look at Invoke-ExecCreateSAMApp for the new version. - #> - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] - [CmdletBinding()] - param($Request, $TriggerMetadata) - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{ message = 'This endpoint is no longer used. Please use the CreateSAMApp endpoint instead.' } - }) - - if ($Request.Query.error) { - Add-Type -AssemblyName System.Web - return ([HttpResponseContext]@{ - ContentType = 'text/html' - StatusCode = [HttpStatusCode]::Forbidden - Body = Get-normalizedError -Message [System.Web.HttpUtility]::UrlDecode($Request.Query.error_description) - }) - exit - } - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - if (!$Secret) { - $Secret = [PSCustomObject]@{ - 'PartitionKey' = 'Secret' - 'RowKey' = 'Secret' - 'TenantId' = '' - 'RefreshToken' = '' - 'ApplicationId' = '' - 'ApplicationSecret' = '' - } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } - } - if (!$env:SetFromProfile) { - Write-Information "We're reloading from KV" - Get-CIPPAuthentication - } - - $KV = $env:WEBSITE_DEPLOYMENT_ID - $Table = Get-CIPPTable -TableName SAMWizard - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - - try { - if ($Request.Query.count -lt 1 ) { $Results = 'No authentication code found. Please go back to the wizard.' } - - if ($Request.Body.setkeys) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - if ($Request.Body.TenantId) { $Secret.TenantId = $Request.Body.tenantid } - if ($Request.Body.RefreshToken) { $Secret.RefreshToken = $Request.Body.RefreshToken } - if ($Request.Body.applicationid) { $Secret.ApplicationId = $Request.Body.ApplicationId } - if ($Request.Body.ApplicationSecret) { $Secret.ApplicationSecret = $Request.Body.ApplicationSecret } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - if ($Request.Body.tenantid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $Request.Body.tenantid -AsPlainText -Force) } - if ($Request.Body.RefreshToken) { Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Request.Body.RefreshToken -AsPlainText -Force) } - if ($Request.Body.applicationid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationid -AsPlainText -Force) } - if ($Request.Body.applicationsecret) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationsecret -AsPlainText -Force) } - } - - $Results = @{ Results = 'The keys have been replaced. Please perform a permissions check.' } - } - if ($Request.Query.error -eq 'invalid_client') { $Results = 'Client ID was not found in Azure. Try waiting 10 seconds to try again, if you have gotten this error after 5 minutes, please restart the process.' } - if ($Request.Query.code) { - try { - $TenantId = $Rows.tenantid - if (!$TenantId -or $TenantId -eq 'NotStarted') { $TenantId = $env:TenantID } - $AppID = $Rows.appid - if (!$AppID -or $AppID -eq 'NotStarted') { $appid = $env:ApplicationID } - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $clientsecret = $Secret.ApplicationSecret - } else { - $clientsecret = Get-CippKeyVaultSecret -VaultName $kv -Name 'ApplicationSecret' -AsPlainText - } - if (!$clientsecret) { $clientsecret = $env:ApplicationSecret } - Write-Information "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" #-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - $RefreshToken = Invoke-RestMethod -Method POST -Body "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.RefreshToken = $RefreshToken.refresh_token - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $RefreshToken.refresh_token -AsPlainText -Force) - } - - $Results = 'Authentication is now complete. You may now close this window.' - try { - $SetupPhase = $rows.validated = $true - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - } catch { - #no need. - } - } catch { - $Results = "Authentication failed. $($_.Exception.message)" - } - } - if ($Request.Query.CreateSAM) { - $Rows = @{ - RowKey = 'setup' - PartitionKey = 'setup' - validated = $false - SamSetup = 'NotStarted' - partnersetup = $true - appid = 'NotStarted' - tenantid = 'NotStarted' - } - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - $step = 1 - $DeviceLogon = New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -FirstLogon - $SetupPhase = $rows.SamSetup = [string]($DeviceLogon | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Results = @{ code = $($DeviceLogon.user_code); message = "Your code is $($DeviceLogon.user_code). Enter the code" ; step = $step; url = $DeviceLogon.verification_uri } - } - if ($Request.Query.CheckSetupProcess -and $Request.Query.step -eq 1) { - $SAMSetup = $Rows.SamSetup | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($SamSetup.token_type -eq 'Bearer') { - #sleeping for 10 seconds to allow the token to be created. - Start-Sleep 10 - #nulling the token to force a recheck. - $step = 2 - } - $Token = (New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -device_code $SAMSetup.device_code) - Write-Information "Token is $($token | ConvertTo-Json)" - if ($Token.access_token) { - $step = 2 - $rows.SamSetup = [string]($Token | ConvertTo-Json) - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $PartnerSetup = $true - $TenantId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/organization' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method GET -ContentType 'application/json').value.id - $SetupPhase = $rows.tenantid = [string]($TenantId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - if ($PartnerSetup) { - #$app = Get-Content '.\Cache_SAMSetup\SAMManifest.json' | ConvertFrom-Json - $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') - $app = Get-Content $SamManifestFile.FullName | ConvertFrom-Json - - $App.web.redirectUris = @($App.web.redirectUris + $URL) - $app = $app | ConvertTo-Json -Depth 15 - $AppId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/applications' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body $app -ContentType 'application/json') - $rows.appid = [string]($AppId.appId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $attempt = 0 - do { - try { - try { - $SPNDefender = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fc780465-2017-40d4-a0c5-307022471b92`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for defender, probably already there." - } - try { - $SPNTeams = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"48ac35b8-9aa8-4d74-927d-1f4a14a0b239`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for Teams, probably already there." - } - try { - $SPNO365Manage = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"c5393580-f805-4401-95e8-94b7a6ef2fc2`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for O365 Management, probably already there." - } - try { - $SPNPartnerCenter = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for PartnerCenter, probably already there." - } - $SPN = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"$($AppId.appId)`" }" -ContentType 'application/json') - Start-Sleep 3 - $attempt ++ - } catch { - $attempt ++ - } - } until ($attempt -gt 5) - } - $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.TenantId = $TenantId - $Secret.ApplicationId = $AppId.appId - $Secret.ApplicationSecret = $AppPassword - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - Write-Information ($Secret | ConvertTo-Json -Depth 5) - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $TenantId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Appid.appId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $AppPassword -AsPlainText -Force) - } - $Results = @{'message' = 'Created application. Waiting 30 seconds for Azure propagation'; step = $step } - } else { - $step = 1 - $Results = @{ code = $($SAMSetup.user_code); message = "Your code is $($SAMSetup.user_code). Enter the code " ; step = $step; url = $SAMSetup.verification_uri } - } - - } - switch ($Request.Query.step) { - 2 { - $step = 2 - $TenantId = $Rows.tenantid - $AppID = $rows.appid - $PartnerSetup = $true - $SetupPhase = $rows.SamSetup = [string]($FirstLogonRefreshtoken | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $Validated = $Rows.validated - if ($Validated) { $step = 3 } - $Results = @{ appId = $AppID; message = 'Give the next approval by clicking ' ; step = $step; url = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?scope=https://graph.microsoft.com/.default+offline_access+openid+profile&response_type=code&client_id=$($appid)&redirect_uri=$($url)" } - } - 3 { - $step = 4 - $Results = @{'message' = 'Received token.'; step = $step } - } - 4 { - Remove-AzDataTableEntity -Force @Table -Entity $Rows - $step = 5 - $Results = @{'message' = 'setup completed.'; step = $step - } - } - } - - } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.message)" ; step = $step } - } - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results - }) - -} From 0b72ea50b0745522bf5062f96c87e028cfd576f1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:12:35 +0800 Subject: [PATCH 111/150] Update Compare-CIPPIntuneObject.ps1 --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index fc9902d80823f..da6a537fc21fc 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -173,11 +173,12 @@ function Compare-CIPPIntuneObject { if (ShouldCompareAsUnorderedSet -PropertyPath $PropertyPath) { # For unordered sets, compare contents regardless of order if ($Object1.Count -ne $Object2.Count) { - # Different lengths - report the difference + # Different lengths - report the actual values so a technician + # can see exactly what differs and decide on the action. $result.Add([PSCustomObject]@{ Property = $PropertyPath - ExpectedValue = "Array with $($Object1.Count) items" - ReceivedValue = "Array with $($Object2.Count) items" + ExpectedValue = ($Object1 -join ', ') + ReceivedValue = ($Object2 -join ', ') }) } else { # Same length - check if all items exist in both arrays From 3ce368b674ce4d879873826552f8f64047c49aaa Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:00:00 +0800 Subject: [PATCH 112/150] Manual Audit Log Search Fixes --- .../Add-CippAuditLogCoverageManualEntry.ps1 | 74 +++++++++++++++++++ .../AuditLogs/New-CippAuditLogSearch.ps1 | 7 ++ .../Alerts/Invoke-ExecAuditLogSearch.ps1 | 5 ++ 3 files changed, 86 insertions(+) create mode 100644 Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 diff --git a/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 new file mode 100644 index 0000000000000..cf79d871408c6 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 @@ -0,0 +1,74 @@ +function Add-CippAuditLogCoverageManualEntry { + <# + .SYNOPSIS + Bridge a manually-created audit log search into the V2 AuditLogCoverage ledger so the + pipeline downloads and processes it automatically (Option B). + .DESCRIPTION + Manual searches live in the AuditLogSearches table, which the (now V2-only) pipeline no + longer scans. When alert processing is requested for a manual search, this writes a ledger + row keyed 'MANUAL-' in State 'Created' so Start-AuditLogIngestionV2 / + Push-AuditLogDownloadV2 poll, download and process it like any other search - it inherits + retries, the orphan sweep, SearchStatus tracking and the coverage UI. + + The RowKey prefix keeps these out of the window planner: Get-CippAuditLogPlannedWindows only + considers 14-digit RowKeys and Get-CippAuditLogReconciliationWindows only 'RECON-*', so a + 'MANUAL-*' row is never treated as a window, gap or reconciliation block. Type 'Manual' lets + the UI exclude them from the window heatmap/charts. + + Idempotent (UpsertMerge): re-queuing the same search resets State to 'Created' to reprocess. + .PARAMETER TenantFilter + Tenant default domain (becomes the ledger PartitionKey). + .PARAMETER SearchId + The Graph audit-log search id. + .PARAMETER StartTime + Search start (datetime / DateTimeOffset / ISO string). Stored as WindowStart. + .PARAMETER EndTime + Search end. Stored as WindowEnd. + .PARAMETER SearchStatus + Graph search status at creation (e.g. notStarted); refreshed on each poll. + .PARAMETER TenantId + Tenant customerId. Resolved from TenantFilter if not supplied. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$SearchId, + $StartTime, + $EndTime, + [string]$SearchStatus, + [string]$TenantId + ) + + try { + if (-not $TenantId) { + try { $TenantId = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 -ExpandProperty customerId } catch {} + } + + $Now = (Get-Date).ToUniversalTime() + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Entity = @{ + PartitionKey = [string]$TenantFilter + RowKey = 'MANUAL-' + [string]$SearchId + TenantId = [string]$TenantId + Type = 'Manual' + State = 'Created' + SearchId = [string]$SearchId + SearchStatus = [string]$SearchStatus + Attempts = 0 + RetryCount = 0 + ThrottleCount = 0 + CreatedUtc = $Now + LastPolledUtc = $Now + LastError = '' + } + if ($StartTime) { try { $Entity.WindowStart = ([datetimeoffset]$StartTime).UtcDateTime } catch {} } + if ($EndTime) { try { $Entity.WindowEnd = ([datetimeoffset]$EndTime).UtcDateTime } catch {} } + + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -OperationType UpsertMerge + Write-Information "AuditLogV2: bridged manual search $SearchId for $TenantFilter into coverage ledger (MANUAL-$SearchId)" + } catch { + Write-Information ('Add-CippAuditLogCoverageManualEntry error for {0} / {1}: {2}' -f $TenantFilter, $SearchId, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index befd7cd164966..94e2cd35cfbf9 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -269,6 +269,13 @@ function New-CippAuditLogSearch { } $Table = Get-CIPPTable -TableName 'AuditLogSearches' Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + + # When alert processing is requested, bridge the search into the V2 AuditLogCoverage + # ledger so the pipeline downloads + processes it automatically (the V2 pipeline does + # not scan the AuditLogSearches table). + if ($ProcessLogs.IsPresent) { + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $Query.id -StartTime $StartTime -EndTime $EndTime -SearchStatus $Query.status + } } return $Query diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 index 56cc814a95fbc..7956b8e2902c3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 @@ -50,6 +50,11 @@ function Invoke-ExecAuditLogSearch { Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + # Bridge into the V2 AuditLogCoverage ledger so the pipeline picks it up automatically + # (re-queuing resets it to State 'Created' to reprocess). + $ManualStatus = if ($Search) { [string]$Search.status } else { '' } + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $SearchId -StartTime $Entity.StartTime -EndTime $Entity.EndTime -SearchStatus $ManualStatus + $DisplayName = $Entity.DisplayName Write-LogMessage -headers $Headers -API $APIName -message "Queued search for processing: $($Search.displayName)" -Sev 'Info' -tenant $TenantFilter From 034366b4e0b29d1164e59954494354c6fc56faca Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:41:56 +0800 Subject: [PATCH 113/150] Fixes for array value business phone number in templates --- .../Users/Invoke-AddUserDefaults.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 04bb3c7a4fad5..dd9793aa4c126 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -73,13 +73,13 @@ function Invoke-AddUserDefaults { # Contact fields $MobilePhone = $Request.Body.mobilePhone - $BusinessPhones = if ($null -ne $Request.Body.businessPhones) { - if ($Request.Body.businessPhones -is [array]) { $Request.Body.businessPhones[0] } else { $Request.Body.businessPhones } - } elseif ($null -ne $Request.Body.'businessPhones[0]') { - $Request.Body.'businessPhones[0]' - } else { - $null - } + $BusinessPhones = @( + if ($null -ne $Request.Body.businessPhones) { + $Request.Body.businessPhones | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } elseif ($null -ne $Request.Body.'businessPhones[0]') { + $Request.Body.'businessPhones[0]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + ) $OtherMails = $Request.Body.otherMails # User relations From 6089106327bd8e520eb7d59f84d2067656e27a09 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:39:58 +0800 Subject: [PATCH 114/150] Fix for adding groups failing for sec groups --- .../Identity/Administration/Users/Invoke-EditUser.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index e5f9f1fc482c6..af7659da83c61 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -187,12 +187,13 @@ function Invoke-EditUser { $AddToGroups | ForEach-Object { $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { Write-Host 'Adding to group via Add-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true @@ -219,12 +220,13 @@ function Invoke-EditUser { $RemoveFromGroups | ForEach-Object { $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { Write-Host 'Removing From group via Remove-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true From 6205e97c2cbf4dfb58f20878dd3b76b1b4169b69 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:46:30 +0200 Subject: [PATCH 115/150] fix: modernize and improve logging --- .../Invoke-ExecDeleteGDAPRelationship.ps1 | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 index 8c0898c318ef9..d0f4ed0b234cd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecDeleteGDAPRelationship { +function Invoke-ExecDeleteGDAPRelationship { <# .FUNCTIONALITY Entrypoint,AnyTenant @@ -13,19 +13,23 @@ Function Invoke-ExecDeleteGDAPRelationship { # Interact with query parameters or the body of the request. - $GDAPID = $Request.Query.GDAPId ?? $Request.Body.GDAPId + $GDAPId = $Request.Query.GDAPId ?? $Request.Body.GDAPId try { - $DELETE = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPID)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID - $Results = [pscustomobject]@{'Results' = "Success. GDAP relationship for $($GDAPID) been revoked" } - Write-LogMessage -headers $Headers -API $APIName -message "Success. GDAP relationship for $($GDAPID) been revoked" -Sev 'Info' + $null = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPId)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID + $Result = "Success. GDAP relationship for $($GDAPId) been revoked" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to revoke GDAP relationship for $($GDAPId). Error: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } }) } From 7313bd9dc9fec80fbf1be225968bc55238dc5fa6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:55:31 +0800 Subject: [PATCH 116/150] Update CIPPSharp --- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 44544 -> 44544 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 9f352615961ffbce098cba92da1f221d9bf52536..c6c5b41d02c2e4e84e2bb8faae1154cc0ebfada8 100644 GIT binary patch delta 170 zcmZp;!_;txX+j5!e(aCg8+$fP5m;d=d4Rj@yj$DsoMzD=9*507Q~&a7m>F1D8XA}x z8<{7iSfrX78Kfqervyh>#x4Z F1OP@(f$GxaaOMpB}AvPEi= zWvZEZYO=ABSz20hVsf%+ikYcNqD87fQfg{Sa*~;G^5(*&`K Date: Tue, 30 Jun 2026 10:11:13 -0400 Subject: [PATCH 117/150] fix: rereun detection logic --- Modules/CIPPCore/Public/Test-CIPPRerun.ps1 | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 index 6a259ea06a9d4..740056e8e8b03 100644 --- a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 @@ -28,9 +28,12 @@ function Test-CIPPRerun { } } - # Use BaseTime if provided, otherwise use current time - $CurrentUnixTime = if ($BaseTime -gt 0) { $BaseTime } else { [int][double]::Parse((Get-Date -UFormat %s)) } - $EstimatedNextRun = $CurrentUnixTime + $EstimatedDifference + # Real wall-clock time, used to decide whether enough time has actually elapsed to allow a rerun. + $Now = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + # Anchor time for *recording* the next run. For scheduled tasks we anchor to the task's + # ScheduledTime (BaseTime) so EstimatedNextRun aligns to the schedule; otherwise use now. + $AnchorTime = if ($BaseTime -gt 0) { $BaseTime } else { $Now } + $EstimatedNextRun = $AnchorTime + $EstimatedDifference try { $Filters = [System.Collections.Generic.List[string]]::new() @@ -63,7 +66,7 @@ function Test-CIPPRerun { if ($NewSettings.Length -ne $PreviousSettings.Length) { Write-Host "$($NewSettings.Length) vs $($PreviousSettings.Length) - settings have changed." $RerunData | Add-Member -MemberType NoteProperty -Name 'EstimatedNextRun' -Value $EstimatedNextRun -Force - $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$CurrentUnixTime" -Force + $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$AnchorTime" -Force $RerunData | Add-Member -MemberType NoteProperty -Name 'Settings' -Value "$($Settings | ConvertTo-Json -Depth 10 -Compress)" -Force Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false # Not a rerun because settings have changed. @@ -79,24 +82,24 @@ function Test-CIPPRerun { Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false } - if ($RerunData.EstimatedNextRun -gt $CurrentUnixTime) { + if ($RerunData.EstimatedNextRun -gt $Now) { Write-LogMessage -API $API -message "$Type rerun detected for $($API). Prevented from running again." -tenant $TenantFilter -headers $Headers -Sev 'Info' return $true } else { $RerunData | Add-Member -MemberType NoteProperty -Name 'EstimatedNextRun' -Value $EstimatedNextRun -Force - $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$BaseTime" -Force + $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$AnchorTime" -Force $RerunData | Add-Member -MemberType NoteProperty -Name 'Settings' -Value "$($Settings | ConvertTo-Json -Depth 10 -Compress)" -Force Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false } } else { - $EstimatedNextRun = $CurrentUnixTime + $EstimatedDifference + $EstimatedNextRun = $AnchorTime + $EstimatedDifference $NewEntity = @{ PartitionKey = "$TenantFilter" RowKey = "$($Type)_$($API)" Settings = "$($Settings | ConvertTo-Json -Depth 10 -Compress)" EstimatedNextRun = $EstimatedNextRun - LastScheduledTime = "$CurrentUnixTime" + LastScheduledTime = "$AnchorTime" } Add-CIPPAzDataTableEntity @RerunTable -Entity $NewEntity -Force return $false From c5a732ddf7f50ab819145f1b2f2cd0c2bd467c5c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:53:32 +0800 Subject: [PATCH 118/150] Update Invoke-AddSpamFilter.ps1 --- .../Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 index c01f5d98d6d32..f998de70ff36e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 @@ -1,7 +1,7 @@ Function Invoke-AddSpamFilter { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Exchange.SpamFilter.ReadWrite #> From 25445c637b8866ea6165dfe6d9a3ca67fd7b4e3c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:20:30 +0800 Subject: [PATCH 119/150] Update Oauth standards with conflict information --- Config/standards.json | 8 ++++---- .../Invoke-CIPPStandardOauthConsent.ps1 | 20 ++++++++++++++++--- .../Invoke-CIPPStandardOauthConsentLowSec.ps1 | 4 ++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 6552e8000429a..f2384c901ae76 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1587,8 +1587,8 @@ "ZTNA21807", "ZTNA21810" ], - "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", - "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", + "helpText": "Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the \"Allow users to consent to applications with low security risk\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the \"Allow users to consent to applications with low security risk\" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Requires administrative approval before employees can grant applications access to company data, preventing unauthorized data sharing and potential security breaches. This protects against malicious applications while allowing approved business tools to function normally.", "addedComponent": [ { @@ -1609,8 +1609,8 @@ "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", "tag": ["IntegratedApps"], - "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", - "docsDescription": "Allows users to consent to applications with low assigned risk.", + "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the \"Require admin consent for applications\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Allows users to consent to applications with low assigned risk. This standard conflicts with the \"Require admin consent for applications (Prevent OAuth phishing)\" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Allows employees to approve low-risk applications without administrative intervention, balancing security with productivity. This provides a middle ground between complete restriction and open access, enabling business agility while maintaining protection against high-risk applications.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", "impact": "Medium Impact", diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 index f986c4446e3c2..c9bc356ccec04 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsent { .SYNOPSIS (Label) Require admin consent for applications (Prevent OAuth phishing) .DESCRIPTION - (Helptext) Disables users from being able to consent to applications, except for those specified in the field below - (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. + (Helptext) Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the "Allow users to consent to applications with low security risk" standard; only one of the two should be assigned per tenant. + (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the "Allow users to consent to applications with low security risk" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards @@ -66,7 +66,13 @@ function Invoke-CIPPStandardOauthConsent { } $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'ManagePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } - if ($Settings.remediate -eq $true) { + $Standards = Get-CIPPStandards -Tenant $tenant + $ConflictingStandard = $Standards | Where-Object -Property Standard -EQ 'OauthConsentLowSec' + + if ($Settings.remediate -eq $true -and $ConflictingStandard -and $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low') { + # A conflicting low security OAuth consent standard is enabled and currently applied. Skip remediation so we don't fight the other standard, but still fall through to alert/report. + Write-LogMessage -API 'Standards' -tenant $tenant -message 'There is a conflicting OAuth Consent policy standard enabled for this tenant. Remove the Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) standard from this tenant to apply the require admin consent standard.' -sev Error + } elseif ($Settings.remediate -eq $true) { $DidRemediationChange = $false try { if (-not $CompareIncludesFetched) { @@ -222,6 +228,14 @@ function Invoke-CIPPStandardOauthConsent { permissionGrantPolicyIdsAssignedToDefaultUserRole = $State.permissionGrantPolicyIdsAssignedToDefaultUserRole includes = $CurrentIncludesForCompare } + # Add conflicting standard info if applicable + if ($ConflictingStandard) { + $CurrentValue.conflictingStandard = @{ + name = $ConflictingStandard.Standard + templateid = $ConflictingStandard.TemplateId + } + } + $ExpectedValue = @{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.cipp-consent-policy') includes = $ExpectedIncludesForCompare diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index bd359c2bba5f4..186f02face965 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsentLowSec { .SYNOPSIS (Label) Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) .DESCRIPTION - (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. - (DocsDescription) Allows users to consent to applications with low assigned risk. + (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the "Require admin consent for applications" standard; only one of the two should be assigned per tenant. + (DocsDescription) Allows users to consent to applications with low assigned risk. This standard conflicts with the "Require admin consent for applications (Prevent OAuth phishing)" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards From e77de9803ec68a12a52e796eb3dd2b8db3e2789b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:04:20 +0800 Subject: [PATCH 120/150] CIPP Rogue App List --- Config/MaliciousApps.json | 1074 +++++++++++++++++ .../Alerts/Get-CIPPAlertHuntressRogueApps.ps1 | 21 +- 2 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 Config/MaliciousApps.json diff --git a/Config/MaliciousApps.json b/Config/MaliciousApps.json new file mode 100644 index 0000000000000..b212e1153a8ea --- /dev/null +++ b/Config/MaliciousApps.json @@ -0,0 +1,1074 @@ +{ + "metadata": { + "name": "CIPP Malicious Applications", + "description": "Curated list of OAuth / Entra ID enterprise applications that have been observed being abused by threat actors against Microsoft 365 tenants. Entries are matched on appId (the Application/Client ID of the service principal that appears in a tenant after consent).", + "permissionTypes": [ + "Delegated", + "Application" + ], + "resources": [ + "Microsoft Graph", + "Office 365 Exchange Online", + "SharePoint" + ], + "categoryDefinitions": { + "Mailbox exfiltration": "Application synchronizes or exports the contents of a user's mailbox, enabling bulk theft of email.", + "Address book exfiltration": "Application harvests contacts or directory information for reconnaissance and follow-on phishing.", + "Sharepoint/OneDrive exfiltration": "Application reads or downloads files from SharePoint Online or OneDrive for Business.", + "Data exfiltration": "Application is used to move data out of the tenant to an attacker-controlled location.", + "Reconnaissance": "Application is used to enumerate users, contacts, or organizational data to identify targets.", + "Phishing": "Application is used to send phishing email or otherwise facilitate phishing campaigns.", + "Persistence": "Application grants the attacker durable, consent-based access that survives a password reset.", + "Business Email Compromise": "Application observed as part of a BEC intrusion (mailbox access, fraudulent email, financial redirection)." + }, + "applicationFields": { + "name": "Display name of the application as it appears on the consent screen / enterprise application.", + "appId": "Application (client) ID of the service principal. Primary key used for detection.", + "description": "What the application is / does.", + "categories": "Controlled-vocabulary classifications (see categoryDefinitions).", + "tags": "Free-form labels.", + "permissions": "Array of { scope, type, resource } granted to or requested by the app.", + "mitreAttackIds": "Relevant MITRE ATT&CK technique IDs.", + "publisher": "Publisher identity { name, id, verified } where known.", + "appOwnerOrganizationId": "Tenant ID that owns the multi-tenant application, where known.", + "references": "Write-ups, threat-intel articles, or official documentation.", + "detection": "Hunting / detection guidance specific to this application.", + "relatedAppIds": "appIds of related entries (e.g. renamed versions of the same app).", + "notes": "Caveats, data-quality flags, or additional context." + } + }, + "applications": [ + { + "name": "CloudSponge", + "appId": "a43e5392-f48b-46a4-a0f1-098b5eeb4757", + "description": "CloudSponge is a software-as-a-service product that imports contacts from all the major address books. It is embedded by websites so that users do not have to type email addresses into referral / invite forms.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "CubeBackup", + "appId": "412445a2-0794-487e-9dd6-d57d9593b249", + "description": "CubeBackup is a self-hosted cloud backup solution that integrates with Microsoft 365, including Exchange Online, SharePoint and OneDrive. With sufficient privileges it can be leveraged by threat actors to conduct automated, mass exfiltration from a victim's environment.", + "categories": [ + "Data exfiltration", + "Mailbox exfiltration", + "Sharepoint/OneDrive exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "Exfiltration" + ], + "permissions": [ + { + "scope": "Calendars.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMessage.Read.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Directory.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Group.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsAppInstallation.ReadWriteForTeam.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "full_access_as_app", + "type": "Application", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "SharePoint" + } + ], + "mitreAttackIds": [ + "T1537", + "T1567", + "T1020" + ], + "publisher": { + "name": "CubeBackup, Inc.", + "id": "cubebackupinc1662619479161", + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.cubebackup.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Edison Mail", + "appId": "62db40a4-2c7e-4373-a609-eda138798962", + "description": "Email client with full Microsoft 365 synchronization capabilities.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "eM Client", + "appId": "e9a7fea1-1cc0-4cd9-a31b-9137ca5deedd", + "description": "eM Client is a desktop email client with full Microsoft Office 365 synchronization. Abused to bulk-synchronize and exfiltrate a compromised mailbox and to maintain access.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise", + "https://cybercorner.tech/malicious-usage-of-em-client-in-business-email-compromise/" + ], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "Fastmail", + "appId": "77468577-4f6e-40e7-b745-11d3d0c28095", + "description": "Fastmail is an alternative email service that allows import/export from various email providers, including Microsoft 365. If a victim consents, all email can be exfiltrated to an attacker-controlled Fastmail account, with the option to continue exfiltrating mail post-consent.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [ + "Persistence", + "Exfiltration" + ], + "permissions": [ + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [ + "T1114.002", + "T1567.002", + "T1136.003", + "T1098.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": false + }, + "appOwnerOrganizationId": "9188040d-6c67-4c5b-b112-36a304b66dad", + "references": [ + "https://x.com/mwaski88/status/1775904383382802502", + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#Fastmail", + "https://www.fastmail.help/hc/en-us/articles/360060590593-Migrate-to-Fastmail-from-another-provider" + ], + "detection": null, + "relatedAppIds": [], + "notes": "appOwnerOrganizationId 9188040d-6c67-4c5b-b112-36a304b66dad is the Microsoft consumer (personal accounts) tenant. Publisher reported as not verified." + }, + { + "name": "Foxmail", + "appId": "231575bc-9f6c-4539-9241-5cfae696b630", + "description": "Foxmail is a desktop email client. Observed consented in a business email compromise with the full set of legacy Exchange protocol scopes, enabling full mailbox synchronization.", + "categories": [ + "Mailbox exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EAS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Horizon Tech", + "appId": "b1c4926a-5fb6-4aad-b920-709c957be148", + "description": "Pulls email and contacts and sends phishing emails from the compromised mailbox.", + "categories": [ + "Mailbox exfiltration", + "Address book exfiltration", + "Phishing", + "Persistence", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "persistence", + "spam", + "phishing", + "email abuse" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1114", + "T1566", + "T1071.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Jotform", + "appId": "9af771d1-1288-43f0-91a6-adadcbd212b5", + "description": "Jotform is an online form and survey builder. Abused as a native phishing / spam vector after Microsoft 365 SSO consent.", + "categories": [ + "Phishing", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "spam", + "phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "261450de-982f-4efb-a5fe-0fc2d9d08717", + "references": [ + "https://www.jotform.com", + "https://www.bleepingcomputer.com/news/security/the-rise-of-native-phishing-microsoft-365-apps-abused-in-attacks/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Mail_Backup", + "appId": "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139", + "description": "Exports mailboxes for backup purposes; used by threat actors to exfiltrate email. This is the renamed successor to PerfectData Software.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "Updated version of PerfectData Software. Unified Audit Log surfaces synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "ff8d92dc-3d82-41d6-bcbd-b9174d163620" + ], + "notes": null + }, + { + "name": "Newsletter Software Supermailer", + "appId": "a245e8c0-b53c-4b67-9b45-751d1dff8e6b", + "description": "Bulk email sending software. Abused to send phishing / spam from a compromised mailbox.", + "categories": [ + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "PerfectData Software", + "appId": "ff8d92dc-3d82-41d6-bcbd-b9174d163620", + "description": "Exports mailboxes for backup purposes. Widely abused for Office 365 business email compromise to bulk-export a victim mailbox.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "The Unified Audit Log initially did not record items synced by this app; it now surfaces them as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139" + ], + "notes": "Renamed/superseded by 'Mail_Backup' (2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139)." + }, + { + "name": "PostBox", + "appId": "179d5108-412b-4c95-8e34-06786784ab39", + "description": "Desktop email client with full Microsoft 365 synchronization capabilities. Abused for mailbox exfiltration and persistence.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "rclone", + "appId": "4761b959-9780-4c2d-87a3-512b4638f767", + "description": "rclone is a command-line program for managing files across cloud storage providers. Abused to bulk-download SharePoint Online / OneDrive content from a compromised tenant.", + "categories": [ + "Data exfiltration", + "Sharepoint/OneDrive exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "Files.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.kroll.com/en/insights/publications/cyber/new-m365-business-email-compromise-attacks-with-rclone" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "SigParser", + "appId": "caffae8c-0882-4c81-9a27-d1803af53a40", + "description": "SigParser scans emails, calendars, address books, spreadsheets and more to automatically generate profiles on the people and companies who have interacted with a business. Abused for address-book / contact harvesting.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://sigparser.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Spike", + "appId": "946c777c-bc85-489e-b034-392389ae23d6", + "description": "Spike is a conversational email client and team-collaboration app. Abused for mailbox exfiltration, persistence and phishing.", + "categories": [ + "Mailbox exfiltration", + "Persistence", + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "People.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://darktrace.com/blog/breakdown-of-a-multi-account-compromise-within-office-365" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Teleforge Directory", + "appId": "1a9b8d93-0d60-4835-896f-83016de95ff5", + "description": "Used for business email compromise. Installed shortly after a breach and after an additional MFA authentication device was added; data was collected for roughly a week, after which fraudulent emails were sent.", + "categories": [ + "Business Email Compromise", + "Mailbox exfiltration" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1119" + ], + "publisher": { + "name": "Unknown (deleted before recorded)", + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "aa68a5d2-2ee2-4ff7-8193-3d037ab704b1", + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "ZoomInfo Communitiez Login", + "appId": "497ac034-5120-4c1a-929a-0351f5c09918", + "description": "Service principal consented to when the 'My Connections' feature within ZoomInfo is used. This feature extracts address-book and other contact information from the victim account, enabling target discovery, exfiltration and targeted phishing.", + "categories": [ + "Address book exfiltration", + "Reconnaissance", + "Phishing", + "Persistence" + ], + "tags": [ + "Discovery", + "Exfiltration", + "Persistence", + "Phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1530", + "T1567", + "T1087.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo" + ], + "detection": null, + "relatedAppIds": [ + "858d7e42-35f0-44b7-9033-df309239a47f" + ], + "notes": null + }, + { + "name": "Zoominfo Login", + "appId": "858d7e42-35f0-44b7-9033-df309239a47f", + "description": "ZoomInfo is a B2B business-intelligence platform with a large database of company and contact information. It can be used via SSO with a Microsoft 365 account, which creates this service principal in the tenant and can be abused for persistence and contact harvesting.", + "categories": [ + "Address book exfiltration", + "Persistence" + ], + "tags": [ + "persistence" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1136.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo", + "https://abnormalsecurity.com/blog/cybercriminals-exploit-b2b-lead-generation-tools" + ], + "detection": null, + "relatedAppIds": [ + "497ac034-5120-4c1a-929a-0351f5c09918" + ], + "notes": null + } + ] +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index cb50fae892f54..ec58292bbae10 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -3,7 +3,7 @@ function Get-CIPPAlertHuntressRogueApps { .SYNOPSIS Check for rogue apps in a Tenant .DESCRIPTION - This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress. + This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress and a CIPP collections of appids. .FUNCTIONALITY Entrypoint .LINK @@ -19,8 +19,23 @@ function Get-CIPPAlertHuntressRogueApps { try { $RogueApps = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' - $RogueAppFilter = $RogueApps.appId -join "','" - $ServicePrincipals = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$filter=appId in ('$RogueAppFilter')" -tenantid $TenantFilter + $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\schemaDefinitions.json') | ConvertFrom-Json).applications.appId + $HuntressRogueApps = $RogueApps.appId + $RogueAppIds = @($CippRogueApps) + @($HuntressRogueApps) | Where-Object { $_ } | Select-Object -Unique + $Requests = for ($i = 0; $i -lt $RogueAppIds.Count; $i += 15) { + $Chunk = $RogueAppIds[$i..([Math]::Min($i + 14, $RogueAppIds.Count - 1))] + @{ + id = [string]$i + method = 'GET' + url = "servicePrincipals?`$filter=appId in ('$($Chunk -join "','")')" + } + } + $Requests = @($Requests) + + $ServicePrincipals = if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter + foreach ($Response in $Responses) { $Response.body.value } + } # If IgnoreDisabledApps is true, filter out disabled service principals if ($InputValue -eq $true) { $ServicePrincipals = $ServicePrincipals | Where-Object { $_.accountEnabled -eq $true } From 0dcb4fb1515b2f3e40e9081a6ad6e8d747a0104b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 12:21:44 -0400 Subject: [PATCH 121/150] fix: hudu sync - replace arraylist with generic list - skip sync on archived companies --- .../Public/Hudu/Invoke-HuduExtensionSync.ps1 | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index f184e81f493e1..803865a5e3687 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -8,7 +8,7 @@ function Invoke-HuduExtensionSync { $TenantFilter ) try { - Connect-HuduAPI -configuration $Configuration + Connect-HuduAPI -configuration $Configuration | Out-Null $Configuration = $Configuration.Hudu $Tenant = Get-Tenants -TenantFilter $TenantFilter -IncludeErrors $CompanyResult = [PSCustomObject]@{ @@ -47,6 +47,18 @@ function Invoke-HuduExtensionSync { # Include mailboxes if needed for Hudu sync $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Tenant.defaultDomainName -IncludeMailboxes $company_id = $TenantMap.IntegrationId + $HuduCompany = Get-HuduCompanies -Id $company_id + if ($HuduCompany.archived -eq $true) { + Write-Host "Company $($HuduCompany.name) is archived. Skipping sync." + $ReturnObject = [PSCustomObject]@{ + Name = $Tenant.displayName + Users = 0 + Devices = 0 + Errors = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + Logs = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + } + return $ReturnObject + } # If tenant not found in mapping table, return error if (!$TenantMap) { @@ -121,7 +133,7 @@ function Invoke-HuduExtensionSync { } $HuduRelations = Get-HuduRelations - [System.Collections.ArrayList]$Links = @( + [System.Collections.Generic.List[object]]$Links = @( @{ Title = 'M365 Admin Portal' URL = 'https://admin.cloud.microsoft?delegatedOrg={0}' -f $Tenant.initialDomainName @@ -153,29 +165,26 @@ function Invoke-HuduExtensionSync { Icon = 'fas fa-server' } ) - if($Configuration.IncludeDefenderLink) - { + if ($Configuration.IncludeDefenderLink) { $Links.Add(@{ - Title = 'Defender Portal' - URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-shield' - }) + Title = 'Defender Portal' + URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-shield' + }) } - if($Configuration.IncludeComplianceLink) - { + if ($Configuration.IncludeComplianceLink) { $Links.Add(@{ - Title = 'Compliance Portal' - URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-caret-up' - }) + Title = 'Compliance Portal' + URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-caret-up' + }) } - if($Configuration.IncludeParterCenterLink) - { + if ($Configuration.IncludeParterCenterLink) { $Links.Add(@{ - Title = 'Partner Center Portals' - URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId - Icon = 'fas fa-arrow-up-right-from-square' - }) + Title = 'Partner Center Portals' + URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId + Icon = 'fas fa-arrow-up-right-from-square' + }) } $FormattedLinks = foreach ($Link in $Links) { @@ -209,7 +218,7 @@ function Invoke-HuduExtensionSync { $post = '' - if($Configuration.HideEmptyRoles) { + if ($Configuration.HideEmptyRoles) { $Roles = $Roles | Where-Object { $_.ParsedMembers } } From 7c0da88b49c4bf87aed315f4f71ab05c78058f60 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 12:21:59 -0400 Subject: [PATCH 122/150] feat: add featureflags to stats timer --- .../Timer Functions/Start-CIPPStatsTimer.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index 4484f851a9fe8..bc1651351056c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -48,6 +48,12 @@ function Start-CIPPStatsTimer { $driftStandardsCount = Get-CIPPStatsDriftStandardsCount $mobileEnrollment = Get-CIPPStatsMobileEnrollment + # Feature flags + $FeatureFlags = @{} + Get-CIPPFeatureFlag | Select-Object -Property Id, Enabled | ForEach-Object { + $FeatureFlags[$_.Id] = $_.Enabled + } + $SendingObject = [PSCustomObject]@{ rgid = $env:WEBSITE_SITE_NAME SetupComplete = $SetupComplete @@ -77,6 +83,10 @@ function Start-CIPPStatsTimer { PWPush = $RawExt.PWPush.Enabled CFZTNA = $RawExt.CFZTNA.Enabled GitHub = $RawExt.GitHub.Enabled + BestPracticeAnalyser = $FeatureFlags.BestPracticeAnalyser + SuperAdminNG = $FeatureFlags.SuperAdminNG + CopilotAI = $FeatureFlags.CopilotAI + MCPServer = $FeatureFlags.MCPServer } | ConvertTo-Json try { From fc8756f1235e07ac72926d88213ee6df32c98b1c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:37:22 +0800 Subject: [PATCH 123/150] User Offboarding default settings fix --- .../Users/Invoke-ListUserSettings.ps1 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index e539f77654140..147ad97b2e063 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -45,6 +45,25 @@ function Invoke-ListUserSettings { Write-Warning "Failed to convert UserSpecificSettings JSON: $($_.Exception.Message)" } + $TestOffboardingConfigured = { + param($Offboarding) + if (-not $Offboarding) { return $false } + foreach ($Property in $Offboarding.PSObject.Properties) { + if ($Property.Value -eq $true) { return $true } + } + return $false + } + + $AllUsersOffboardingConfigured = & $TestOffboardingConfigured $UserSettings.offboardingDefaults + $UserOffboardingConfigured = & $TestOffboardingConfigured $UserSpecificSettings.offboardingDefaults + + $OffboardingDefaultsSource = 'allUsers' + if (-not $AllUsersOffboardingConfigured -and $UserOffboardingConfigured) { + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaults' -Value $UserSpecificSettings.offboardingDefaults -Force | Out-Null + $OffboardingDefaultsSource = 'user' + } + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaultsSource' -Value $OffboardingDefaultsSource -Force | Out-Null + # Get user bookmarks try { $UserBookmarks = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserBookmarks' and RowKey eq '$Username'" From 3c919651ec9326ad9e9c7c29a46021e80011576c Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 30 Jun 2026 18:53:49 +0100 Subject: [PATCH 124/150] Adds a read-only endpoint that surfaces the currently-active fired alert items for a tenant, so the frontend can display live alert instances (not just configured rules or snoozed items). Backs the new Alerts card on the dashboard. Adds a read-only endpoint that surfaces the currently-active fired alert items for a tenant, so the frontend can display live alert instances (not just configured rules or snoozed items). Backs the new Alerts card on the dashboard. --- .../CIPP/Core/Invoke-ListAlertResults.ps1 | 83 +++++++++++++++++++ .../CIPP/Core/Invoke-ListSnoozedAlerts.ps1 | 1 + 2 files changed, 84 insertions(+) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 new file mode 100644 index 0000000000000..a961058be27fe --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 @@ -0,0 +1,83 @@ +function Invoke-ListAlertResults { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Alert.Read + .DESCRIPTION + Lists the currently-active fired alert items for a tenant, read from the + AlertLastRun table. AlertLastRun stores the items produced by the most recent + run of each scripted alert (Get-CIPPAlert*) after snoozed items have already + been filtered out, so this returns the active (non-snoozed) instances. Each + item is returned with a content preview/hash (matching the snooze format) and + the raw alert item so the frontend can snooze it via ExecSnoozeAlert. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required.' } + }) + } + + $Table = Get-CIPPTable -tablename 'AlertLastRun' + # AlertLastRun: PartitionKey = run date (yyyyMMdd), RowKey = "{tenant}-{cmdlet}" + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $Rows = Get-CIPPAzDataTableEntity @Table -Filter "Tenant eq '$SafeTenant'" + + # Keep only the most recent run (highest date partition) per alert. RowKey is + # "{tenant}-{cmdlet}", uniquely identifying the alert for this tenant. Write-AlertTrace + # only writes a new row when the data changes, so the latest row is the current state. + $LatestByAlert = @{} + foreach ($Row in @($Rows)) { + $Key = $Row.RowKey + $Existing = $LatestByAlert[$Key] + if (-not $Existing -or [string]$Row.PartitionKey -gt [string]$Existing.PartitionKey) { + $LatestByAlert[$Key] = $Row + } + } + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Row in $LatestByAlert.Values) { + if ([string]::IsNullOrWhiteSpace($Row.LogData)) { continue } + try { + $Items = $Row.LogData | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Information "Failed to parse AlertLastRun LogData for $($Row.RowKey): $($_.Exception.Message)" + continue + } + + foreach ($Item in @($Items)) { + if ($null -eq $Item) { continue } + $Hash = Get-AlertContentHash -AlertItem $Item + $Results.Add([PSCustomObject]@{ + CmdletName = $Row.CmdletName + AlertComment = $Row.AlertComment + Tenant = $Row.Tenant + LastRun = $Row.PartitionKey + ContentHash = $Hash.ContentHash + ContentPreview = $Hash.ContentPreview + AlertItem = $Item + }) + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list alert results: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to list alert results: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 index b4e897b7999a4..e0841c6eeced1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 @@ -41,6 +41,7 @@ function Invoke-ListSnoozedAlerts { RowKey = $_.RowKey CmdletName = $_.PartitionKey Tenant = $_.Tenant + ContentHash = $_.ContentHash ContentPreview = $_.ContentPreview SnoozedBy = $_.SnoozedBy SnoozedAt = $_.SnoozedAt From 29e8e6cc46b881853f8f2ff56665b37fd98c1369 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:22:31 +0800 Subject: [PATCH 125/150] Fixes for standards where deployment errors were hidden If a CA failed to be deployed because of a missing X or another issue these were not surfaced to the user and in tern drift/standards alignment indicated the policy as aligned even though nothing was deployed. Also adds some future helpers that might be of use later --- .../Standards/Push-CIPPStandard.ps1 | 21 ++ .../Push-CIPPStandardsApplyBatch.ps1 | 26 ++ .../Public/Invoke-CIPPCATemplateBatch.ps1 | 314 ++++++++++++++++++ Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 130 +++++--- .../Public/Resolve-CIPPCADependencies.ps1 | 216 ++++++++++++ ...-CIPPStandardConditionalAccessTemplate.ps1 | 61 +++- 6 files changed, 713 insertions(+), 55 deletions(-) create mode 100644 Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 create mode 100644 Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index 6d8cba6a8cb2c..b3c91c96ed345 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -11,6 +11,27 @@ function Push-CIPPStandard { $Tenant = $Item.Tenant $Standard = $Item.Standard + + # FUTURE USE - ZAC + # Grouped Conditional Access batch: deploy all of a tenant's CA templates in one sequential + # pass. Per-template rerun checks and standard-info context are handled inside the batch + # function, so we bypass the generic per-item dispatch below. + # if ($Item.BatchTemplates) { + # $TemplateCount = @($Item.BatchTemplates).Count + # Write-Information "Received Conditional Access batch for $Tenant with $TemplateCount template(s)." + # Set-CippUserAgentContext -Source 'standard' -TemplateId $Item.TemplateId + # $QueuedTime = if ($Item.QueuedTime) { [int64]$Item.QueuedTime } else { 0 } + # try { + # Invoke-CIPPCATemplateBatch -Tenant $Tenant -Templates $Item.BatchTemplates -QueuedTime $QueuedTime + # Write-Information "Conditional Access batch completed for tenant $Tenant" + # } catch { + # Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + # Write-Warning "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" + # throw $_.Exception.Message + # } + # return + # } + $FunctionName = 'Invoke-CIPPStandard{0}' -f $Standard Write-Information "We'll be running $FunctionName" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 index 24062cd9847d5..e15912a163472 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 @@ -20,6 +20,32 @@ function Push-CIPPStandardsApplyBatch { return } + # FUTURE USE - ZAC + # Group all ConditionalAccessTemplate standards per tenant into a single batch item so + # they deploy sequentially (one activity per tenant) instead of fanning out one activity + # per template. This removes the 429 storm against the ~1 req/s CA write endpoint and the + # duplicate named location / c1-c99 / 1040 races. Non-CA standards pass through unchanged. + # $CAStandards = @($AllStandards | Where-Object { $_.Standard -eq 'ConditionalAccessTemplate' }) + # if ($CAStandards.Count -gt 0) { + # $OtherStandards = @($AllStandards | Where-Object { $_.Standard -ne 'ConditionalAccessTemplate' }) + # $GroupedCA = foreach ($TenantGroup in ($CAStandards | Group-Object -Property Tenant)) { + # [pscustomobject]@{ + # Tenant = $TenantGroup.Name + # Standard = 'ConditionalAccessTemplate' + # FunctionName = 'CIPPStandard' + # QueuedTime = ($TenantGroup.Group | Select-Object -First 1).QueuedTime + # BatchTemplates = @($TenantGroup.Group | ForEach-Object { + # [pscustomobject]@{ + # Settings = $_.Settings + # TemplateId = $_.TemplateId + # } + # }) + # } + # } + # $AllStandards = @($OtherStandards) + @($GroupedCA) + # Write-Information "Grouped $($CAStandards.Count) Conditional Access template standards into $(@($GroupedCA).Count) per-tenant batch item(s)." + # } + Write-Information "Aggregated $($AllStandards.Count) standards from all tenants: $($AllStandards | ConvertTo-Json -Depth 5 -Compress)" # Start orchestrator to apply standards diff --git a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 new file mode 100644 index 0000000000000..3d5ae1633f994 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 @@ -0,0 +1,314 @@ +function Invoke-CIPPCATemplateBatch { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Deploy all Conditional Access template standards for a single tenant in one + sequential pass, reconciling shared dependencies once up front. + .DESCRIPTION + Per-tenant batch path for the ConditionalAccessTemplate standard. Replaces the + previous one-activity-per-template fan-out (which caused 429 storms against the + ~1 req/s CA write endpoint, plus duplicate-dependency / c1-c99 / 1040 races) with a + single serial deployment. Dependencies (named locations, auth contexts, auth + strengths) are reconciled ONCE via Resolve-CIPPCADependencies, then each policy is + deployed sequentially using New-CIPPCAPolicy -DependencyMap. Reporting is folded + into the same loop and remains per-template (one compare field per template). + + Dispatched internally from Push-CIPPStandard when a grouped batch item (carrying + BatchTemplates) is dequeued. This is NOT a user-selectable standard. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Tenant, + $Templates, + [int64]$QueuedTime = 0, + $Headers + ) + + $Templates = @($Templates | Where-Object { $_ }) + if ($Templates.Count -eq 0) { + Write-Information "No CA templates to deploy for $Tenant" + return + } + + # Always refresh the CA cache first. This is one long-running activity and both Phase 1 + # (dependency reconciliation) and Phase 2 (existing-policy checks / reporting) read off + # the snapshot, so it must reflect current tenant state before we begin. + try { + Write-Information "Refreshing Conditional Access DB cache for $Tenant before batch deploy" + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + } catch { + Write-Warning "Failed to refresh CA cache for $Tenant : $($_.Exception.Message)" + } + + # General Entra license gate - applies to every CA template in the batch + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -Preset Entra + if ($TestResult -eq $false) { + foreach ($t in $Templates) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($t.Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant + } + return + } + + # Preload snapshots from the freshly-updated cache + try { + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + $PreloadedSecurityDefaults = New-CIPPDbRequest -TenantFilter $Tenant -Type 'SecurityDefaults' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not load the ConditionalAccessTemplate cache for $Tenant. Error: $ErrorMessage" -Sev Error + return + } + # Auth strengths are cached; auth contexts are not - Resolve-CIPPCADependencies fetches + # contexts live and falls back to a live fetch if the strengths snapshot is empty. + try { $PreloadedAuthStrengths = New-CIPPDbRequest -TenantFilter $Tenant -Type 'AuthenticationStrengths' } catch { $PreloadedAuthStrengths = $null } + + # Preload tenant-wide lookups ONCE for the whole batch (reused by every policy deploy and the + # report conversion). Without this, New-CIPPCAPolicy and New-CIPPCATemplate each re-fetch + # users/groups/servicePrincipals/vacation-groups per policy - dozens of redundant Graph calls. + $UGRequests = @( + @{ id = 'users'; url = 'users?$select=id,displayName&$top=999'; method = 'GET' } + @{ id = 'groups'; url = 'groups?$select=id,displayName&$top=999'; method = 'GET' } + ) + $PreloadedUsers = $null; $PreloadedGroups = $null; $PreloadedServicePrincipals = $null; $PreloadedVacationGroups = $null + try { + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { Write-Warning "Failed to preload users/groups for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload service principals for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedVacationGroups = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload vacation exclusion groups for $Tenant : $($_.Exception.Message)" } + + $Table = Get-CippTable -tablename 'templates' + + # Load each template's JSON once + $CATemplates = foreach ($t in $Templates) { + $TemplateValue = $t.Settings.TemplateList.value + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$TemplateValue'" + $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + if (-not $JSON) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping." -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -FieldValue "Template '$($t.Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + continue + } + [pscustomobject]@{ + Settings = $t.Settings + TemplateId = $t.TemplateId + RawJSON = $JSON + WillRemediate = $false + NeedsRemediation = $false + Skip = $false + DeployError = $null + } + } + $CATemplates = @($CATemplates) + if ($CATemplates.Count -eq 0) { return } + + # Resolve P2 capability once for the whole tenant (reused for every risk-based policy) + $TenantHasP2 = [bool](Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog) + + # Nested helper: compare ONE template against the current deployed state, write its compare + # field in the renderable CurrentValue/ExpectedValue shape, and return a status string: + # 'Compliant' | 'Drifted' | 'Missing' | 'P2Blocked' | 'Failed' | 'Error'. + function Set-CABatchCompareStatus { + param($ct) + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + $FieldName = "standards.ConditionalAccessTemplate.$TemplateValue" + + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + + try { + $Policy = $ct.RawJSON | ConvertFrom-Json -Depth 10 + if ($null -eq $Policy) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be parsed." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + + # Override the template's state with the standard's state for drift comparison + if ($Settings.state -and $Settings.state -ne 'donotchange') { + $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force + } + + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's OWN LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + + $CheckExisting = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + if ($CheckExisting -is [array] -and $CheckExisting.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExisting.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExisting = $CheckExisting[0] + } + + if (!$CheckExisting) { + $NeedsP2 = ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) + if ($ct.DeployError) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $($ct.DeployError)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Failed' + } elseif ($NeedsP2 -and -not $TenantHasP2) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'P2Blocked' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Missing' + } + } + + $templateResult = New-CIPPCATemplate -TenantFilter $Tenant -JSON $CheckExisting -preloadedLocations $PreloadedLocations -preloadedUsers $PreloadedUsers -preloadedGroups $PreloadedGroups + $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + if ($null -eq $CompareObj) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + try { + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' + } catch { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + if (!$Compare) { + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue @{ Differences = 'No Differences found' } -ExpectedValue @{ Differences = 'No Differences found' } -Tenant $Tenant + return 'Compliant' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Compare } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Drifted' + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error evaluating conditional access template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + return 'Error' + } + } + + # Per-template rerun decision (marker written here so a redelivered batch skips handled ones) + foreach ($ct in $CATemplates) { + if ($QueuedTime -gt 0) { + $API = "ConditionalAccessTemplate_$($ct.TemplateId)_$($ct.Settings.TemplateList.value)" + if (Test-CIPPRerun -Type Standard -Tenant $Tenant -API $API -Settings $ct.Settings -BaseTime $QueuedTime) { + Write-Information "Detected rerun for $API. Skipping." + $ct.Skip = $true + } + } + } + + # ---- Evaluate: compare every in-scope template against the CURRENT state, write its result, + # and flag only the ones that actually need remediation (missing or drifted). Compliant + # policies are reported and then left untouched - no needless PATCH on every run. ---- + foreach ($ct in $CATemplates) { + if ($ct.Skip) { continue } + if (-not ($ct.Settings.report -eq $true -or $ct.Settings.remediate -eq $true)) { continue } + $Status = Set-CABatchCompareStatus -ct $ct + if ($ct.Settings.remediate -eq $true -and $Status -in @('Missing', 'Drifted')) { + $ct.WillRemediate = $true + $ct.NeedsRemediation = $true + } + } + + # ---- Reconcile shared dependencies ONCE, only for the policies we will actually deploy ---- + $DeployObjects = [System.Collections.Generic.List[object]]::new() + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation) { $DeployObjects.Add(($ct.RawJSON | ConvertFrom-Json)) } + } + $DependencyMap = $null + if ($DeployObjects.Count -gt 0) { + try { + $DependencyMap = Resolve-CIPPCADependencies -TenantFilter $Tenant -PolicyObjects $DeployObjects -AllNamedLocations $PreloadedLocations -AllAuthStrengthPolicies $PreloadedAuthStrengths -Overwrite $true -Headers $Headers -APIName 'Standards' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to reconcile Conditional Access dependencies for $Tenant. Error: $ErrorMessage" -sev 'Error' + $DependencyMap = $null + } + } + + # ---- Remediate: deploy only the missing/drifted policies, sequentially ---- + $RemediatedAny = $false + foreach ($ct in $CATemplates) { + if (-not $ct.NeedsRemediation -or -not $DependencyMap) { continue } + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + try { + $NewCAPolicy = @{ + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Settings.state + RawJSON = $ct.RawJSON + Overwrite = $true + APIName = 'Standards' + Headers = $Headers + DisableSD = $Settings.DisableSD + CreateGroups = $Settings.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies + PreloadedLocations = $PreloadedLocations + PreloadedSecurityDefaults = $PreloadedSecurityDefaults + DependencyMap = $DependencyMap + PreloadedServicePrincipals = $PreloadedServicePrincipals + PreloadedUsers = $PreloadedUsers + PreloadedGroups = $PreloadedGroups + PreloadedVacationGroups = $PreloadedVacationGroups + } + $null = New-CIPPCAPolicy @NewCAPolicy + $RemediatedAny = $true + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + # Record the deploy failure so the re-report surfaces the reason instead of "missing" + $ct.DeployError = $ErrorMessage + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to create or update conditional access rule from template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + } + } + + # ---- Refresh + re-report ONLY the policies we just deployed ---- + # Only refresh when something actually changed ($RemediatedAny is set on a successful + # create/update). When nothing was deployed there's no need to re-pull the cache at all. + if ($RemediatedAny) { + # Give Graph a moment to propagate the new/updated policies before refreshing the cache, + # otherwise the refresh can race ahead of eventual consistency and miss just-created ones. + Start-Sleep -Seconds 5 + try { + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + # Refresh groups too so the report resolves any exclusion groups just created during deploy + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { + Write-Warning "Failed to refresh CA snapshot after remediation for $Tenant : $($_.Exception.Message)" + } + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation -and -not $ct.Skip) { + $null = Set-CABatchCompareStatus -ct $ct + } + } + } + + Set-CippStandardInfoContext -StandardInfo $null +} diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index f2b236fdf9b54..c3061a926f554 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -13,7 +13,12 @@ function New-CIPPCAPolicy { $Headers, $PreloadedCAPolicies = $null, $PreloadedLocations = $null, - $PreloadedSecurityDefaults = $null + $PreloadedSecurityDefaults = $null, + $DependencyMap = $null, + $PreloadedServicePrincipals = $null, + $PreloadedUsers = $null, + $PreloadedGroups = $null, + $PreloadedVacationGroups = $null ) # Helper function to replace group display names with GUIDs @@ -137,9 +142,9 @@ function New-CIPPCAPolicy { } } - # Get named locations once if needed + # Get named locations once if needed (skipped when a shared DependencyMap is supplied - deps were reconciled up front) $AllNamedLocations = $null - if ($JSONobj.LocationInfo) { + if (-not $DependencyMap -and $JSONobj.LocationInfo) { if ($PreloadedLocations) { Write-Information 'Using preloaded named locations' $AllNamedLocations = $PreloadedLocations @@ -155,9 +160,9 @@ function New-CIPPCAPolicy { } } - # Get authentication strength policies once if needed + # Get authentication strength policies once if needed (skipped when a shared DependencyMap is supplied) $AllAuthStrengthPolicies = $null - if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { + if (-not $DependencyMap -and ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn')) { try { Write-Information 'Fetching authentication strength policies...' $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true @@ -168,9 +173,9 @@ function New-CIPPCAPolicy { } } - # Get authentication context class references once if needed + # Get authentication context class references once if needed (skipped when a shared DependencyMap is supplied) $AllAuthContexts = $null - if ($JSONobj.AuthContextInfo) { + if (-not $DependencyMap -and $JSONobj.AuthContextInfo) { try { Write-Information 'Fetching authentication context class references...' $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true @@ -181,9 +186,13 @@ function New-CIPPCAPolicy { } } - # Get service principals once if needed + # Get service principals once if needed (use preloaded set when supplied to avoid a + # tenant-wide fetch on every policy in a batch) $AllServicePrincipals = $null if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { + if ($PreloadedServicePrincipals) { + $AllServicePrincipals = $PreloadedServicePrincipals + } else { try { Write-Information 'Fetching all service principals...' $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true @@ -192,19 +201,26 @@ function New-CIPPCAPolicy { Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" throw "Failed to fetch service principals: $($ErrorMessage.NormalizedError)" } + } } #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { - $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName - if ($ExistingStrength) { - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - + if ($DependencyMap) { + # Dependencies were reconciled up front - resolve the id from the shared map by display name + $StrengthName = $JSONobj.GrantControls.authenticationStrength.displayName + $JSONobj.GrantControls.authenticationStrength = @{ id = $DependencyMap.AuthStrength[$StrengthName] } } else { - $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName + if ($ExistingStrength) { + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + + } else { + $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + } } } @@ -234,8 +250,20 @@ function New-CIPPCAPolicy { # Handle authentication context class references - create if missing, replace displayNames with IDs if ($JSONobj.AuthContextInfo) { - $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { - if (-not $authContext.displayName) { continue } + if ($DependencyMap) { + # Build this policy's lookup from its own AuthContextInfo + the shared id map. + # templateId stays scoped to THIS policy so per-template ids never collide across policies. + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.AuthContexts[$authContext.displayName] + displayName = $authContext.displayName + templateId = $authContext.id + } + } + } else { + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $authContext.displayName if ($ExistingContext) { Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched authentication context: $($authContext.displayName)" -Sev 'Info' @@ -280,10 +308,10 @@ function New-CIPPCAPolicy { templateId = $authContext.id } } + } } - Write-Information 'Auth Context Lookup Table:' - Write-Information ($AuthContextLookupTable | ConvertTo-Json -Depth 10) + Write-Information "Auth Context Lookup Table: $(@($AuthContextLookupTable) | ConvertTo-Json -Depth 10 -Compress)" # Replace display names with actual IDs in the policy if ($AuthContextLookupTable -and $JSONobj.conditions.applications.includeAuthenticationContextClassReferences) { @@ -302,6 +330,21 @@ function New-CIPPCAPolicy { } #for each of the locations, check if they exist, if not create them. These are in $JSONobj.LocationInfo + if ($DependencyMap) { + # Build this policy's lookup from its own LocationInfo + the shared id map + $NewLocationsCreated = $DependencyMap.NewLocationsCreated + $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.Locations[$location.displayName] + name = $location.displayName + templateId = $location.id + } + } + } + } else { $NewLocationsCreated = $false $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { if (!$locations) { continue } @@ -383,8 +426,8 @@ function New-CIPPCAPolicy { } } } - Write-Information 'Location Lookup Table:' - Write-Information ($LocationLookupTable | ConvertTo-Json -Depth 10) + } + Write-Information "Location Lookup Table: $(@($LocationLookupTable) | ConvertTo-Json -Depth 10 -Compress)" if ($LocationLookupTable -and $JSONobj.conditions.locations) { foreach ($location in $JSONobj.conditions.locations.includeLocations) { @@ -431,22 +474,28 @@ function New-CIPPCAPolicy { } try { Write-Information 'Replacement pattern for inclusions and exclusions is displayName.' - $Requests = @( - @{ - url = 'users?$select=id,displayName&$top=999' - method = 'GET' - id = 'users' - } - @{ - url = 'groups?$select=id,displayName&$top=999' - method = 'GET' - id = 'groups' - } - ) - $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + if ($null -ne $PreloadedUsers -and $null -ne $PreloadedGroups) { + # Use the batch-level preloaded lookups to avoid a users+groups fetch per policy + $users = $PreloadedUsers + $groups = $PreloadedGroups + } else { + $Requests = @( + @{ + url = 'users?$select=id,displayName&$top=999' + method = 'GET' + id = 'users' + } + @{ + url = 'groups?$select=id,displayName&$top=999' + method = 'GET' + id = 'groups' + } + ) + $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true - $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value - $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value + $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + } foreach ($userType in 'includeUsers', 'excludeUsers') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestOrExternalUsers') { @@ -548,7 +597,12 @@ function New-CIPPCAPolicy { } # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { - $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | + $VacationGroups = if ($null -ne $PreloadedVacationGroups) { + $PreloadedVacationGroups + } else { + New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true + } + $ExistingVacationGroup = $VacationGroups | Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { diff --git a/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 new file mode 100644 index 0000000000000..02af8bcb8dfc8 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 @@ -0,0 +1,216 @@ +function Resolve-CIPPCADependencies { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Reconcile Conditional Access policy dependencies (named locations, authentication + contexts, authentication strengths) ONCE across a set of CA policy/template objects. + .DESCRIPTION + Used by the per-tenant CA batch deployment path (Invoke-CIPPCATemplateBatch) so that + dependencies shared by multiple policies are created/deduplicated a single time. This + avoids the duplicate named locations, c1-c99 authentication-context id collisions, and + error 1040 propagation races that occur when many CA policies deploy concurrently and + each one independently creates the dependencies it references. + + Returns displayName -> id maps that New-CIPPCAPolicy consumes via its -DependencyMap + parameter. Each policy still resolves its OWN references downstream (using its own + LocationInfo / AuthContextInfo) so per-template template-ids never collide across policies. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $TenantFilter, + [object[]]$PolicyObjects, + $AllNamedLocations = $null, + $AllAuthStrengthPolicies = $null, + $AllAuthContexts = $null, + $Overwrite = $true, + $Headers, + $APIName = 'CA Dependency Reconciliation' + ) + + $AuthStrengthMap = @{} + $AuthContextMap = @{} + $LocationMap = @{} + $NewLocationsCreated = $false + + $PolicyObjects = @($PolicyObjects | Where-Object { $_ }) + if ($PolicyObjects.Count -eq 0) { + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $false + } + } + + # ---- Authentication strength policies ---- + $NeedAuthStrength = @($PolicyObjects | Where-Object { $_.GrantControls.authenticationStrength.policyType -in @('custom', 'BuiltIn') }) + if ($NeedAuthStrength.Count -gt 0) { + if ($null -eq $AllAuthStrengthPolicies) { + try { + $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication strength policies: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthStrength) { + $Strength = $Policy.GrantControls.authenticationStrength + $Name = $Strength.displayName + if (!$Name -or $AuthStrengthMap.ContainsKey($Name)) { continue } + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingStrength) { + $AuthStrengthMap[$Name] = $ExistingStrength.id + } else { + $Body = ConvertTo-Json -InputObject $Strength -Depth 10 + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $Body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $AuthStrengthMap[$Name] = $GraphRequest.id + $AllAuthStrengthPolicies = @($AllAuthStrengthPolicies) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Created new Authentication Strength Policy: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication strength policy '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + + # ---- Authentication context class references ---- + $NeedAuthContext = @($PolicyObjects | Where-Object { $_.AuthContextInfo }) + if ($NeedAuthContext.Count -gt 0) { + if ($null -eq $AllAuthContexts) { + try { + $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication context class references: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthContext) { + foreach ($authContext in $Policy.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + $Name = $authContext.displayName + if ($AuthContextMap.ContainsKey($Name)) { continue } + $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingContext) { + $AuthContextMap[$Name] = $ExistingContext.id + } else { + # Find the next available ID (c1-c99) across the running set so concurrent + # contexts in the same batch never collide on the same id. + $UsedIds = @($AllAuthContexts.id) + $NewId = $null + for ($i = 1; $i -le 99; $i++) { + if ("c$i" -notin $UsedIds) { $NewId = "c$i"; break } + } + if (-not $NewId) { + throw "No available authentication context IDs (c1-c99) in tenant $TenantFilter" + } + $Body = @{ + id = $NewId + displayName = $Name + description = if ($authContext.description) { $authContext.description } else { '' } + isAvailable = $true + } | ConvertTo-Json -Compress + try { + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + $AuthContextMap[$Name] = $NewId + $AllAuthContexts = @($AllAuthContexts) + @([pscustomobject]@{ id = $NewId; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Authentication Context: $Name with ID $NewId" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication context '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + } + + # ---- Named locations ---- + $NeedLocations = @($PolicyObjects | Where-Object { $_.LocationInfo }) + if ($NeedLocations.Count -gt 0) { + if ($null -eq $AllNamedLocations) { + try { + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedLocations) { + foreach ($locations in $Policy.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + $Name = $location.displayName + if ($LocationMap.ContainsKey($Name)) { continue } + $ExistingLocation = @($AllNamedLocations | Where-Object -Property displayName -EQ $Name) + $locationExists = $ExistingLocation.Count -gt 0 + if ($locationExists) { + $ExistingLocation = $ExistingLocation[0] + if ($Overwrite) { + $LocationUpdate = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationUpdate + $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $Body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Named Location '$Name' (id: $($ExistingLocation.id)) could not be updated - it may have been deleted. Will attempt to create it. Error: $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage + $locationExists = $false + } + } + if ($locationExists) { + $LocationMap[$Name] = $ExistingLocation.id + } + } + if (-not $locationExists) { + if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } + $LocationBody = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationBody + $Body = ConvertTo-Json -InputObject $LocationBody + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + Write-Information "Created named location with ID: $($GraphRequest.id)" + # Wait for location to be available before any policy references it + $retryCount = 0 + $MaxRetryCount = 5 + $LocationRequest = $null + do { + Start-Sleep -Seconds 3 + try { + $LocationRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($GraphRequest.id)" -tenantid $TenantFilter -asApp $true -ErrorAction Stop + } catch { + Write-Information 'Location not yet available, will retry...' + } + $retryCount++ + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) + + if (!$LocationRequest -or !$LocationRequest.id) { + Write-Warning "Location $Name created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." + } + $NewLocationsCreated = $true + $LocationMap[$Name] = $GraphRequest.id + $AllNamedLocations = @($AllNamedLocations) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create named location $Name : $($ErrorMessage.NormalizedError)" + } + } + } + } + } + } + + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $NewLocationsCreated + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index bb7c1d6d1d2ca..c792e589d626e 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -67,6 +67,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { return } + $DeployError = $null if ($Settings.remediate -eq $true) { try { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" @@ -76,7 +77,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return $true } } @@ -97,17 +98,20 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $null = New-CIPPCAPolicy @NewCAPolicy } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' + # Capture the Graph deploy error (e.g. invalid CA policy 1011/1085) so the report + # section below surfaces the reason in the compare fields instead of just "missing". + $DeployError = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $DeployError" -sev 'Error' } } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { + $FieldName = "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 if ($null -eq $Policy) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } @@ -118,43 +122,66 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's own LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + # Duplicate display names would pass an array to New-CIPPCATemplate (breaking its single-object + # conversion and dumping the whole template). Compare against the first match instead. + if ($CheckExististing -is [array] -and $CheckExististing.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExististing.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExististing = $CheckExististing[0] + } if (!$CheckExististing) { - if ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { + if ($DeployError) { + # Attempted but the Graph deployment errored (e.g. invalid CA policy) - surface the reason + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $DeployError" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + } elseif ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations + $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $PreloadedLocations $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult - if ($null -eq $Policy -or $null -eq $CompareObj) { - $nullSide = if ($null -eq $Policy) { 'template policy' } else { 'tenant policy conversion' } - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: $nullSide returned null for $($Settings.TemplateList.label)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $nullSide returned null" -Tenant $Tenant + if ($null -eq $CompareObj) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: tenant policy conversion returned null for $($Settings.TemplateList.label)" -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } try { $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } if (!$Compare) { $ExpectedValue = @{ 'Differences' = 'No Differences found' } $CurrentValue = @{ 'Differences' = 'No Differences found' } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } else { - #this can still be prettified but is for later. $ExpectedValue = @{ 'Differences' = @() } $CurrentValue = @{ 'Differences' = $Compare } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } } From f965afebf7fc6556a46f3ff62d3e81515bbb7745 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:39:47 +0200 Subject: [PATCH 126/150] Implements #6214 --- .../Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 9a304a61ae372..1623d335a2968 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -20,6 +20,8 @@ function Get-CIPPAlertInactiveLicensedUsers { if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { $excludeDisabled = [bool]$InputValue.ExcludeDisabled + # Allow the alert configuration to opt in to reporting users who have never signed in (e.g. accounts with sign-in blocked) + if ([bool]$InputValue.IncludeNeverSignedIn) { $IncludeNeverSignedIn = $true } if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { $parsedDays = 0 if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { From 67fdcd25d5177327d68b6861a764c775581a0021 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:31:08 +0200 Subject: [PATCH 127/150] feat: implement clearable fields in user edit API --- .../Administration/Users/Invoke-EditUser.ps1 | 25 ++++- Tests/Endpoint/Invoke-EditUser.Tests.ps1 | 94 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Tests/Endpoint/Invoke-EditUser.Tests.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index af7659da83c61..84590fda70b59 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -30,7 +30,6 @@ function Invoke-EditUser { #Edit the user try { - Write-Host "$([boolean]$UserObj.MustChangePass)" $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" $normalizedOtherMails = @( @($UserObj.otherMails) | ForEach-Object { @@ -66,9 +65,27 @@ function Invoke-EditUser { } } | ForEach-Object { $NonEmptyProperties = $_.PSObject.Properties | - Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | - Select-Object -ExpandProperty Name - $_ | Select-Object -Property $NonEmptyProperties + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | + Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + # Explicit clears: the frontend lists the profile fields the user actively emptied. + # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while + # untouched empty fields stay omitted. Whitelisted to safe attributes + $ClearableFields = @( + 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', + 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', + 'businessPhones', 'otherMails' + ) + $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($Prop in $ClearList) { + if ($Prop -notin $ClearableFields) { continue } + # Pass @() literally; routing it through a variable unrolls it back to $null. + if ($Prop -in 'businessPhones', 'otherMails') { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force + } else { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force + } } if ($UserObj.defaultAttributes) { $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { diff --git a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 new file mode 100644 index 0000000000000..81aee9276912f --- /dev/null +++ b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 @@ -0,0 +1,94 @@ +# Pester tests for Invoke-EditUser +# Validates the clear-vs-omit behaviour of the user PATCH body: +# a profile field present in the request (even as null) is forwarded to Graph as a clear, +# while an omitted field is left untouched. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1' + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + # The Functions worker exposes [HttpStatusCode]; map it for standalone test runs. + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + # Capture the body of the user PATCH (first call; password reset is a separate call we don't trigger) + function New-GraphPostRequest { + param($uri, $tenantid, $type, $body, [switch]$verbose) + if ($null -eq $script:lastBody) { $script:lastBody = $body } + } + + function New-EditRequest { + param([hashtable]$Extra) + $body = [pscustomobject]@{ + id = '11111111-1111-1111-1111-111111111111' + tenantFilter = 'contoso.onmicrosoft.com' + username = 'jdoe' + Domain = 'contoso.com' + displayName = 'John Doe' + } + foreach ($key in $Extra.Keys) { + $body | Add-Member -NotePropertyName $key -NotePropertyValue $Extra[$key] -Force + } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'EditUser' } + Headers = @{} + Body = $body + } + } + + . $FunctionPath +} + +Describe 'Invoke-EditUser body construction' { + BeforeEach { + $script:lastBody = $null + } + + It 'clears a field listed in clearProperties' { + $request = New-EditRequest -Extra @{ clearProperties = @('jobTitle') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":null' + } + + It 'omits a field that was neither sent nor listed for clearing' { + $request = New-EditRequest -Extra @{} + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match 'jobTitle' + } + + It 'sends a provided value unchanged' { + $request = New-EditRequest -Extra @{ jobTitle = 'Manager' } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":"Manager"' + } + + It 'clears a collection field with an empty array' { + $request = New-EditRequest -Extra @{ clearProperties = @('otherMails') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"otherMails":\[\]' + } + + It 'never clears displayName even when listed (Graph rejects it)' { + $request = New-EditRequest -Extra @{ clearProperties = @('displayName') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match '"displayName":(null|"")' + } +} From a0b263cd30d3a08e21f60eab384c9c778acc3e91 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 17:56:44 -0400 Subject: [PATCH 128/150] chore: update version to 10.5.6 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 67e853b0f3cf9..cfa1e128d096c 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.5", + "defaultVersion": "10.5.6", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index 23b7528bc2089..3b24057083036 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.5 +10.5.6 From 259770d93654ec76cb3d1ae6b44750ea3d9b0ca5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 18:37:32 -0400 Subject: [PATCH 129/150] fix: revert rerun protection on scheduled tasks --- .../Push-ExecScheduledCommand.ps1 | 53 ++++--------------- .../Scheduler/Invoke-AddScheduledItem.ps1 | 7 --- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 8a358101773e4..b37e214051722 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -50,48 +50,17 @@ function Push-ExecScheduledCommand { # Task should be 'Pending' (queued by orchestrator) or 'Running' (retry/recovery) # We accept both to handle edge cases - # Check for rerun protection - prevent duplicate executions within the recurrence interval - # Do this BEFORE updating state to 'Running' to avoid getting stuck - if ($task.Recurrence -and $task.Recurrence -ne '0' -and !$IsMultiTenantExecution) { - # Calculate interval in seconds from recurrence string - $IntervalSeconds = switch -Regex ($task.Recurrence) { - '^(\d+)$' { [int64]$matches[1] * 86400 } # Plain number = days - '(\d+)m$' { [int64]$matches[1] * 60 } - '(\d+)h$' { [int64]$matches[1] * 3600 } - '(\d+)d$' { [int64]$matches[1] * 86400 } - default { 0 } - } - - if ($IntervalSeconds -gt 0) { - # Round down to nearest 15-minute interval (900 seconds) since that's when orchestrator runs - # This prevents rerun blocking issues due to slight timing variations - $FifteenMinutes = 900 - $AdjustedInterval = [Math]::Floor($IntervalSeconds / $FifteenMinutes) * $FifteenMinutes - - # Ensure we have at least one 15-minute interval - if ($AdjustedInterval -lt $FifteenMinutes) { - $AdjustedInterval = $FifteenMinutes - } - # Use task RowKey as API identifier for rerun cache - $RerunParams = @{ - TenantFilter = $Tenant - Type = 'ScheduledTask' - API = $task.RowKey - Interval = $AdjustedInterval - BaseTime = [int64]$task.ScheduledTime - Headers = $Headers - } - - $IsRerun = Test-CIPPRerun @RerunParams - if ($IsRerun) { - Write-Information "Scheduled task $($task.Name) for tenant $Tenant was recently executed. Skipping to prevent duplicate execution." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue - return - } - } - } - - # Also check for one-time task rerun protection based on ExecutedTime + # NOTE: Recurring scheduled tasks intentionally have NO rerun-cache protection here. + # Duplicate execution is already prevented by the orchestrator's own state machine: + # - the ETag claim flips the task to 'Pending' atomically (no concurrent dispatch), + # - 'Pending'/'Running' tasks are not re-picked until they go stale (1h/4h), + # - ScheduledTime is advanced on completion so the task isn't eligible again until due. + # A separate rerun cache (Test-CIPPRerun) was a second, independent clock that drifted out + # of sync with ScheduledTime whenever a run didn't finish advancing the schedule, which both + # deadlocked tasks and blocked the orchestrator's stuck-task recovery. ScheduledTime is the + # single source of truth. + + # One-time task rerun protection based on ExecutedTime (the task's own field, not a cache) if ((!$task.Recurrence -or $task.Recurrence -eq '0') -and $task.ExecutedTime -and !$IsMultiTenantExecution) { $currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds $timeSinceExecution = $currentUnixTime - [int64]$task.ExecutedTime diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index f3443d1471c64..909c824a59047 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -26,13 +26,6 @@ function Invoke-AddScheduledItem { } if ($ExistingTask -and $Request.Body.RunNow -eq $true) { - $RerunParams = @{ - TenantFilter = $ExistingTask.Tenant - Type = 'ScheduledTask' - API = $Request.Body.RowKey - Clear = $true - } - $null = Test-CIPPRerun @RerunParams # Clear ExecutedTime so the one-time task rerun guard in Push-ExecScheduledCommand does not block re-execution $null = Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $ExistingTask.PartitionKey From d17b33f46405056fcd5a8cbd42a6d8b01e18bc33 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 21:11:31 -0400 Subject: [PATCH 130/150] fix: add system32,osdrive --- Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f96bef07090cc..f6b1abb01a863 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -29,6 +29,8 @@ function Get-CIPPTextReplacement { '%serial%', '%systemroot%', '%systemdrive%', + '%system32%', + '%osdrive%', '%temp%', '%tenantid%', '%tenantfilter%', From 52ce1759413b411f500df829abca2b5cf2bf5080 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 21:34:01 -0400 Subject: [PATCH 131/150] fix: exclude imAddresses from user backup/restore --- Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 | 2 +- Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 index 55f189947694d..d8dfdeecb4f6b 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 @@ -27,7 +27,7 @@ function New-CIPPBackupTask { } 'users' { Measure-CippTask -TaskName 'Users' -EventName 'CIPP.BackupCompleted' -Script { - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, imAddresses, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { #remove the property if the value is $null $_.psobject.properties | Where-Object { $null -eq $_.Value } | ForEach-Object { $_.psobject.properties.Remove($_.Name) diff --git a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 index 595b18dae5a79..b1b2dddf77c80 100644 --- a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 @@ -39,7 +39,7 @@ function New-CIPPRestoreTask { # Helper function to clean user object for Graph API - removes reference properties, nulls, and empty strings function Clean-GraphObject { param($Object, [switch]$ExcludeId) - $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo') + $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo', 'imAddresses') if ($ExcludeId) { $excludeProps += @('id') } From 9cf8a3c674e72204b40b9056ca42dfc74da9e0c6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:33:56 +0800 Subject: [PATCH 132/150] Reduce Verbose nop logging --- .../Timer Functions/Start-UserSyncTimer.ps1 | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index 435d3a3d4a017..89710f3e8bf37 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -20,8 +20,6 @@ function Start-UserSyncTimer { $ApiName = 'UserSync' try { - Write-LogMessage -API $ApiName -tenant 'none' -message 'Starting user sync from partner tenant.' -sev Info - # Load the role-to-group mappings $AccessGroupsTable = Get-CippTable -TableName AccessRoleGroups $AccessGroups = @(Get-CIPPAzDataTableEntity @AccessGroupsTable -Filter "PartitionKey eq 'AccessRoleGroups'") @@ -69,12 +67,6 @@ function Start-UserSyncTimer { } } - if ($UserRoleMap.Count -eq 0 -and $RoleGroupIds.Count -gt 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No users found in any role groups.' -sev Info - } elseif ($RoleGroupIds.Count -eq 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No Entra groups mapped to roles — will clean up any stale auto-provisioned users.' -sev Info - } - # Load existing allowedUsers table $UsersTable = Get-CippTable -tablename 'allowedUsers' $ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') }) @@ -91,7 +83,6 @@ function Start-UserSyncTimer { } $Now = (Get-Date).ToUniversalTime().ToString('o') - $UpsertCount = 0 $RemoveCount = 0 $EntitiesToUpsert = [System.Collections.Generic.List[object]]::new() $EntitiesToRemove = [System.Collections.Generic.List[object]]::new() @@ -134,7 +125,6 @@ function Start-UserSyncTimer { } $EntitiesToUpsert.Add($Entity) - $UpsertCount++ } # Reconcile existing users that are NOT in any mapped role group @@ -205,8 +195,22 @@ function Start-UserSyncTimer { } } - # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows) + # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows). + # Only count an upsert as a change when the role data actually differs from the + # existing canonical row — LastSync alone changing every run isn't a real change. + $ChangedCount = 0 foreach ($Entity in $EntitiesToUpsert) { + $Canonical = $null + if ($ExistingLookup.ContainsKey($Entity.RowKey)) { + $Canonical = $ExistingLookup[$Entity.RowKey] | Where-Object { $_.RowKey -ceq $Entity.RowKey } | Select-Object -First 1 + } + if (-not $Canonical -or + $Canonical.Roles -ne $Entity.Roles -or + $Canonical.AutoRoles -ne $Entity.AutoRoles -or + $Canonical.ManualRoles -ne $Entity.ManualRoles -or + $Canonical.Source -ne $Entity.Source) { + $ChangedCount++ + } Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force } foreach ($Entity in $EntitiesToRemove) { @@ -217,7 +221,10 @@ function Start-UserSyncTimer { # Invalidate CRAFT's in-memory user cache so changes apply try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info + # Only log when something actually changed — no noise on steady-state runs. + if ($ChangedCount -gt 0 -or $RemoveCount -gt 0) { + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $ChangedCount users added/updated, $RemoveCount duplicate/stale rows removed." -sev Info + } } catch { $ErrorData = Get-CippException -Exception $_ From 103078a4210304204077e2cbf577087599a095cd Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:40:33 +0800 Subject: [PATCH 133/150] JSON escaping fixes for intune policies --- .../CIPPCore/Public/Get-CIPPTextReplacement.ps1 | 14 ++++++++++++-- Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 | 2 +- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f6b1abb01a863..a89ce2bee2662 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -16,6 +16,16 @@ function Get-CIPPTextReplacement { $Text, [switch]$EscapeForJson ) + # Escapes a replacement value so it can be safely spliced into a serialized JSON + # string literal. Handles quotes, backslashes, newlines, tabs and control chars. + function ConvertTo-CIPPJsonEscapedString { + param($Value) + if ($null -eq $Value) { return '' } + $Encoded = [string]$Value | ConvertTo-Json -Compress + # Strip the surrounding quotes ConvertTo-Json adds, leaving just the escaped body. + return $Encoded.Substring(1, $Encoded.Length - 2) + } + if ($Text -isnot [string]) { return , $Text } @@ -70,7 +80,7 @@ function Get-CIPPTextReplacement { if (-not $Var.PSObject.Properties['Value']) { continue } $Val = $Var.Value if ($EscapeForJson.IsPresent) { - $Val = $Val -replace '(? Date: Wed, 1 Jul 2026 17:05:40 +0800 Subject: [PATCH 134/150] Bump AzBobbyTables version --- .../AzBobbyTables/3.5.1/AzBobbyTables.PS.dll | Bin 43520 -> 43520 bytes .../AzBobbyTables/3.5.1/AzBobbyTables.psd1 | 7 +- Modules/AzBobbyTables/3.5.1/CHANGELOG.md | 13 +- .../3.5.1/dependencies/AzBobbyTables.Core.dll | Bin 49152 -> 49664 bytes .../Microsoft.Bcl.AsyncInterfaces.dll | Bin 27960 -> 28432 bytes .../dependencies/Microsoft.Bcl.Memory.dll | Bin 78096 -> 78136 bytes .../dependencies/System.Interactive.Async.dll | Bin 368080 -> 368080 bytes .../3.5.1/dependencies/System.Linq.Async.dll | Bin 1189840 -> 1190352 bytes .../System.Linq.AsyncEnumerable.dll | Bin 460560 -> 460560 bytes .../3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml | 228 +++++++++++++++++- 10 files changed, 237 insertions(+), 11 deletions(-) diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll index cd004ea9a49b2d2e80c431a8b4b0be0ce5f1716d..9854cf9db2e1b423a2d2fa0693be3fa3d0d1cf16 100644 GIT binary patch delta 27841 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 I?*Wl82g}lc#sB~S delta 27457 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 ZIRIh+VgO;2lf88U{5Z1_zV88%FbASj+$R74 delta 31445 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll b/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll index 35906b95ebbb0d862af1ada3afb3b0a34217ee15..cfd93e39366fd26596bb7fc320d8169610c89bcb 100644 GIT binary patch delta 111970 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ɋK-*9QQW{!F`N{yNYdYKf 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 z�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 Sp#o^DVTPhQ8uOn4&i@1Ov*~RB delta 111199 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%0Ap4ynpJPUn4?~3`l`7-ld<_|FDGQZM7 zOB*cIowUH^7h^69EXgSy;ywwpx=HNzAvj$ih0d z$bv459vX95>}|fK#TM4&#TNFL#ebKKxh!!r-_jBbc3HB^JeMV{*5suYbXjV_E=#AF z=dyH5D=wGKxAcP2LS354I=QueT&H9`%1UqIQBDaZJ~%z62kYb~6EG>CeM=XR$BHrn zCQoNw@7Xj%swdDd5LiWTB(R>|MzOoUE?6JJ`vd3rHxv0cS6kue0{R?2eSalt^~Ryq z8(ol<&%e>7Bun@=Nv&{3@NcsDHNLFADH*?+^@z=Jm6%GSMV+R=0Y=%6fp z$}r*Hi^uw-Jy?uOyRiDT3#+_cSWDW#!U>+_Ne%B{It~rPgxF9KD1V`oNFu|uybb2g zmEeKVldVW<1t5e-QN0^mXQk;R23sfxZv|wu?j} z(WCVcv%}M?HE(ZjXCa2X zZK3X&c`i&qtN7A_@6U{}P&ds2m$j|99JSEW=N9UIvJhV?TSc)w%(tY?woo_G0+$7? zxR{Pd*t)Y8>K6Tpf0_gN1nR1|Fsu|hXn2o@&W0GsI2Ewj-g zO-GAguPOz{1%HyOMh7j}h5{w%jBSMLUqW!g?K0h$J>!XJeG=b~-B49SSd0lKG0r+Q2qEsZokF-W>sa z`co3NhC6HOqURJFn!6X8yE~e@ zGuxBNz9a-0IgstpF=o4bbQNXpY6sVyL+F>d?%dXn4w_1T#9?1=2X-aj`SdUC=}z60 z?am4x;6ZtK12_h?cEISPEJ|J^72u<92?O^<3cl!%B}v%XYT|N+w@5?i$6BJ#l(0xO zmXHc0+<*LCWMOW6BWei?4c*_FK94ZFBmD$naxncT!u$}%i@?52G(vTM zrVL?EC4C5CstbJ`VXQO#Ho^>7`WAT(ppTK~PJi@m^4y(!j^dsZ=;P!$iT;{A52D{C z&x7f2$n#kG9r8Sm{)#+Ta`=-t{8{vI4Bj(nn*JIKvGhXvUG~jl`WyDmTKawV%{uxn z62-XFhY_ZG(qEEi7gnfrH&&+a=m=$*T}OUD{P<>{RYA? zZ-4qTgi*dM?C(!MMHn4U|A;WIALE2DC64KYFeiaYC-~X)4+MV{qad9=h6x}>P%)_l zzlsS#1#)Pb$-%-9TFXqv!dSYIS%oNhQYHi8XnUp{VUdhkhOk1;93V~EG2Iakr4Bq_Z-WbAf*p$g6LH4Ih}jXqmPp3A@oi1tm2*vx#tSjWGPjw z$)cxo&ok-U*fG&`GdnO+=y~kGh^Akp53p}m&{x?v8|k;$H=F4%*f+cRH*fH7j(^gJ z**C}NFWEPz_%~;#{F`t1H$T$H*f&4%68_D5ZfH>11kOAh)_CkMuwim)e>7C>`oc@Fi?|tXvUSy<`gED%;q#^Aeqe>Ock+{BfXT^NlEV~&tCLP zF?32n1AG9D+Yy�EMGf%_v6l6~1N#1BD8m>2Ovx>HKV2LO~H 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 ZqxJ9P^`ZNOON(Fms~?EWR?$!9{|h$$%{l-8 delta 11551 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 Xj=S<($ph~Gc;^vw`8NDS2mkyJ-dVL> diff --git a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml index 162d20d700f77..66a2240908b39 100644 --- a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml +++ b/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml @@ -61,6 +61,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Add-AzDataTableEntity @@ -99,6 +111,18 @@ None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -168,6 +192,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -271,6 +307,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -286,6 +334,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -374,6 +434,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -402,6 +474,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -485,6 +569,18 @@ PS C:\> Clear-AzDataTable $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Get-AzDataTableEntity @@ -524,6 +620,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -612,6 +720,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -792,6 +912,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -807,6 +939,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -886,7 +1030,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -937,7 +1081,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -964,7 +1108,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1003,7 +1147,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1054,7 +1198,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1141,7 +1285,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1302,6 +1446,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1317,6 +1473,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1405,6 +1573,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1444,6 +1624,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1566,6 +1758,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -1622,6 +1826,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType From f06ad2fbbb36ab0ddfb3657c187c361df9ff2c4b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:32:00 +0800 Subject: [PATCH 135/150] Table Read Retry --- Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index 01def39a8b870..da15f05e62a8b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -7,9 +7,11 @@ function Get-CIPPAzDataTableEntity { $First, $Skip, $Sort, - $Count + $Count, + [int]$MaxRetries = 3 ) + $PSBoundParameters['MaxRetries'] = $MaxRetries $Results = Get-AzDataTableEntity @PSBoundParameters $mergedResults = @{} $rootEntities = @{} # Keyed by "$PartitionKey|$RowKey" From 390b07534b89c2fa91a69c784aa6fe69a430a315 Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:12:08 +0100 Subject: [PATCH 136/150] Escape square brackets in Intune assignment group name matching Group display names are matched with -like, which treats square brackets as a wildcard character class, so a literal group such as [WIN] Company Devices never matched itself and assignment failed with No matching groups resolved. Escape only [ and ] before the match so bracketed names resolve literally, while * and ? wildcards (documented in the assignment UI, 'Wildcards (*) are allowed') keep working. Applied to the six group-resolution sites in Set-CIPPAssignedApplication, Set-CIPPAssignedPolicy and Compare-CIPPIntuneAssignments (include and exclude). Filter-name matching is unchanged. Resolves KelvinTegelaar/CIPP#6246 --- Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 | 4 ++-- Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 | 4 ++-- Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 index a123691d9d8cd..7c96361eb7bf6 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 @@ -80,7 +80,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedGroupIds = @( $ExpectedCustomGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingIds = @($ExpectedGroupIds | Where-Object { $_ -notin $ExistingIncludeGroupIds }) @@ -97,7 +97,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedExcludeIds = @( $ExpectedExcludeGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingExcludeIds = @($ExpectedExcludeIds | Where-Object { $_ -notin $ExistingExcludeGroupIds }) diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 01cb05ec8ec77..18abcd1c7c659 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -114,7 +114,7 @@ function Set-CIPPAssignedApplication { $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ foreach ($SingleName in $GroupNames) { - if ($Group.displayName -like $SingleName) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $Group.id } } @@ -161,7 +161,7 @@ function Set-CIPPAssignedApplication { $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ foreach ($SingleName in $ExcludeGroupNames) { - if ($Group.displayName -like $SingleName) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $Group.id } } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index 3aa9d3530e2c3..faff217f016f1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -89,7 +89,7 @@ function Set-CIPPAssignedPolicy { $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | ForEach-Object { foreach ($SingleName in $GroupNames) { - if ($_.displayName -like $SingleName) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $_.id } } @@ -129,7 +129,7 @@ function Set-CIPPAssignedPolicy { $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | ForEach-Object { foreach ($SingleName in $ExcludeGroupNames) { - if ($_.displayName -like $SingleName) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $_.id } } From 614949f2ce1c020a825f782773209787852dcd5b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:13:01 +0800 Subject: [PATCH 137/150] Update payload for log collection endpoint --- .../Endpoint/MEM/Invoke-ExecDeviceAction.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 index 76de8b21de39c..8b3123ea19d14 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 @@ -38,6 +38,15 @@ function Invoke-ExecDeviceAction { Write-Host "ActionBody: $ActionBody" break } + 'createDeviceLogCollectionRequest' { + $ActionBody = @{ + templateType = @{ + '@odata.type' = '#microsoft.graph.deviceLogCollectionRequest' + templateType = 'predefined' + } + } | ConvertTo-Json -Compress -Depth 5 + break + } default { $ActionBody = $Request.Body | ConvertTo-Json -Compress } } From aae566d72488dfe59e277b261c9885fa764ecfde Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:46:11 +0200 Subject: [PATCH 138/150] add executingUser --- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 465634d5e7bea..a8e084fcc376a 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -137,6 +137,11 @@ function Send-CIPPScheduledTaskAlert { $NotificationFilter = "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'" $NotificationConfig = [pscustomobject](Get-CIPPAzDataTableEntity @NotificationTable -Filter $NotificationFilter) $UseStandardizedSchema = [boolean]$NotificationConfig.UseStandardizedSchema + $TaskParameters = $TaskInfo.Parameters + if ($TaskParameters -is [string] -and $TaskParameters) { + try { $TaskParameters = $TaskParameters | ConvertFrom-Json -ErrorAction Stop } catch { $TaskParameters = $null } + } + $ExecutingUser = $TaskParameters.Headers.'x-ms-client-principal-name' # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { @@ -181,10 +186,11 @@ function Send-CIPPScheduledTaskAlert { $Webhook = if ($UseStandardizedSchema) { $obj = [PSCustomObject]@{ - tenantId = $TenantInfo.customerId - tenant = $TenantFilter - taskType = $TaskType - task = [PSCustomObject]@{ + tenantId = $TenantInfo.customerId + tenant = $TenantFilter + taskType = $TaskType + executingUser = $ExecutingUser + task = [PSCustomObject]@{ id = $TaskInfo.RowKey name = $TaskInfo.Name command = $TaskInfo.Command @@ -194,8 +200,8 @@ function Send-CIPPScheduledTaskAlert { executed = $TaskInfo.ExecutedTime partition = $TaskInfo.PartitionKey } - results = $Results - alertComment = $TaskInfo.AlertComment + results = $Results + alertComment = $TaskInfo.AlertComment } if ($SnoozeInfo) { $obj | Add-Member -NotePropertyName 'snooze' -NotePropertyValue $SnoozeInfo } $obj From a973ec62c08346a2b3ccc1ddb806cdaca47204c9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:30 +0800 Subject: [PATCH 139/150] Dep bump --- .../AzBobbyTables/3.5.1/AzBobbyTables.PS.dll | Bin 43520 -> 0 bytes .../3.5.1/dependencies/AzBobbyTables.Core.dll | Bin 49664 -> 0 bytes .../Microsoft.VisualStudio.Threading.dll | Bin 450576 -> 0 bytes .../Microsoft.VisualStudio.Validation.dll | Bin 37904 -> 0 bytes .../AzBobbyTables/3.6.0/AzBobbyTables.PS.dll | Bin 0 -> 44032 bytes .../{3.5.1 => 3.6.0}/AzBobbyTables.psd1 | 12 +- .../{3.5.1 => 3.6.0}/CHANGELOG.md | 8 +- .../AzBobbyTables/{3.5.1 => 3.6.0}/LICENSE | 0 .../AzBobbyTables/3.6.0/PSGetModuleInfo.xml | 159 ++++++++++++++++++ .../3.6.0/dependencies/AzBobbyTables.Core.dll | Bin 0 -> 50176 bytes .../dependencies/Azure.Core.dll | Bin .../dependencies/Azure.Data.Tables.dll | Bin .../Microsoft.Bcl.AsyncInterfaces.dll | Bin .../dependencies/Microsoft.Bcl.Memory.dll | Bin .../Microsoft.VisualStudio.Threading.dll | Bin 0 -> 464224 bytes .../Microsoft.VisualStudio.Validation.dll | Bin 0 -> 45072 bytes .../dependencies/Microsoft.Win32.Registry.dll | Bin .../dependencies/System.Buffers.dll | Bin .../dependencies/System.ClientModel.dll | Bin .../System.Diagnostics.DiagnosticSource.dll | Bin .../dependencies/System.Interactive.Async.dll | Bin .../dependencies/System.Linq.Async.dll | Bin .../System.Linq.AsyncEnumerable.dll | Bin .../dependencies/System.Memory.Data.dll | Bin .../dependencies/System.Memory.dll | Bin .../dependencies/System.Numerics.Vectors.dll | Bin ...System.Runtime.CompilerServices.Unsafe.dll | Bin .../System.Security.AccessControl.dll | Bin .../System.Security.Principal.Windows.dll | Bin .../System.Text.Encodings.Web.dll | Bin .../dependencies/System.Text.Json.dll | Bin .../System.Threading.Tasks.Extensions.dll | Bin .../en-US/AzBobbyTables.PS.dll-Help.xml | 0 33 files changed, 173 insertions(+), 6 deletions(-) delete mode 100644 Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll create mode 100644 Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/AzBobbyTables.psd1 (92%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/CHANGELOG.md (93%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/LICENSE (100%) create mode 100644 Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Azure.Core.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Azure.Data.Tables.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Bcl.AsyncInterfaces.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Bcl.Memory.dll (100%) create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Win32.Registry.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Buffers.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.ClientModel.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Diagnostics.DiagnosticSource.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Interactive.Async.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Linq.Async.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Linq.AsyncEnumerable.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Memory.Data.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Memory.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Numerics.Vectors.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Runtime.CompilerServices.Unsafe.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Security.AccessControl.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Security.Principal.Windows.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Text.Encodings.Web.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Text.Json.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Threading.Tasks.Extensions.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/en-US/AzBobbyTables.PS.dll-Help.xml (100%) diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll deleted file mode 100644 index 9854cf9db2e1b423a2d2fa0693be3fa3d0d1cf16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43520 zcmd43cR*81(>T022>}9xB&dj>bcyt81?eiHbPESXotfQpa&Yj71&E0dV!`+4PlOJ^ z6@T0a{Ldg8nz{N%xM;uXCBs9EL6;0e;^Jin$x=zIRFr5CB}z(?q!>hs4Wy|_2JuM- zUjD%biIQlswT1@AM6No(8=*lACW<}xqFgarH8L_#W7s0J4mfO@d%hH|2Jo$hFG4(m zQ?%Wff$b+B0|r9C#~&t&n1)61|E*U!iCOST1-_m&Rg+&~3Z#RE{K}PIHRV@WO_sTqErkZjt)qk; zU`UE71mCbJRu)!FDP>`O6qlWa^-)|_7S=~`m9nrtimQ@^^-x^pEUcH}s%8;8v}{>u zD(D5P`OFl6x~B82)TDqU%d}yX@j^f?I6@u3)s)$iPHhy{FNE%#Nxh(p zsg+jOc0br!;5L~BFio>DS7M0;r_lN)34kNC0*)2c&U{ikG7t>R!KPuin}3pyg(0(v zoDDD}wg7~70Hh1CE!Yg9J%DT&hcyWU8MvOEjdkI`fOw$;gCM{FwKRhuzyQb741xfo zqk=&YV8C+F5(ELi=-Miw?Qmrh|G+A-th!LrA}~nifK7V?n7(d1nIpE&34l3;*Nv?% zpoHClv{a*{Y803cE6S#Xpn$C{UYnkdpO*YZm5ELTAhZcfd?&cMwF^mW_6TR!9#%C3FE9O4t{m#1#OCEnN-+ zNZbIogqcFPu-Vd$nB@+v6eWl;HMX?4MW&OSz^xl0ycrq-CUKdSo?NX5v`9PwNS{J4 z5FB4Op#nvL_O%8etP#y12yM$9 zumythf3$@XLMdmrri>L-y*}72*l*GRRg~PeR7fL%K^F=gwloI5Cr^Xs9+ET7*Mfk2NDDru-DQIf&jxr z!5|1QOce}*00SmUcOl3o3eOzMXlu4~;3%91&NTpnI2Y0lVuy|D;0hW3Kdu*~xErYF z2oSwV`6*lrTNbzMCIY(*K-gI5NwG<1$!O4^VZl?CgaJb8Hc_EUFX79F992uUs(cCg zT1pgO>6a*1tl@s7cSW&=W~lf+UE>uouM1~9h#4%v7ly-?#0|%S%O-JyXMp2|-4Zu! zOW0RQ+>m{gLTad!DXkxL!W_uv2s;kFVgykP21^NBAKgF@U_j>441xdyR*hy51Q-?y z20?%UXD3>MAi#j56U`t9Fd*Y-20?%UhbEdq5MaQZX$C>?S&st|j$k&p4oAQ=VEv)q zTUZ%U(g(1}u52vIy!Eui^nIK^6|9f4G0@eSgx(H`WdTfG#|KNZ} zGYA3<$P1c55Mmpo0KQlHpYu+Bbc-h5hykd`!45wd65xW#5RbnGxuTYv`2JCw;KWH= zL=Z3$5}0NX1Q={^byhn_8pEyyXy^ z0DK6HWVk5K%-9uT;)>r-wUF;TFcu{u(?u=kS`&3R*W%g9w>abxJkW!I@4VmxYlG4p zt!=FxY#r?2fQ%1lFeGx+M#y*)LIrSj6aXHaB8^Xqm0^kNkfzSi(RfHO;(;IWkjDLo z_;|t90O;FLNQ^xaBoMshLxEYt^w+7X0v|Oq98d>f016cmwTCYr7AuCXgb#Bg_w+a5 zitU4ZM3DCgQit$yUr0+Li?0ZYIrMDUC{*RX1{=L0aFB9^h9>%k(+SO19;3=e*Hntt z*yx2ah8qYhC2$GhM-ZG+#u|F4ZsxF24Hv`C$`~HyV3?zc;RwJvC{tyH8V7CGSfR#6 zetZXjx78ig1ZXr*s?J7+d_2l#rDBj70Wxej^5R})q}N7u+-oN%n$1Nw)cSHcNT2Wx zRk6+;1kcsPGN+l*Y&INRL&1J)lnryK;b2^y;Ogqwo^UqSECYECN>|2Xbt0_{GNL`v z#wO5-w6aLA8deP0Sxsgd!0`voUs)KwR>Rs>FtIjsK8CZ1=H6g42U!!#&vCI;KI(Xm z*&4-MHhRaxmIz@)ydoYLzzV^-KuEr4ui-kiQ0}lgn4%B^n)&R$Tz9~kz~P}3USF;^ ztTN;qP^zlwYJ(9UaYi2f+u`oUfvM#DX1&5P^+cb>_49v-5d;-{s;5R7DsU(tT2&X6i17(NyD`9BO z!O(@*4BC!SKLCEFC?*5-1xW^a3Aa!T6hN_=6uQmi0PMoj1dbz&rI4q4eIW8}aXV%^ zklaXh1o(h~;d}~nGC-T6FSegSY-SK^83NWj`@(o3^crj!jTwDQ!1)T=|D;3l(rI%rwlpuL&*|mpDm|esoMdS zOQ@+T3suu0P8Z0j$!sTRTOp^yKqrL?@TT%nYE)7gxc`stzFZTjhj! zBbd!JKosoNpi`x*zz`WAH97TMa{<#3Y00Vgnt4F!$*BX^LccSHzc7IK#W5PI3xou^BRwQ=3$I zKv~EsoW(-qAg5BHpAb3ADV)>Yke8gg&Eo+zSWe+uV2*~!sXX?2pu*%7&UXtGBd5-D zctAA9DO{JF&}KP>ZRw77$ti40cXWtQv@JbQhIOkga9)O8!Ce(YQziTy!~*W_ zndl~PSZEG#lu#WUeA#Fp@Rd;s$g3a&H6BkDd1&BXTAH}mA%YL#V9tK%#X$I3Cx!YG z&9wgi4D(T<2XzC;Lv{rA zBG8>c9|8jj3?)zmkc~0{wnvWv>cWW;b5sfB12jY;fMzI@;5h(!XeK}#7#T~>Byb_& z=P^ENx}&Y=88Zmr1@&P7L%5?+A)~8W3`q87k3)eBXNaLfhFEg~+Jm0(rvkK*b8=B0 z(RP@?3J3vOaxvPCGaW(S6LlKjG6=E4&w^97d*Z@!1D#Th7$;!@yy|j#|&(9H%193473?) zNEn5Tz05rH1?>EU3>imNQ-B|easU?c<^#OWna_B|P*Gb9_9tkrW|T6{s;y_#6Fqy- zcupDPD&rrhp?lC2ZW+kmVX?vT3N{b4IdZERCTNaEJ;M^M)}*MbjES1c)E7oG%!onV zg!>0W#!8k0mB%=(=}$QmNiQO4$QZ-TqI4KOU@e~eZ0L*kT|-7X=r?3cWff3{jNPmv zN(JtNssQrPZGi321AraT6M%Z?1;B35DnMc)6A#b~2~Z{58ty4?Lj~1Bj{)jJN?XHD zrV7vk1p;(Hu>kv^6o8&62cR$E2Ltp(BVjC0c-(cHiXeED9AZusibBP#Y{DrZu!O*B z0uclEWe{jUpdo?AjIroG%Z=df1o|*!=oTA8j&cOyhzN`aj*~KmSpdg_ws^(_fZ3!q zhrp?zO4hOjUXIa4Yb4sbYAW zKqhA^ktrdqr36+GnF_+MCY;9vz5;$v&MW3(Bm#&>e8pUek~j<&o{=gG>r`c7z9Hds z0}hkpPH-Q<164%?kH?Z!JZn9g%*iI490F$oU#L<*@InH&5>6?>D*&IzsUZ3*SU7$j z6aFirjiE$rP{JAv3EmCxd7N%a*q&}mSl*5B-3j!8R#F}K{z4;;|V{9;DrR1 z5LilJHGz)_L~J}FgFpiU4GDB7P()xhfjI;g5LifH34x^qRulM`K%`7`5@az8sRnuGq6Jtdz;vNznjkGWv$uouFg$Zs z+R8*okG%t+xyD|Ary&avTFpKHFq5?2=V8u4gkdK&3>Wf_0({Lc0r;NCAl7NXoi#D+ z!o_-M85)+WxAvlObHRC#`KWmrphz9V+nShjnrNVV(YDd{w6%n`jkZKpfJdZlqwQ}y zE^Q%g8_oaSX8f!QKdoW`#P9Mb0c67&kb)Z#J}XgT@jqFOaXUYJfN3BgoUXkpL-pw&nx; zaDWtwgfSVg)A|A)4P!E3haU`h42%iSc>q%Iye$y$u>dJ}q8$u)B0vfy!I<#$2OzvO z0b?@IIDmcOEa=I2&G^c&q(syh3bt@)Y5R!v6r3qb5hq&v_#;oLSd=0j z7M~Iq>>iBV$9qU3BQry=ZkcsJFiI08q>97C6&*Y!2?=7NTV~x~oFtaUN1(x2?$vGK`&a{I+6q==<|@&}7DS`C&3h^4_|Y1+RC z28rP}8nNt8CG@%zxBA5RNm9Hs;$wtpSX_LHI5=4pC2l(m zUb1*#rQl3c5O^q`B&;7=FhP=mBod8z;ufd_B$9C`1xr#PT@bwP`&WLDI8l-&ZZkXN znIINP+cNr#Q`(Y#;`Fv8t$_Fn(KuNTh^T&2Ng~Y$4SzC+Bu7J8QSg0|Xi@j*Xay@o zLbHfLG}>C$kdIfqELkGM&Owj{v^Q{S6Soj?Lg550Io= z|GEiyN~E-re=UNvkN#F3k?gPBVNytkp<-#IL?(uP3a_#lg-A1DO#m4xN{GjSgS-H5gH=H8$nAKg|;A3Qd_o{I5IUh7RO4fsAAzl z;#1nP{ZmttQ;GjtMMFeVSdV@sX3DkY24!&&242wzQ%*Hb9h=*(w+W8%}(p znDA5LBjXdGytlS0bdx&r7d~udblvq%Om5pR4IgBXNS=}*k|426l8`1=9Be}3;2abU z#}EZWZiTgciEs-E-C-hW5+;64NqWC0$yM_^RF%tmCq>J55j=*6I5s}1 z4Gpy@xeY0bjt-}%frTNzagl@2h=U_pvN#EL#;8oFIfV0jfg;RgEIULRkLZ2nkJF!Z zbV5Q)O41I8;mNKF%aAIT$(>D{;R6Rsx%1F3)eDz(x@_Yei})ynV6Bdk)4%Uxf9`8| zmcQ-%bl()BaiAnV3CULs{d+mHhJ{F@6OND@WDo)CjGeUj1?jKAxU#~|dlB3fvh zaDG_3r>02g{8ltV>d6nyWB~7sC~-30!P}k(TA469E`lvCY;94bU@J#%NXtI!o)nGa z6W2<7uEDhi?g$jKA>?4#pTtsv;t<9sPr8Or2P*i4ByM(<6A2O8}%;-T%zRP+CT%TnBspE zNc{V$8TtG=C7-1Dlz3Qx@n9TDcyhvPx#xoI8^@shsDdeLyrlyq7w;sP|};c2=e)2DhkBFYfGUqOz8r9jF(BtO|JoT4CMnhF%` zBxfVp-%|n4qvaTY4*>YkBKw6)FAi>#{YxF7+Xlx+NhLB#ObTRHwAejKl#nTlZ<$z| zn?^K1oB;KMo`eV7T12-*iww0Cd3uNa7qhMDjYNiE8>V+8;*r1K&k*nYOH&lN^KEnU z18I{4g#f`>8xj}=1p7yhfdd;I268+gm>!;t=|$i9(&MK}rI7vv_mKri#WDruiSM9j zLkNb~58pl!7D4=zd=l|#jC3aVP^68<2Ez&75C6d$?gti5i3W_6EVJaEy!5%@q zez!iI_$3b7qR1SvT~S0fiue)tj>SGvwFX-f;6G&Qj>g0LjS_eY83}fVfLtW;yA0;; zNwmNltUB16*e`ev7)HTJaKDF;E0#%x9(Zm^fMd&WONv}mJm3n8l0bsCi1rOwqL2Ik z&i4k7;&qh55tr-1LU6$zj@3NWPaPi&F`jXjR_U{5Jl5Ov1h!z-k)D-9$Q zfJfq@n-B0?Ie+R3k!wQA?$9F|Aq{Wnp@==CNn4KNCU3cnMB{qIG3iQ4pHWG@VnFZu5wZO}&SR}VW%jT z6+1%&X@gs2#C>=vW8jO^1xrc*Mzxs=c47(y1F~wJl%HG&UXXFH{P9FS8m8E?aK1R< z#=;keJ6@P5vOvEEK-=Y-01x}KKrS70<3+=XEl=TIzXpj1EX=PnA4Y79L$r=IR_L}} zvbLi^dW+0plwSkn&jRsp1pp_3A{1Kn{#FjM-}-|XcC`dtjK%0I{&PuN)zNun0FtyD za6J9J5+;$nOMq5dAI?vlCAiwqah(brT>a8PTRbcP&O=-yaOSsW1zkCDZHNMXDue+Y z3YgPsbK4M-{E>IK`k|b0?xYLus3=mc6T@cx_euRZmDZw$BN-QOoM5!~aXKfE38w<* z&(&*P8SDmJI_M1rFA3Ixd*Gy{r%Ah7u}FWPMcd_}^9z?9+J?W49}UZhUDs*}F02Od zgU#RV{d4|`f`(%&24;_E8V%cx2L2nR?05Krzi}{33QQ5+PJmy$Y(PW;>xl%c#kzdn@MY z>8U8Q^aMi$p50j5C#CbS%te!E1wfaDfO=BpA(UFMz*F7%dPBM8t@W8emcz9-^QZ z8_NsCxPu53aJd|17C#gIOBH0P8L$|F?2TN0_A@<@1N<>qL8Oo~hKx*!&Z!Ov8K z2@*r~_?g7i$2?^vVB}F;JwZDPDv-Oov7w`=%h z{tZrV7J?6pfdPKOf`#AQU|}Z3R$;RFJc`X_vIRcy)q`(4Cc86S`Kc`Ic?^~UhpQ}~ ztQvSp5qIDzJ`Z9>1rS&;162l>&x23}oU6jdnIQ;NK@>zFgei>5z;f*%EEs}zfOdwJ zge=m-YEm)EPgOTy!Js&0E2b_KqK%| zF~(I9%DbyD;B^!H>B4|FR6?i@!=$2QKS@%{GiLZ(4Mk1@PtXi>nSct;?xwWD8bSoAT=@}J_??zk)KLj9PA=v9C|u+ z7ezaCcN9CoUqO4)X8zwCz$MR?TfAUK>*&&E{w)ue>BnB!Pqa(m+2XG*(S^>y6twBA zc)p0V8Hi0kNc*)<=NA5-b|Zva>Bn+^YlUbg?_?o+93c=4umte4GdwV4gm8KPd8y6+ zmo7@=eGSMJ=5N|5K0N1372|gW@N?S)__g4_59#{+!c4ld2H{lR84QMxoNR>^CuV&4)F4b zhKJA_CZvwKexd1yoYX$o zIR4B1uisxBoLtiP!>Sh-k3DjbRo-c;6LuUUbVICF$HD0F13BtHrc9(>Xdn$ZBEdi8 za&f#LH`WUN5f4fat&lL1=P*lNI8^c&GcpfVc5Gq(ztfM=n=@9z7>i96Hp~K+s z#~(AYBbjL5lo?HV4x8HPmyBGyE<`wIW4hB4{Z9)|ZTWt2Mz<_}*RCCU>MwOh+3k>l zD(d;hP5s-L8RVa#v9UfgR|8eE(aTZ$*WM}kR{yhh+DG^&;>OsFFk)Pt_NZiEJ%T3_ za3cdxgcwJV>TUyf_e)oQFb{Gef zO3^FVXIp)6Si8mT4%I#`w1D!Q9bRO1hth~1QIIoH%`qS*huvk(!Szgc-xLGUOm2hK z756-)-V645DVr~Q>2^=hxnt-$rmt^Gb;L|=#IlzqX9b;E!`Dq6x$LFEc|qs<2c19U zFix!fd6qS0=nA*8*R?Jk7VImd%!dwlyT?)M7%E~oJ6J}-L*0I$Ar~C%3{ZAHwxybsjd$%>$u$Zx#^r#)u`~Yp37~m-M-8% zuDqprMxr+^=dxwxr&(7*o#8e3#wll+i1#Ds3qoGms9EhWg# zEM0qAyQ^Eh?%D@qpS!--xh(A)JGtp>vvS`z^EIw~JL5FDdSB|ow48C)&1NjLo7pLQ zf@l|$k^NNj7;}<{LX0WeAJV6*GFUxVpMIz|NZr8TX{2TB3YMFj8Oqj1C8?lp{_?My z6T63XDS5AJ@jb`ey6*Cwe1or^mwzhvtx_BHM9HS!Bl}s8H784I5H--|<|Ca=i1+^E zx|91Rb^H0=tXcWYp>y3PH#}LMG1K>0jcM3xflb!T<%i2Jggx0c>d7 zN*Z)!6l(Xg!`q|$nPTz`Hj%(jN2w&03t>oJ2=D@yd>%+(AxZ2j8{yxOzk;CNq z{ojIJUFP~8du|%`MzAYu=B>l!Z^E85TGux(&zS0a?6K*?mjcPenLQ4duMB%~$-4ge z@{HNO$DWx!0BeiJQ`olzZCL zS1!+3?t9Fl`paCapC$R)-;b_7TXM3oZgRw&+0FI-4?hUrrOs?RQT}z*lU<{qJkGA^ zVSS`z5Tb&t&%96@fIjHgf2u8(uK5tQYS^^cTl3W$D(_uZ(GNJ z*vi?f#w|0u%`iX56D!AM_n*_bfGJb8%T}M5t?I@eKi~(_iCgE5+AZjs&sb1uG~U6D z?fcn=VP9aW@e=hZ>MC2x4bBzJzRQ}To)G`t@Lb8_ZpaO zl^J)93tm_DeyH-yZG}x)({@dkBacy#gSe?VUyQ2VOLW*x-omx1Iv zu?GVOZ0E_$kj?8br#pM5p15z&>?m4f`0}b%Kl2UU=bnn1nz_}n@0_Aw!*31inWf7| z?CH2=Nx>AGj|DqKcUeo5Ef2eS^qIW8*nH%Y^BSp5{i~uU{?qTwlv$q9N1y9W-}cGM zW{|~>{Aqzxy7ypNy9F)r2%ctm{hMC$vt1S$eR{HVRzDaO?iem{J!HJ3T7T}u@zXan z+*(@8|MX&b{jH7ly_(mJbPw3wO^2oPFuaNz9<}())gc0rW8cI&>!b|!Shw+Z=0g|1 z;N9W}bXn#xmE$&L;W59H$bH6Zl`PwV)5-)rTm*wN#S7W`CoV4nKGa_RucWKb&fjXQ6r$YU1Yc0S1(#dyMhxf;XMG*tak8b*>bjym+6{V>sEBbmC zoc0hssQJ-{L_C7nBd>4R3~NS(}N z;~jWbw~Pg+?W<>QFx6H|*G|oGNJ_h{xx0THKlUF{ec}2C`JVo}Y&YE-oHcL!0lz+i zo-y+ls)v@Zh#Y)q#o=ca4&Cx(lc#rCd@ri6u}f;r;&9oaoY&vnnjcqB&+VZh`Z^=l z)jrsy!`XYYR`3oqvCdf(4@}|eYS*dNoff|I3RBJM^=XmRAZW$YaR)c=@t0LSpvEPQ z*gEmsE~SSdxfbJcOXoMPIW;TY>Yd4f+N>w{f`_iINIl=n)b#ucb>~UluEsgb0=~{n zdw2S(@u`{7tN-~iHeGYDt7yH&_}-ar{1Y_|etwfL*6nJnc=2*}<5=mbz_M{2BRiZk z7d~Dnd>!f3=lebX3o!>a$Bo|Wy(^v5N%BwX!N>jf-#^7DsEEEZcI31I-$tjzJk`nH zR@Q16bm)UoEH2XG-uO?>oaTpZ1wZ1r_5JoH#cOYPaCv7Z~VJ+%T5=o zUnpJFb(P@(FZYa5ADYr@HoA`P-?aYRq2iAdP5V6`p0vQmY>~*iV>hLS#BLhg`9%i@ z>Fe##=w0M6F2(iYmWb>c=LMf@1J}P@_T#kg4TFgs<9v5pd&iYaj_y&mvL8P%SjpJ@ zN!A_4<+@WgpKU%knN8xkzVp5AS6DFEvh-D9oqE?NGw&IvE$r#hd*1ayU3xwXh|(FV zH7U5TAMd#N$aS0UH~RED{Ao*k;BoiE`L7O4b%Cv7j=}U%;nL#UWB57KPNuxI?{a8X z)-8&!Q{=UJ`<~j=>PH);eI91|T$wp%sj_yEZ_lQVi*sh0tl82lX=p@*X84f4lRkDG zKV#*=F`H_RNj!$DOg5gW++Te?TC#4l_)-s-u|`L>{N%l9be#3z^4j6|B?dA5*FLQL zQs3cm*^NivldGC55;k6N{1MjkO@;Q-F$+f;`81h~Ztu4v`CYkF#)*}gHqzM6E-R5&JDvY8)Bc=yvX+!ua>n7 za;_cZw~t{_-2VKoFAmCi%|=6CMNHz%TQ6BB`hIKXwC>A=ueR4&Hmxspe7dQfas*8S~Ua0eZ{>d$J zPSvjR8*o*L`mFJ>dKc^Ya~zA3xnSa_xkYyFE@1F|%E) zlX$D*$%uZvEyIudCQAzLPCS2W*#2v#m1o6HA4Zu^-VhUdV&anVmL=2mGe+KsY`XW- zCon>DllJ+H`{nw3j`APt>E|uJ-}mHf^X7V!7k&d%N<&8SvUL;tzYM(8$3!VBt$b-sxca_cm7+sp{Tjc7S=9l#-Y&j81ObyeY%Kjq7+oYI@Cc`elG5|zYTW;A<*hu0ZdanM}<>P`h0+)QPX!u>LoeB%99w)bqWIy6jXHNH7#cm_ z*<+Te$;hQfQ4xIio0T1RPVvgLs(;SZ-fUKT>EM{oYwn+`-QYd={$QiHMLeIy#>MH! zAJ1!=6ycixxo*zL&oZBft2a|x-SwYJWWwTIg%Pi`;=AejthApn#B*TcSLVt|n-)FH zeYx)PF8>(U1G#C(o6|m9j9oD3$M9j6jxWtiRd+QH=@c{fRJ7wW!4!|}*UlH*j68MT zdZTr_%_=CO^udmDOvGC=O73>Emw=JKXH-Ci* zd(=PgJaz}(o*5%Pb#qD&wW-;q-vTFxj?oWWH@R@oVyo~$P3=q(Gv>g<+2&q`Pl}W& z-7TuZ@%_fH^}XREn>Fvt-57zY{^S6Y0=vxh2eQ9sq-f7CF6>viYSP-wJ0X$kJ68>2 zCBF00HM$i(qH=udkyVYUWnaz0ix&5yoyJ?mCgVA7p$6E<-(y;T#`iqba)N98Zg z)jyH4W6jo43!gnNmh=@|ekjm6XK7cjnRzzsYO$`-;GLz$TedjgIKNe{X5PiGy@?PL0(CUU}-%*z9Z##?rhad$V6t zOzoVY=hK7jvef6#eZ1(nskzJKc7~>b2kO*5j+k#hx$mxvDyFr|dT)Gl+@-tA{`#WO zvijK%jXsUYE9`!Hibq3CU8u|bp`9k#dQI#XTbhvgLE~&wSxxk5{+e~44oy6au@u~;S?9Bun3GOc5m7p|HAm{u(5ld?Vd z{A>Q@Yv&@)>iul)9=zE|zZbJ>;FOt`Z*0$_^vmD5g|~mW6+cyJaQ_m0%W&t}X!Aw7 zPx@b&v~$AHX9Io0Lau)4TC^(b`eT`^mKrreJ8Vco@11GupPR+>ZHzoz<+Wl@ZH4dl z_D9wfwLkN+*+814RdRDdx$%|hDtbm|_?wIG8ahq7*VN^H>CH6$WIvO{jDi=F1{xpx zZgMAfIfp;V^mBReu`6f3CPr^eeR|5oCgaD!tvb5f&NO)Dmw&U^{OJBhyE9H70%z@6 ztW$LR`KID5&Q2F$MSiyZaF*fGKp}BE>m)0GWTzK04Q`5N1FXH=LEX~+( zFuK#%6_W#+dL1{)?WSfNomn{O+Vh1cn_l+m*Cp{z<%poIjkeeOss@E+IyuhR<0Wjr z(P5wob4S>v028Cq0qPsujpk$}u3fKm_Uotqsn%KFg%$_3Q=K(aJ}2@fh9#`K*dua% zmGRYtur9tzyI#3m@$Xssa&F;m&1;)?#)u~wE;*h&!{_Uen|{50E1k9!7Ou@>Ei$p1 zd)m)p#?t9$Z#eDmzGc6*^Upe zaupBjW3IbkV%@M&3tzY#8#kt)1OJ}?wD<2%bzLS|-EH>r&>cO@_SwIGv(ix+5Vc!$jrS5JaPN zwd!sBOs|t=qMGAtmY#i->5yQR*`WJM^~|Re?bVdL@8)+Mnm=}_C|{_b-&OK>na`qA z9wFK{=Tnbu;=GGbPBYo|EhFEk>-9}<9?a`0?$!Ndy!3d_qtotvJbpPc>FtT7dza=7 zU0##y@0L64w)Wv+11HSSFn`x9ODNjKW$8}7clh|R{F&)*hHNb@dVjR@nax_O%NKt3 zT=sqGtT(QUJ{QlizklbE)vR-NH@m$!+|;A})2NG;2L~!|X|TK=>}nf-%V%4e&Gq-! z&U7C=1_kR6@`$mxd-cRof%5CDYY+q^UShi)(MxlCd zTdQpuA2;lF=r`uHMNoL3ZSKKG3mUqnKigYcpL=rCzJ)bGFE+E(^d_HNzWQ6FtD5h@ zK%KzaJ!iM+zt|M>*6He~7yb)74$&LK-q!b-lQME*)u|cQr(I9I zxW%fUyv+AS?jE1wInT|Lue}Sm4q85P$;kUP??ZYnbYZ0@*Dz|U?T*NLnNtTXuFtx? zvR%y8LDyCa7Ju}aH1lo9`H<@ux7?Y2$LZbohldW8F0I;lZOBghy?XP%eRKXNbn(W= z8J*IvUTZ(zIr6Ueyz9-~v?k5#>1t;hIPXEllxt;==lW=!j2?g0`>Ti1LcLi90f(ue zW2;o|SH%r*UA;KuUF@U_f|?bMDLdAe{;-duy8KA0)_%HYs`LHh6ISJvXGN~O(3I9^ zMD^P3bG9y54yYJX>-u0(_bXxNFZD4s8~QQ(?x~+bl^>RRHOZAL#ja_Yp>LbM>__R5}CZdbPUHkx4^I;LP!cAQFp4IB`u6N^^x=@Ejx!J2$ zbSO>soKWkvJUi{u?SC3)U%sv1uY0~l(ORR#pbZnhyUm`~q3&Z|k8$_z+}0=#ieGHl zPQ#ejEAm3X%C2vxPtUa<{6fp)%Hn{gQ%eh{KhC*R+y0ehsQ*RBK&6kn-LrDP3a6T0 zQ+^rYz9}p-E|x7xu4w9<`J`Y1W5cdNTe}T4YpBy$X{zO1l78Aq=h^64?*sS8&#Z`V z{?hKxFFis$HCAs=A+TzYRY3hQ$u*VxM^P^?<~tL-%`S)$QzX zb#rs?TP(4mdiMsq6P`b6e8*Qse|Elqsr{oLLW{(yuT;NxT(hY$HeA+8aJ^5JrPbZ% zg^dFwQ;M7NhgHuqHZ*)yKh;N)bnU+1@tE@kBUbD-y!oT?{G{$X($2Z>?R}$g)X#pU zSA%~PpZfMTPPcsF`;N>VGyh3dTejECc*oEimffGMSo?XjVCTiHF*^+h*S)cldJGXh zaqZyWU^KO|&%HC-`=<+|W*xs>o_@buFBQAIN_C@Wr)_&%K59izdV)r!D#1NqubBp%3h6n;M{4E$+DZ%-^_gI?6NB4!Sk+$ zqT-Jy-#s#NI&0GHoB8n1OT*6X*mYW8dF;@Ynx}g31S7v`9H@UZwGU;!&8jwPaF@Yj zm+1CVe^c(Yy)i_P+9_y<#=uo)t~m{IEs;2`R86k3S&^Yu+^uJqkJ{&79~P|)Yi=Gn zv-mn1I`iZ|F}q(>6*OKC2^UQ7Gi=;&rLPZlxDgqBdkq}D`p32jpL6C_)ab@sxw|QF zdhk?9zQG0N=w8;}|KSx%)(CQTIQh?cyy@DAbCJiYnzYl|-1CJdPj3#^tB<>>r##ki zQqtM`UiplXHgOljI=@&r zP{8XqVd%Uky21uwexIHhL#uM?zKX6-e7wBp6VKi@i_DEeD*TM?f|ZIUts0QD>a|Ma z)vBpm%N8$)yT)Hs{nTL4n|BX#Cx}^otl(}O^{p&8~T>qLkck8X$XAjK{msFe{ zU9@`rm|Fvuz8pTb--vZj-r4ULG#y>PaBj4)bMy8eo~1_?&z?VK^|9M=_neyEPtk6- zP|4+*L#6FM89TmxcsP1&($zcZ>+;QuZyi$>_6XNX&6oBM+Iz*!`OR|eMLX6^f4E=U z=CI_+pzt3ri?oOB9_u*cX_M+c<<}zyg}>`{W%{ERUd9h#@M`?}NW#|6W#2`le?-*b82;X`UZTB3RH_oV%-eCf@<6JcAM;j$!SL(#ND zXNKN8dT2hvVigH9zmK{`qycl}oPS3uU`eWkbU& zYCaXD4mcEi>A-}+>S-&3Exh-Chyidj=T1!4f}@%@Xy7q+|mEQ z_Jc8>o(48Fn5uO5jhP;J>Ye!Nm?e+T=RJL3`6`HaWl&n$x^JD9Sr^49HKo65IMMjw z#n!h+`+Zm=$oMwwOWmDkrM%l_w=1Mu7mQqCGUUnVOV7`8*IceS8mlR+ntga~nNrxg zAK8~Cecr9N^6U0%qv78gC>Z`cG^oyH#IY4k#;@F+o&~9OxqW{4j#2NFCSH59e$y9| z4Ix+Wt7g<3w>3OeXLGPNrTj`^(OD-;Vez1YUR4|R`LBD|<<$|JT@_nZXS;G5u5tY~ zENoKk8?^Oj&kaZSTuPnLKK*{rH&a6!21xDiz0V0;r`fM#rt+{w7k93{{4pnO2YYS5 zVHzDOOH$*%6<*)=rS4+Wu%qXCWNfkv-CLdUwBpOog;J%pV|wp6Gw1fFd7u2F-G%SY zP3iV_3WNGE`cc+oug!}_6lW*Cqo0}StFRvbHI<@v->dcOLOf9M*MZ)-Md@dp-Z zv2TSQt{wBwkOleeGe+NvI;m@?bHOF=tahCGz4Ot9tCGJ~>W{97sCeHvlbN<*2uJ;J z?W?GfCYRrwd!v?jPq(Q3(5x*-H#Z)PVO_tqXWqK|yIz!ZZyu_Bt|nCd<*vXUxvPWb z&K|HccV*{Si@y%e6fTM!9-DP;r?l z@SADkLsPYS%rpI39(rW@nvVOuGj_P#I&Ea_bIUN!t9!~CZP#UySzaFE_WZpOrzcN& z5V2t6zFgxQTV1w}U+vlV`1N_pp<}j1-f^4eY}Ea;nawmoVD-NCBRg%}^gdeIV&C~& znwlS7TV z?mK4O{XOUA#Ol7=cc4SVd8?{*LoRRJy!FDXvD0?>Eg1eo$6NhpbJ*))J$rpkdwqGq zq8pd4>~6Zd$tquaP~!NRNe@PaF3}n@yiBwBb4KLX(GkwojMNpwbOoqKDj_&G*WX27?`*;Wzr8GR?IN zb4q((ytTjfApPD=y9F1DK32{0A249|K-UpZAGp=mSd2HR-Dv+|s3+l}k{K(8z};#%;!dqXt$M!O7hdhhVX@WGj-%WJ!oOH1;myLhhd zmG;rxu#ful{wa$GMt^#~c=Os&Grd70&zy46vd#E7>B)@DTcM^UQ@I~TA={;w0`EK? zl03dVC&Wwid0an+dz??$t#LQH2OSq3D!&nMBJgVFGEw^Q{qvqb*bzB&*P-+srMWfr z^-c4auNpM0{__GIO(}cMnL96h?a!Xwv8A)uw>?Yq_Iob)vVGC3aXa>2?_R%AIpJi$ z$oGQIoAlIfS1W(f>`|@t+Ku^R{eYwT=h0&g)1Axh7gu!A$yoMaK*{{3#OoiYpT4#E z>u`?`yIzh-EwlW%5M{qkA8*CsZ7)c=8a8g4XNY#4tLlSNyGI*^YdhU;@Y%|rYO=t_U|8UI!wAQd*0Oluf21Qj;#CoeAG!gw$ZWGv8|46+a23h$F^$>Rz90YHiDl3Xm*WQ9AjBFKih=&bkL8Bn zI^T^!KyQ|HtAg>FvA*IZS~a(?tYMNgO&q~vhZ=h_FB)fgRiuDQh)e{sj&4LPB0&c( z>?|b5$)Ho*wqx5Gr?yYU?oK$1{UXV`vqd6!H@s68JfX!$LSZlIzz6q`;?ncHGa_P< zBDuO+k317OeU_v)5H4)7uwxC<9HhK7x-)^|m8neiA}WPdzNLV}OD!@=(oZ0t&_e0&BgU6l+8Cj-MBcGL^eSaB7(gWxIn+2v|}I^G|t%JRKa;73A3#VPEqh+Xi#+ zOaYf*$UV&^uBTF-U)v@(k>e!>uHxPgAV{@A)g@Sur!b*M#C^ z#E3+{eg!uD{+^Mu`z~Ldi)ub^kMdTY33whQu}=rjrcjeKh4Cz`;Wm*Ac8bmTrGitG zFHOuH9N9`d->PkBskP`t1??%b85#W8iUDQNrrivzLePkq*O~vIPah5KRih4$)|Ms~ zv|=Rh`N`xZweeHqzWDW{ZSZWhTBwssQ##b<1#Q8D{>c8h=B&)2%aW98%-~z-6+?(| zmrT9=!FK3ny(mdIGNCTtfZ?}1lweW&v91;6H{+8RUGHRK^Amq!{8{DRP8^mP54RfO z>__)mv_6|^EKuf6=G1h@VC;BYhEl}Qh{zZVFpdyYWPvbE1z>ru*XDBNUH$}+wfasr z;_+b44=v!|K(c99Ltu6%m|cyFG`~HDh1u(F__$k(xVWG}G8kXItXxbUiqU@WKAHct zIe85-V$QKq=g)sTX;*x_xV*JEl<$~C zilc?4o+ezhQkK3Xb2yd5)kGLPuTOEdzM^Yf9ZPVN71&50ZV=P2z!dn|;i6&4B>t5m zVUdll+GWVRO{R%wo$cl9gZ<%|%H;};k?DU;}~h(c;{pd_gBw0^7& z9^l!ZP~99E88ChdfkZZtyZMuA>gDDbtj|`3uEpfFF%0v*G-UGUoa8fh;>@gzM>Scy zXSnV6N5&UC7IgX)chX&6rtuhZZX3K0S3?lgCfKm_ zwDQb#vwDN_4_pj`@X4#Fy8uC0!fM4@JuJhA9= z9&Q>=G2BV{7+fCSO6~JQao`IdRrd)NM%YMT;}bF8B(+RVjayEr{$g2doyo4FC8m&` z=Q5GiYBJ?P91UwqpZ)nJmIE@eBRyMpl%=DgEGtUUNLOb33oSG=`depjW$+?{wPr|- z9CJ$mbDml=`=bI%N&GF8tq;YloCQXkJLGETR|_OZe+L&?D;w}U`CRqQJZX7YgJ3K~ z+lvRxkKhRP*HTe#0N?+TGXwna*i|FJDqO0 zG;#%qHYPLUYqHJgxi}*zkQqDPzZ77vJVFk2aQeb2-8;I_V9e>^W68t{schJw&7pfY zS@U6+cBaa#zi;q1=4z8aU0lT42+Ce@Lx4A3_z((;Zq#PDqz_YRGz;rgnrsYwxM*cP zibd*jZEsubq~&FeeK|CK5*~GiGg%zI(I{B3(%J!AP*iVqR+}b#3vzi_8J5I?tF%e9 zm8XDoLLffaDAmX0pLD=Nw7SyF_tGYM$QRJ0`l_#5@0*z5bJ`q`bK-nA57~HDY`UGo zaDaK#rhNSzek58zAhD{>P%veXNQAT(a~i8py1o5%Z+6|U0Qw+Nm=q(k%8cvXv@m2A zeq+)$RZ^<1l*xSHXp>9UOr?2av+K?!r1grVZ)PtOM&N}kpdaYu_#FN{%0a2WZ_Ac1 z(CesQd~U~nu~*fZ8`i!rPG82mt|g5X^D<6XM`G!)Zd0BN<827Eq$9QEqZ#rs^Z*9J zjB-7&wp1hjNJZbO>V+;Onz+N{jsCFXyORO;r&37BL~gO9!AAT-J@OYdKAm)OrM!8J zhOQqOUEVC%;{-o8&^Q~dMefjY*SD3oe8uN;yqFhFjFRQ%Xm6Jl0>Ca^dQ)t1-L;vg z30rs8<`wOmw|@T=>_3IW9mU|G&6A(7Fv-oFAd|^!#Ir97AlLBlly13TD8Qd`$@+i- zSNl$MFEug8Vs8+BbuyecBEaOwcNao40TwII1nRzf_UpUkbX?|5UjtvkmG+3V`~@S2 zZN%Y$e{I<53*e%WifxR308SiX-@UsdPmY$_<|b8$C({a#hiu@O|MvGBip`R56J@DPc!qu@>Og`f{;Fq#Is~&^3B}wyXb7S;MMW+&_GYhLX!Q; zxz{3L$E=#oqbdSk))}GYQ&h$Wg=&hkuF1Rd1mH~L7UHgY%?NT^HqVm+O>=5zo9@9J zIeh_x1<10MYs!u^4OZhIqm>nSD+Ws8xIPQ&UH=6=a1iEtDNwr3rqqw$Ok&VX%e98t!b2$};P>N={<6p^gJF>_am*)wE!ZPFVKc1MefZ!uDU1^}U z(z&Bc+X!9n&wo>M_Z9OyPJyI8pjfftiYr^rdW4Mq_+{*6mgy$n zTT*lEmir^?!ysn>EBw*Ocw0lIthLAmObb3fZ(umoGFVFioDHAA`zYN`SkDFzEq0UM z#w94Mg_4PBJ+9_z(9p{fU8o$9hLlpykS7)c07*wG-@Dh_W{Q8*$3sR}@ueurk|kXBcfxGcd`;zt|E%la(!k zSI%lqHgyVOuhffoT2z1K^zX>pIYo=bxWo@$~%zO)(iHd@)L zAkz6UwCJ#`HS}g>u)Z(hnI%CNqSVb0?E|ZnDwvsh88b4*b}eUx zlQU^=)4Ud+*-WH$sO}bG-^*+8JN)))k(SGq_aAioRV(|vTsxd&-N~PU)_3uPxRnPI zk^*s=?nnCBMFy{$8hdM?kkQ$$VboKXb2litAVjeI+pYnQTWPKJgB4{)ZF}JFU{=`K zlT%(iaI$Q=S78?$eUPi^=Et)bOR{_S76$6&ZP`i{PU;-a#Uh^viMj{p2>aocubC6X zs4k@+W-yHt3{5ZsP|&I(3LFRASvITd$5~jXY+)CdiLWWNuvV3`d2idkpWC+(^G?`y z7cWT~RduTNKc}rc@sTyEgNnjQ%Xx1?gIyJ*6}$G1s5h=Zw3!mU?HTyHx8cZ?c0fl4 zrc&ULKqN;W=?$L6?U#!WmMN~m7r#`f8`aB+OLaja)&*?zY>ToKG}E;pY8$O)6C==u z#q><0U}8y)Mv`~;@MYqS5@6&!)6)mjhKj_=)EmCk>$K({3w2yLDZvwr)|<2HIDd>( z*LJfaVii@PttEpmrG3FS@+3Nj&L$I&IF>fRTUIS$J8FGL_=&o?K4T^2F2DZmv`DdR z0FOV^l#6F_*9)a4Vj3u7-K?N4(kFoD+d9Xz_g(bJvoH{+LL)?#?9c{{NJFe%Lj#zv zDyNhp6>*FvV(i)LEF`(YGr5vSJ(-C^(dj&D{>&WX6rPos><2e zMA)5n>qwlK3|T7}bdvu%*>AUzuhu?0L2b)P|QZT6f zp3u1yGI0`ok+joc+_;{*Itf9=QegnPT+3m`oMcY^5Z$3H+I>DB^V)Tuehe{G(|IJu z_<~plnG6lpxHk$^j`xqO)6?7lMpMjsV&xKBKcdC5OqDOrL^1ftoSWCivck1QgseGU1ki7`6{ z#O$l|#D_ta_$XO9PW}n>uyza%ZHcxD<`05MBO@8VTh}Ik3!4Z{N)Pm+gP287T*z0Orj$F(fanA5|Q!6oV z&k(;0y=D<(KolCw~$=c?vs~f$68D8$MjD6>loGHF=Yq85#>bjy;(0zTDiF8h} z-MhV@S+o{eQ(&U8L|^?z!Ymj66vQOF)Wf}v*Di_{8ocUv?iGpd7|&vNxfhJJYwq0? zzr?^lmSzyxK2b;ZF?6(~zVRT9j{k-cpUopnM^>C<5dAhF6Y{D1xiO+tuapksVp-qN z;U{DzbQx2M!Zv0(mX5pXewSyArBvC8?0vk1<`f?Z(k&%u@kj#fr+aO{o+KZE$cu9Ao92GgN4#~zjzaVGcBUug zYTYdB-fOcV)UK9JRvM~4GtN=>hN($XKUSPq-M4{ei+XF0Q);{&s|@sMt25@3R%YuB z2|V|;2b3hmD1AYv5uTp>>p%0JB+4u;z!-&3oNE6|F42 zXPm44vj+|xaP9kF?%BSSP-NSBe%W}eMePZag*k6pR4Nl9+`zQsHVAbtX}Nn;3g-u; zh*9@kw&-LRYLB4=HOwJD9x0E6#TdW#+2u#?`3pbVDCv*zBCpoe*n9FLNKW?4R=%-d z&L7XWY1^a22Yp3LGDr>Ur~!C1j>@!S2>Y>}>|u_4;RuTz*h zP!(OcabAP)WW^E)LSAu$4}5!69HEU(aaj(D_y%kJHkg4^De>A@Q??_4i)q!-B7 z6qzmHvJG=f@SNb-ndy2=6azV7zt_Pew+kapy0WA>tNeoCrlqgr9q6Tqe+?hRsjinm zmtS)2(IwCRULfp?jBJZ?-W9{^guJodQ&w|nSO$NkHICWYe?-WdYa-{DhMNh zp1G@f8f88u%h+}joZdXFj1e-?@NFLpe-R2h7 zk6P*T%bW+T0b<+Mz)F!s?C!gCU#ypxCFe~AgNTEd!W4FGVy9-a!@J(6+%97*mrA&W zR5>CdHdRQn=xe0hB)lp1c_*F-FDZJdmRIC(^nDI8{53i*dLCE&q#GKpsX$?|dUHE* zM-Yj8;N8q8D$+rZJ9TM%R1AI)YaqB_7|pMpf(1fdMKC624{oR4%_{J57_-t{IqsrT zv@A*c$VSCJs;cxM!nS9j2joxpnbyS20OvGSE_phc6DV=!OrGWpU8TyVQsn;ABZ7){ zpVIx2;2l<^8)FVSi3Nn*Q%SDub9v_o!Htvp*>WdUk4psbfJ(`HfV_Ygejo@&M`;H}Q3l$%pI-pp`KPo=a@ zO6;nm_Ebq9@aT(fv#^o0+uyS&3*hR01^Bwv!)w?~kzpDIn6(tK{+7G+$0U9kK<{VX z|E?T~^U<^o3|$PghZUL09e0-PMR$k@A1f&MNJ3Suv7ePxvZ>eXVTO#yb!$x*|&&jgv8zu{qpyzOct4(km1EOA-dPYQC}1rcVGaxuu?M z2?ECwFOn=Nb(&zQrfPALDL10bwESOXp57EI{e1k)8$VLm72;W}4 zwo{jf>WHN zqe7E71_cS)#>J0J=^E#W1$CCmb)Qd`GU^FmQt@+dY}dqM_LrE$oniF!l(s+8SxJWq zFTuD*;_~SUmsFAlhM{-OmnyJja3*AF`LhJjk6b3>vv;C?Xvjy^>ysytyBwff z$m5rE*%0MUV?f%`s-mt!791Ki{<@YcE06k#lKG9S6a;W7myHOkQjl9ZfmeKbb#3Kj zYXYmJe@11@7?%Nd<@jyf(S=$vH;WkJG}7LdYG?@ocUWF42}%v6`C6c1Je_9MP1zLq zhMQvVqRB0mlZ)ZGiqg}Vq+gR^?UxZ){U)pT&BO%|Ow!c#Tjx(lI+M$>q_ye+dTYT> z{5@B#!~Ac~Q|5Z#nu#8Wo);fDDVU(l+@4&?dHcqvGWWS8=NE*~h(mZnUJw~knY-mXAw#8nsKz-NkH`o=MkdZ*Sb)S8Zta4xy?UiOG!<#m1!p?KSnk)#kJ(yiW z3$GQaYi_G9@)l8|t{#1JdGaB6HD;}vk2*TQ>A`MSHG_8f0kp~0?OFzEt{9o6 zx1C+MK#=e(52kqrY|!f&LCkaomXyp!lvk!0y*Ekx1UxP`K4HE}L0wt-Gg+L9ik6gu zR0XVeNF^)_-x|OB5Ja%~h5hMSVEd44svW`O*GA?W+QTo}jg3y(5AWjVFSxk<{TTs5 z#OVP-{a^#Q#5fs30Xee8{&gK8)y2^P9sY3v`TqXgyy&wNY1?ZbT~2TaAOI&k^zh{A zk5!~{Jit7_+5|iV3kU@~y0*jNm z1B)W+ePz&-)7t_(uXdcB%4K3O-#8oxPhoC-ZaCatT?u@$!LA+zpPmR@*7dZd`6!0R zHjocYFSil%zuejdbC~tuY&~3k+dB96aqX?lGNA|hLCj0Ktoj}8vHz&Kor=rrgqSUvxJ*KvY7qjQnFyJE&@k2&rj~8?1)3E=; zAMs_VnEzs$m*`h6E6(WQ-Zn5$|GtYM5IrBC;g&$$?b$DL2S!_uy01T)-W++^?qmc> z_4}N&fnev(uvaBf+h@%lA<1-Ta2KA8Tps8HFMD6rwY~M`J$#x6(jgJ~y z18WytlXHkm)g#Q7{v;~GLodab)dsjS7O3+Op8Jx25LJK*9m(Zq`zyTZWa!B^(OJ%dt4 z8$<&4UIu+I43EATU0`+f>xnJFlUiYnhtmnRD_4xI!cRrkiu!ir<$3-)I^U+?p{m^h z6N}}s8F)C}@QP0yHRKf48C+w}b(}ErA9_%aLEbYT3f(holbYAuimWqw!%1GKNFQ_{ zxHfc3#poz-_U-uz`7>xDb-fBLWv;J%yYo-=-ZNM)e7N48lMb!K2dSiab!EHJbmuN3 zy0P6DFB)gZyXW_>lj(vwOZedzRSk@bs`um(Y$Jo5konZgd_fXCjlECa0~bqoj<2nO`KHobf;F_C8~i z?3Uyfw}Yt;B63Djpku&4+uqKc8NrLFn{I~>YA}(mKJAZ z&IDq88Pe?0HZ2YXc7?bD1uJq=!OS1~_$gcp=6Ll=mCM}{O%V*$5E>R+JQov{q-g#$ z^O<(`=-zi5;l1q(t*rnK745S$dJYbyuRV~;R!Sl;tJvS=BJ1nQM@RX5i}a8s7}6s{ z7Q5G0lyDQXP^q%keV~JL)YE~R)7hC6K=_?zRD&1>%ULaJzFeK|@jZMRLGSX&8m#G) z`=qOWvn=&megqq^MQ)6U8lz1MDXpc&?c;%oWV>?=fz zetOln*PeVLd}efgAhotFK<@oELgv@7@Epn983kv)y&^%Q= zp~@O4Un=Z_5s>!Y?O6lfS6?OFv$kucwc9fuNqY{iL&Ua<>}=7nR#M^KHEP))k|3WU z!a-cDnj~+`QZ*+bg^T5i?_ncHXjO52@K`jL=T)B5gtcUQ%)M%+el*d7hZ8DfDN+k| znifVLFK(LKWvZm3#(ymDUq>)fnHYixZdc=({FXisoycIRN(5)|Xytub(A=Jnxelne zH01Ad)apLF`x55~dO}+z*>m9-ykG3{xoTam7V#QYP>{!OieYBtf2{9M0bRtz+8 zpRyGlp_%04`V2R=4vvTr_;)?3enWY0_vQpo^~D3M%BSbTee=g?Y6nrTdS6_QMh#L+ z{<)D*}q{-RiV{-cE+xpD*%QUsCB7O7Np}#6}_*n}o+jyQhGgO`pBSO)viXwCS@k zvdF-Phl!anTc$kEGCi-52v-xmREC%%a|PS{P#?zntsS30F^*Ii5=S0fV^^Y@T*8k| zB?ynKbt~YEbBFg@ZL)}&3MVr*ipi3Sy>tC8I|1^A%2uR*q4SE)<^cD7=O!vc{l!=cT2h1iDJDh<@m5{MVO-0P*xUG#g2>a#Ey)z zcl*vJh@&BYrD~~cnFvIjVB;P9-9{TBTkVTr9Yv)0I0vI2K>yuz>o|!j$*{C2i}CYG z%U7ADxp9&9+y?To%(B^{y_(m6)L$MOyJ5f2im(h*9r%OxY^hR%hsfb_uYvWUgIo`7 zy3a{~$B0UUFSI9tlLr3tLgJ1JW>iK;QEsrnk!WCCpe3r(M?o`hGWAI!hej!(9o*>L ziFp8_g)42LQ^exaw`+BRec@cC`{uHwIJC2%5SL;cWhlfbHbS$Z^*auyZg)O+HHZ1s zh>mmS!#YOsWaz#tk{L&s?Qyk}{!)Kx=D71b-YwORW)h8i6SHz;BwlI_#`ys{rVRW2 zdyX(oYg=kwZ|i&m^K6e}D#a$}Kw~W*j$14DkVVcIMhX@((c}o~*j{RYk&P|YLOoF} z=HWC&`wUBOXxCZYATkigK`7IOO8^t*Qf&FW=G~*xp>R7~H|?UsOHcHVOL{<>6mUs=Q(4S5!5i zHBZ0%e~wyc)cV3Z2p*joI)f&A)`bz+iBn;*eqisH66QXpc0}QvYF{E31pC3 zFDSW^K->(z=7ls*bm*cfSsB_BHD(wpf+sW>A>yhTx0g$lOdcne)R;A)FjPp%7yFf5 z|8)M+J7l*CQPA|6gm|(SSS1%9+Qg1~W6S8Db|S6b^rQ;KTrNABA=ZMG%`-Z6S~|^; z-A>0xb}mIIBdAuU;%$s1Q(WmezR&9yC2=ue*XhzAbI4>=mU1GoY_87->x4gUW4p&NU(N{KlU!gnOoS3Q3 zBn$j^jn~Cxn9g#%(`4`M=E@3eLypB<9Bc}fKZe65%RAdeS&^79B9o1|?j?V=J-%fa z4ci_zH6*oi8k@=1=u^19Kq-WgeMm+f48 zgNAPG9RbF4OvL|gnTkGihaMvAB zl+u{_T~95i-WYv6v}tx>*zhDVvJm6eg#&6)U^C| zT4&usq^Dy=RsVLiZc#x?;*FqyK4a0`$yL35F52|+Mwx`F{Z1hBSph|I3(Gt-7@B0! zUp3>YmIBPlwUg%eFNY(sdZDsxoB!vV&juV9eK>U_R~PZ!TC8H%<*hLMJbOdLm9L>0 zT`TSV?tZ1R^g>ULV`Iyh(JDl6ODJN|o-t&>$|`(eL`y;w)o<3jFIHjVT!t^lQ3WvN z*9Y6cj90N(@HJgJcr6u2J6K~a7PAd=dCDOLn0sh){!A%z?89`f=-RedC*9u2SvU)X z=uk$-lE-*6(>o9*70~NbK~}1E-*1yblk7J@&F+2LLf$bZiuXxkdhojpp(*{sL1Ue; z=emLymrU+|Z%pC;L``h!O7&*T5pO-N8Pm2(eG|_cmk4)IkReHj=0SAwmae^Bx4q2C zB`F;rj?PpVJ#%`KM^Jtzuk*6il22wyNi0bP$0RTJd2e6fX4dZ;8(Z6O&B`+RRzfck z^vgL2D=^f*s+Bh2@&O0GhIMZ$nv+1r%G9s@3?SUuXns2EVk#llrLCz=xukSG_Y}Q} zbowy?OOtC~PD>HSfZn!D=b0Rc*iO-#%eWvyBN^Gq9ekZLZvD-) zrXZy0SsQ-Mn$0%jcjlQ zMhfMqH(RPp_8OaA(l3?C(hCh*^EojpkiliSJO;WvYH*@PNSg(YEDW})paFU?w%Q&wJ@JZO&X<@cR5I-Ww0&CzYBuQg>#}iNv=4{a7s~v~}6No6v&!;oaEP8?_ zfT7c;zlg#(=yAEZ==1qdxk>UxF&yay;cXoC^rGOD?pZ2RQE%`@ec7_?-}hk#X9L>_ zz+!-1j4*`SDi3T4#z z$YS_1O6wj;mEJ%?w3EZ?_Tb2SF53rg zixSlQMS2OO8gdPc)Tb6qrWSbLjd}n)9%9;RfAq8Or@O57(D>Rn;M-c@Zd>Tgeo)mH zb_sT%+rYJ&tq4LRN{B#cD0*m3R9azjO`k7>p3|U4xHk>yvPZ|{tFof2h~zleI55Iw z;AOVGQcAd#tg@g3!?POb-7A()kicjW`@S2mFIh!lM78!mkw+81GXJQsqF|5~0aE3J zYdV{dOZKrtHzy)PLFd6x^`nzk%|*lz^5I$9fzrDA25GRRi-&W^fG+RQgEBSy#WI0A z>P-L*tvX~WCd{}PamP?jvg9U3e&0SjHr*eZf>c$(4~hn!>WA#^U|MPDBb)qqKA~kH zcI))I9b6LVtZj7wm?An_Cea>p`s^Oi(Qg7Q9)jt^jkyxojWx4s0NpnxSCnN08$CM9D}` zdg-roupi9|BZl9VK11XA&f%Y6DEr2?EXTK9p|soB{o(Y zPaq2w8v9$9qf;0W787oBwBCv0Af`M@D~Xndf;Em=KXYoHj%?9|))#tREn>`G@%K z0I>RpKJedq%KmFZ4}jVHb7KPFBmg-8FqRMjWB;i5Tm6MJSOXXX|Bli5*L>J3IXKq?E(CNKgaSDa7_QOF#g#6@A#qz$Y%rp=S(R770~~~RQMys|7T+W zFb(Mc^Zie3RDk`TjQxX^_=6F${FfR0uVT^yG%o)VrvSdm0UEEsK$3u%CV##B$;wYa zKK{(a1b`s`ARhj%Zu!44W&W-GPsabm{P+3)sr>;N{jcZyud)9*53&HCe~2TFfB6gl zr{}>Bz&-&6`Y(O_*BShe{80gNX#iLSEI_P)h~I&j08)+r>{$RktbiUiK))fNoe?mb zKR12As?_^iX9n1_0m}M-el{R>z+N9v`?InAJ%9Xw74U~#VFhUa2SW2dK8`=<^Z(M; W4bV6TxEg2pzvS=#yZL`A1OExK(TZvS diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll deleted file mode 100644 index d80c65efa5b76d626bcfeb5538cc4fa1f92dfe14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49664 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll deleted file mode 100644 index eb30047dc7144eae400774bd99ebd569399fad71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450576 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll deleted file mode 100644 index 8c4956f420e13c59808569c3a74248ba9e8d3f5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37904 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 diff --git a/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll new file mode 100644 index 0000000000000000000000000000000000000000..0886fa7c42d74a7ed3ce0e6458e07f14d919e973 GIT binary patch literal 44032 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 XKp#Zs9uiX=^MAF#{(n6GnGF0lZ70vY literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 similarity index 92% rename from Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 rename to Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 index d8b3c26738268..b07587f76bff4 100644 --- a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 +++ b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 @@ -4,7 +4,7 @@ RootModule = 'AzBobbyTables.PS.dll' # Version number of this module. -ModuleVersion = '3.5.1' +ModuleVersion = '3.6.0' # Supported PSEditions CompatiblePSEditions = @('Core') @@ -110,12 +110,16 @@ PrivateData = @{ # IconUri = '' # ReleaseNotes of this module - ReleaseNotes = '## [3.5.1] - 2026-07-01 + ReleaseNotes = '## [3.6.0] - 2026-07-01 ### Added -- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. -- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service''s Retry-After hint between attempts. Defaults to `0` (no retries). +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service''s Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) + +### Changed + +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) ' diff --git a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md similarity index 93% rename from Modules/AzBobbyTables/3.5.1/CHANGELOG.md rename to Modules/AzBobbyTables/3.6.0/CHANGELOG.md index f11860f075d31..1871f2ceeb3b9 100644 --- a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md +++ b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on and uses the types of changes according to [Keep a Change ### Added -- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. -- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) + +### Changed + +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) ## [3.5.0] - 2026-04-20 diff --git a/Modules/AzBobbyTables/3.5.1/LICENSE b/Modules/AzBobbyTables/3.6.0/LICENSE similarity index 100% rename from Modules/AzBobbyTables/3.5.1/LICENSE rename to Modules/AzBobbyTables/3.6.0/LICENSE diff --git a/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml new file mode 100644 index 0000000000000..3ba1a2251829c --- /dev/null +++ b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml @@ -0,0 +1,159 @@ + + + + Microsoft.PowerShell.Commands.PSRepositoryItemInfo + System.Management.Automation.PSCustomObject + System.Object + + + AzBobbyTables + 3.6.0 + Module + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + Emanuel Palm + PalmEmanuel + (c) Emanuel Palm. All rights reserved. +

2026-07-01T12:50:12+08:00
+ +
2026-07-01T21:42:52.9978294+08:00
+ + + + Microsoft.PowerShell.Commands.DisplayHintType + System.Enum + System.ValueType + System.Object + + DateTime + 2 + + +
+ + https://github.com/PalmEmanuel/AzBobbyTables/blob/main/LICENSE + https://github.com/PalmEmanuel/AzBobbyTables + + + + System.Object[] + System.Array + System.Object + + + azure + storage + table + cosmos + cosmosdb + data + PSModule + PSEdition_Core + + + + + System.Collections.Hashtable + System.Object + + + + Function + + + + + + + Command + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + Cmdlet + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + DscResource + + + + RoleCapability + + + + Workflow + + + + + + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + + + + + https://www.powershellgallery.com/api/v2 + PSGallery + NuGet + + + System.Management.Automation.PSCustomObject + System.Object + + + (c) Emanuel Palm. All rights reserved. + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + False + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + True + True + 3 + 93772 + 1969607 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 1:42:19 PM +08:00 + azure storage table cosmos cosmosdb data PSModule PSEdition_Core PSCmdlet_Add-AzDataTableEntity PSCommand_Add-AzDataTableEntity PSCmdlet_Clear-AzDataTable PSCommand_Clear-AzDataTable PSCmdlet_Get-AzDataTable PSCommand_Get-AzDataTable PSCmdlet_Get-AzDataTableEntity PSCommand_Get-AzDataTableEntity PSCmdlet_Get-AzDataTableSupportedEntityType PSCommand_Get-AzDataTableSupportedEntityType PSCmdlet_Remove-AzDataTableEntity PSCommand_Remove-AzDataTableEntity PSCmdlet_Update-AzDataTableEntity PSCommand_Update-AzDataTableEntity PSCmdlet_New-AzDataTableContext PSCommand_New-AzDataTableContext PSCmdlet_Remove-AzDataTable PSCommand_Remove-AzDataTable PSCmdlet_New-AzDataTable PSCommand_New-AzDataTable PSIncludes_Cmdlet + False + 2026-07-01T13:42:19Z + 3.6.0 + Emanuel Palm + false + Module + AzBobbyTables.nuspec|dependencies\System.Memory.Data.dll|dependencies\System.ClientModel.dll|dependencies\System.Interactive.Async.dll|LICENSE|dependencies\AzBobbyTables.Core.dll|dependencies\System.Text.Encodings.Web.dll|dependencies\Microsoft.Bcl.AsyncInterfaces.dll|AzBobbyTables.psd1|dependencies\System.Linq.AsyncEnumerable.dll|dependencies\Microsoft.Win32.Registry.dll|dependencies\System.Numerics.Vectors.dll|CHANGELOG.md|dependencies\System.Linq.Async.dll|dependencies\System.Security.Principal.Windows.dll|dependencies\System.Buffers.dll|AzBobbyTables.PS.dll|dependencies\System.Threading.Tasks.Extensions.dll|dependencies\System.Security.AccessControl.dll|dependencies\Azure.Core.dll|en-US\AzBobbyTables.PS.dll-Help.xml|dependencies\System.Memory.dll|dependencies\System.Diagnostics.DiagnosticSource.dll|dependencies\Microsoft.VisualStudio.Validation.dll|dependencies\Microsoft.Bcl.Memory.dll|dependencies\Microsoft.VisualStudio.Threading.dll|dependencies\Azure.Data.Tables.dll|dependencies\System.Runtime.CompilerServices.Unsafe.dll|dependencies\System.Text.Json.dll + eead4f42-5080-4f83-8901-340c529a5a11 + 7.0 + pipe.how + + + C:\Users\Zac\Documents\PowerShell\Modules\AzBobbyTables\3.6.0 + + + diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll new file mode 100644 index 0000000000000000000000000000000000000000..4cb326f0b68db96e22dbc081a485f097a3be8bd6 GIT binary patch literal 50176 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 BUt|CP literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll new file mode 100644 index 0000000000000000000000000000000000000000..052dcc98c342a3a30e7d6c1461f4331abc61ae79 GIT binary patch literal 464224 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

7(vSO@k$P1MIs5S9;S+B&cq#+WWB*GlGaz` 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

tf?FgNY6vv~4;PGBJx?3pb(eI>@9pUZ z6KsP0E>2RL*}~36=tH^}Z3uX&V4OPaMb`7Xaf_)S2mme&z62bZiy8KIL1ruN9-EPZ zRqo#5SeV;^4>p2rkCX6hX67=G^P{$#$G`@+SqoW=$!CL?Ds#o(GA9?9 za5u<$<)D(Oc@z4Dsc2&kH`UDL{j+h&9JBBgu}-!vix=gZ2(6=+M!146M7_~M6=9W7U|Dy!0l}m=LUS_lzv@*=IP;D8119Yr~G{*OUWY@ zzBR#IT!QZSV5!c8ZvH~T0?l7~aoM}H&9zmzK8S9u<9vWR( z4TgGSz7Z=@AtW09qBBFHH+Qje`Cjf)sxPa|8b?PQ#w>$#BcW$5f=c^WeKp?}@XJ{WxshdLu4;*B1i_Qp@m zH&wSJG$0(k!WFYY-+u4`30UPN8=pFAWjM@ljYJ7nX-slG|CNEI9f<$J&&@j7Oe0Zol?aXcvhQB2HQv<4{(AYHPPR7MaBDnzK zH&=}O_aQ<2QAolkOSOm(5C70Y&2I7s{?-|o zF6p8K9LJ(%#!xAiz0<|;h2oqsGAZJ(Mm(d!O{E~~CN;e&2!8z{ERDZse%ILi-npyL zwM7#Y%R@{=H7-@v=`)ROI{63osgB7<=)*Hr#jAnse#~*rarpyE_%9usFI3r%Pm2l} zDSiFw>t9`61*0;xAXwB1{UFnEa5_!0mF#D<(LHSgt{N#VQ z{)^Skf7?ni4HywLm!786%ip|;*>es-R&PfT9ds78uC59lS<-8!PJ*sE)Lr9Cq&GtY zr3d~zlQBZ0)M4Fr6~;6N)|1Fz{=@SAB=wX%wp^U1l0c&hf{I2DGWkI`-8%)2C^ElCZtm$BTE9(;JRrH;(GkwSwfq#qrgX&N8f%I=2Z z2@R!)5?o1ROTkmFOHLi9R)kiOeYgzjX|OutN4D%%0jGDWAmSQq1>5ZbH&r3gdXlm= zzo~*2HXCPZ1WZpLVstHs>>*p7(50Kr zS6DM$M5w&i*0*n^j~IAQ!N|R`kf8yX?L*556}`a1;c-Fj<}+GocX6{@ATQ?|ntpS0 zq`zeE#80}R?JiC?rdt678}&@4{tlg~2Pb7EAHhbesOofPDHkjIBO+n*X3UWP(!Km+ z6t%kx1-jJhFkF*INR`8k|9(Iw;O$DL5=U(b2y7tBJMt9~AftKE_B+~M{75pB2Cukx zW@%cHlK@TR2>WwmQ_B+6NL4}n3jJC9;7wFydZR@4 zLB7=3>$ey6rHfm(g}#U+^77?NU|k%5XWBGc?GwXrP9%ouULDMNVZyH9O~ZpsZOLuc zH$O7BV2n1=MC&%wRewOuQ%<{FD|_sa2XvSx_nD%Vc1S$7nsOaf>iPjvb-!Nx;L0JG z21kEvlo+f`>{KUWJ31_Z>&Kd7vNFa2qvEZKk zS;2K2*cfeT$%osYblAZVl%U8pvJEja9(g2=%KZ-q^5TYP=cGO{$Hkl7Cr&F43|-@s zV!&D)3q$;ezaC=!8K^ouxX8}J`W zn8fVp%)Zgu{jXA;E zHUVU1SqOD9m2m1T==a})>gN}XpQ0RKlDp*zc6>B>cr2XE#VjmjG9knqNRV7_4q=^x zL%lhFw71)J1Cq%v&O>QTmR`^c^Q~3Mvv~w2r^}g?WWm#oTaDcwDW8bQw|olqCNdyh zwJk0;ckaB{?G}#OERrC(Z%~Nw&H-u0;f7$~@@=wOVs{9VTT!(P!&&2rL&Kl7Ns-^J zZ8jybN_BfEe=b+$9)Biz6Z0#tXSd7TFU2GVOe&t@O^}=fgbId%Y7fhVwufb=JwTB1 zx49ALhqoy>#=_@iLlewd(Y9H7^tuRwa zm?D~+#WJsP&s`$B2u%BFz1cYE4o}kA?2kUpzL_m%{Q75qHT!Ayzs~+@_ILdK&$HRT zyr0cyI2O-tXV?6%uO(j_Ah-OwHOPF%#qil`_Gqx#A1%T9OnI}v+yV26)N9~tFfgdh zPVLt9*SI%?R!8svlmwUXR96;?AZb6*RxO7n2&t_ zIrxMupZ$WOKGCQRIk3$DSJD5L^z+%pY={33W>>Q_{@t1F&%PT$UIRG-ay2^v>uUBE zyhCyw&fd&k&HjRN{ulo1OM5=g>`&&jRKvHTXS09#18usa%S7?#mmn+J&cOdu4LSq< zr*+7V!1xEj{;cNOqklZj!nn+Kj;%~hfy#J6D=`9<@zc2dIj|g$^O%S;uveu2#ccME z|GPSPM(4bPe@E9oQ0XmARCW#LihtG6zo*f+Hq5^u?U7&l`y;ik>zZJL_{Hy=(S=Dc?SJ{%;KmLDO$Qs4JqmsYm_f++N$NJEJf5{?M z!+Zg+=$;z=Eor!RkeA30q&(69&GL&_-~<<=QCi|Y_b*sMYpaRKQs8MDsl>b4|4i9x zum)sF9zp(+DN!nSg!%nFuv^yS6VN;U)4*Q=*HHbEUm9i{{eiv!Q-eMG6Rifd&j#in z|KI;MJM4p~A-SS6PYlwprn~#k&unOT?FT&X^8fSSW|y^UZhlsG=inW&7K|GF>Hi2t zc{%&{v%lg0zhzs$oX!5HOICx1P?#E;_pB72#jxMwyvo1J|9{1Qvp;=HKGBxNsE$2q z_>Z%{w*P1UY|dH~$q?q6d?(bI^lroc`UPp(O6?Qhce8)|Ka(1~Aah-+<7!3B>*U7sENe&jjZal$11E=r=tr+#=vVK?-7X|(lj zatyTbbHHCy(jAlk=OyJEO5hST<5GL61G2A2hr|C-hxLL%d9w4xDM}Xq=Ywo2=pNj~ z><6mRF@cXQIe%kx$Z3&QgeafBguj8hiLp54TMhHWqBbt;swZDt0uBAowVmP%Gzrd9 zoKVYaTBuSrIeYe72ZtuB9y3$D5ci;C@Qz+DoA#@29g$j03p+#quR}v4N~k6Og6c)K z)d+4XfFyGM3%-;=XQtQ`t)px5DfO1Y5+qmC3w}F;GYwZ0oLSKDy&3>`O@?ByVn5dD@ZGpWPc@k8WlrWfzaLnY{i=Hb7wW51 z`&K*hGvlRvLrkXqH{0?ig~+Hpq+VU?`q%XAYmFQ1h&cb#gEJiI`w7sQ? zKil->Jl_M+uAfu-^QFuV`#S4Z{CTBU{MkQij|OChfBfI-5dWcjHMdE!bn3Lzk?lJ+ z=?%-~dbn)1Q3bJ%l0KFUsYvQJJWZ9D{ArS?}8}u(p>@QvS{C-#{`%yqvJPG^U!T@Lenv7rP$T|F4GZdgX^_ z=bvZ?O3L8JbNoJAPhLO<#{9Qh^BLaw2io?Ru!Z7U{v*GNpztS>pPsAooBi1}^=Rfk0uj$-rTm#j za+LZ{4yi>oh!@j2H2Y(D#VTGF@<2iELQG^5VnYi*o&u!|PeRkQhbp{nnUn@S(JKx5AuYa8JsAnmKl5i^RUj{4E}esOB+A+} zSI%;$qg=b?_Y=@^$TFB{y>hpxx7IlQe`dlSsIpNxE$de8^ravtZ;;cEqts7buNtLZLe9bd2xVcVjV$q3Z`n0J zCk5#Q8h@Lx-M7}_j375MsHxX?p(9k+o!hdj08JS-x9`qQw5j zTKbv74INas8{8>V(LW~s{Zv`94AxQk>eEsIeyo&Rcfi~fkr=NsFtIL{gqUo+oemL^%LGl83~H>l zeI!A*%yts<`fTl!ve;a8mUj@-^OxoOX=-xIb1loeQdhks&pILlJHI^gy1Q$Al{4+c z)s$~uFmyw?^xQJtmg;@l+iO85W;3u*X`(uU?kTsa+O1cu^6!kaw)RhXJ1WNZuE<|g z-L^5j9{IdRskY7AmL_qCq@1t2Qc6tFU})zfv&qSvnzT0=-99qCB-lpvYEx3NZf(?0 zM^-hclRzTMp)zn=jWm@x73CG7%BO9w9;HD&yxsDO;>RT3Pdf_D`p-CZDJy?H5EblC zP!~6Ou!O_}bhZ2OF}S_{7?e5~(M~<2ygk)dv~{!i|7U)vv=*s4_Ev)ROHfy@S3YM< zgTqwY?Umm!FuIwJx3Z+SzNEt*vg_fSyJv+ZnBPHE-vjOQYI6#Xi3~uuEx}uF{E7f_{Kal5SZir7Rz>EK_lH#K3y)q zj_P0Tww0a=P+Z0shNagh&`6ZO(Aq=F?G}$wJp-$}yv{)kmIEvW>Dlx;ZxAKOr%|XW zf7D3c%CgIiYa27}rVJ9)V)U?4ehp~9=4~v||7wc_J4sK&=1OX&{9Y_~=jcDz+V65X zyQ;lkuuutR5^wzIoD|(Mc)>O|OO8-U-BOAk7PL!#&nrmFEvU65*!2LF$1P}BlIIsB z?OVsn->H4rAT4jF_Wnm(4@8@=wUG;T_*(rn*<(92F29I4W?nR9ery0v+`G;g_kJ;tj{?MJVZqTBD6#@J)q5aU$` zH)|ZK*z_Yb+DUdxnO4cwaZvEsLVhhm4|naR_n@ha+l+x@$7|d&zGy+l^Y$z-=KA0+ zf3OAMbgSFW$j?g?luFR1Uf=eu7TDdct=$#`+KJg*qq{Go!|5uX@}Qn<8IChI8GK|^2{Zp7%`(8{wj5;*Fv%XcfKHIiR`#uxgIrDbhc5EMJ z;|}|sdlPEoeW@Qkm>XHlt@O$5&1m{#a%(fzo#<}*nBSjw+vi4QhiR9gA~j#L^)*eaNB(CEfnagiKL5PIz10o2 zUR&S6V5!w!30kM@inQ648gHrqzueu3^rb=XEgV}x(>uG$>44xadvCKHu}&E$uIH0! zd&{OtmY%y|z&g>{{EXnEt;tTYTMgUBVO8a}Y!N)xq_-EgEqa6oavITTcC=M+TgJ`y z=oII2p{?@$|NHpuEFo>NGQwO(^b&4w!F3F?RLb+3&#Q6vxYOCpkW-~k3b!doD=p%> z*~Do>hLhQQz0k|(-e$AJe>7a_JjoWz%R73GA9w3@Nk658luq)s%1qO7)I3;0Y`dBb z(vr-31RaDM<+SD2_JEhZ^grvtuAlu_GXIB5+rEWj4piTdE1|%q+OtmF6sLKD})rXyo#IBYWxd{}Unpcw+yD;v1CTRdoFw(H&}P zVb+g~O|KRqPSs3}eXvk&dt6Z)?Sb0b-u3Ni5-)vyq1nOWmWK=5FJ5^Ng3dczvgOf* zU_+I&BXxHtSQ5L9Sss-brQc~5(wR|(3&@mAA=C|njfZA&8q3` zD;Rav@`mRtiZR2i#P)?VbKEwyQ%qZHV6Wx`Ep4YN2iNm#j;Un18w@+IhO@0g^!cwA z#Dz0~mms8T6juVTiX7c6iz4$5DO_^1fX`pcKlZ`B^B-Hv53;5FFt?4mYtVXF3$zEL zW!v`SPK(!_g(weVIn995ZnvzQ=2|VU;!)bPtqV`P;iAnjaf1g&nFvtwgmp&l47sLQ zIPRq6Q&1Udxlc!zb<*K3k_^8dZuy20?G~XyySckevIA_!gckj!qp|1~-QzY06+NXjY;UBXKiZ>arM797ML!NP|m@EmcHbAsat5-=LW3|alhfpGYQHf$F(`z{-(l5HR4azNCq7aKi z@kq+6A@_RDXNKb1va<^{Yz!td7n9K9P%=9U$4ne#+z?mr^h2Sre91rituj`)8qYGy zj?rmzDA_vBkCk4*^_jZvrJ3)Fv{YhHJbU zJ6#;gomN{pY7=dPi?~`7mPhtQUC(*=CCwljikxYRN}6Sf?B=p)aX6evB^nch$+|i$ zanvF8veIa{D$zKmI?))dO{C8cB^qNXuY2~pY7<$k*b7yK>LO9ezt{`Mn`9b&sBq=4a2eORG$K#C8cwE{DNxLlIF{*BiKk%IVTIE< z<$lO-(y%VNmMWHrB3>v|-yvG8sJWC9>E&vD6-d&mibixPoV!+BW<~LeLMk-L-lG<* zFIGX*_tWa>WjjQ3-eFx*N|xUJK@oK3y3#V&-nUd&he`jDa5t}8h~LUG&aJ#EYeh;W zUM>&S-7?Ddw90=NZ3;=^-8>a3fnO+A9aBN5t_eZif|7hEpDmKHdOxj}%r;2)D!j%* zSe1~R6fYJjNn}c?Ms{#aJ)^T6y-PI};8eFFi@_z)a5CEzi+V-G&~Q_A*Q&wel~!DZ ztt1+`)F-mHkY_kfl9Tuw&QC%-yRu)p{>`SSYiX1{QEu}s@kz-P+ zG{XX6xmTO`fyFONcoM{nFL>}gQ`Rc@KI}~@6d^> zj?|J1p^<1IbR7|YQ4^w8%Z&OPUbsV!5*KuebaOqhJ^636!iMlL8LCUidPBMPB z987P{s?)G{VJXhmm&cXLlM0NBmslC%MP-^*%`+~6$dg09teF9s5|#ll2O$DIvzd z4u?y<2oj9f*O%H%)F5BgB9U#giKNZk7n%DaYL}g*sfDy^q9s9L(ko(?&5EF9XN7}? zSAHw}t|o^odwPZRiAG=|bG|PkCm+{hPCgh&$j3rZhD>Q`Dxwy9v0g<-4jSBHDQaD) zRJPfrW-gW6Fqvg;<>nk^QY!xSQt82~>}r+NNg_KrcLkfKCgf{|4~uF-_2K$*)2KIF zcw+i4CFc?@H@)h7soQs|&h7NLd(`8yGDx$xo8WHk9F5AYR#G6%nAuwu+FfU|RTcCj zFYJiyt!}96vUHni*i9rvTo*+JDaMB!?UGqY7Cx7(>!|BZmI`gMbg`tg6<5N`C#cr2 z405r{m`Yt{bn=XKb&VA)r#tY;I`iX`617lg8S|JNpJ)UmT8~tdgmth`3CxacEki`T zDlQ#t_Cc|9Ok6!(bExX_s*b*PqoCH`KC=`*!={w2>LpvJRN6fM9IdXTf~tScP)^_B zI-9$sn#(Ts=n|dj;q`T?VjXg)GV<<7WFJwb;!-i$KKjvkNM=8GLll92uHPb*n>9`FTn{s2Z#4TJ)O#NGt8tP-xnXR+s ztc=qAw8*g;3lVC{l3ETtOQJ;$@mL)5!a_K!Y^Ga$$beQl^3M(_@rq=T#;X+XIR^2k zGEjA;LY8pu1Y0DT9j+@<&F-l3uwE7xPh-r%% zJzF&AMxzm_E`NfS#-J0S;x%V<^H2;5BVDOnM=9P4`IrZ$j5!cgYmDoM*mc!H>NLF+ z9iKg16Hb{70F#ZI2wB@D(q9t9x15*?lbOq{Tq~(aYXx%!*{ZWp(AJc!GrPWY-)KTt zv_e#p>RTsDOQWlm{YbKxuNox1!a1!VZg`(qn`oV(E9_?W2b0^3Zo%h;n#>VTESEa z)lXFAlIlx|){B(YQXMbj=(40aRf#!!#3Rs=T$3VkZl_3EJ5Xz5GQG;$s<-*{-XR)QB8nzC zrdOC^c8TZPgoNy>>Vxh~uNZ1ppEC7rK6PyJH}h|kI=t3}?Jlk4EPa*KQ!ZpV`wrPt z{mDx1*2T{4hapOhGSAVly(;7jo&CzrO`R9|;nbR#DjM8W#UGq5rfDE0byJl`jKll!1m_iGz}^Y_N43 zGIiI@I+8oR68+-(1Tj|l#;!G$OF||ngUHgNv&!MeJ|N0kwSDX4>se4WCpEt?>gxKj zX3jZWZz*UNzXopiM>UW`<*-N<7?h`4#o}1TMbgsohN3=E=B0vOLs?fyTe-rb2I8iL zBpu%?T!PiB>fK29%}&vpXfnDr&|b1N6pNye)*6?7xxAQ;vs1L{Pysy8?KUvv82;&1 zy`p6bW8+9QvNV_-tUT%mEhMU+u(Kdq+z@u&9ZlH*d;P^S#QoM?mGIF)5=hBT8-Bo|(wbMpLHO z$!03DT@Q9(kXmcg-chzATuZe-E>&A-gH1X)KdqoJ+bcL!stbk|cPS8q6qhoYVv&`r z1Gi38OeuSFPj+sKkhzD_>R?-3nd<6jNi3$mmdq}4rmXfIvQt#Wk*vxTdU~CDO8QAn zirSmLN_ zY;>vFJkCx^y*hfhzFt{)MfKG>EwgEuX;zD#K2FOuwZP zW=y*qvbeP?k!UpuzTy+&TOa)$E4Dmu}e2=fZtQ!#oBU-T7JD0W4j{;dS;RPu?fUo)?&_& z+f{v6t*9L+t1^}aY0HHh+_jCFsFV}Z&^)Q`X(ssxI9b1<0zo-boHU zhETa&Ep?%CatkThx?0OhE<8&Bbq&e59;wSi8>6YB{LKj!m^)?5xxp2rul)v1NffwP z!Rs~1f-A~d$+{xsm(xMO{F2%!!I!Og=sb#^ zV@Hc)F0FU1#%&#RwC$YEnjDr{-Q?%1I(d)?!cZvRr8SS~ht_Xdv3#y8-Lz$}+;>wv z{F1KTxtk7>qi)0%#wnGYB^0%_B+(X890V(e1~;~OVOYhkNJ&2_Y3_6^CznqY!=#(Y zUM^qV>C~0`bw#HVkJ;f|Fp;DXPg}}~q^GVcT$>%0=QxFOqQS>HN?V;=TQ`?^N?m07 zP~w}GYPqF6o^l~Zu za4ha==2)A|mL{`L)yKl+{*5tqY?MpTlfw138@N`nR2B88Fy{G(ZbH@4c*mv?w;Jf| zd4I`WJup`o+0)@F+hE8K460ZWRIx%elSk{9@~{zO=`qn}uC|FN%T28H+AL&Xv4U7{ zVhjqY%=~^)&G;>BTJot5@~KunNh?UQ^lGhE}8b*EF-tH!@ zzx{=GIH}9S)l-A}xvb03DSoUPqHlFwdZlfIC9Y0KTe_SoRj_N)ae0mtxp% zsDuvmHyR5h@q#opU$-?` zbW6&24`iENOV)ybHHxLh>Q+9vnoGRfM3C3kF=RMt9g%%o_q9uH#iW%&BLg$jsGT0! z2#x$|Ov;Fmjc=Of5eZa7L+VETR5j9GK!Hb<%#7q{Z4GWpq~BI|llGOvDS=I8O<_%O zSoz$QAxyTXcA+$B$g`WrJ%O=o-kEKh?XBp(oto{vV^p*KSl!4CF6vl|4D9Z%{1uo1 zhBTdDWxEhb+q5f;G1wFkn_kl2s*P!)ak7Vv-XBt?`jj}>4^;U^Twbo5h z1#`-4g2JJ^I9lK5rmNFlcpVq*sIE<75*GJQt*jdF$;=Rciz<;Bt{<0y9l3w1(^2j) zQinw50O`_1W=t}DO(5dUc`;>%D`(^)o6D~+DP+gWLmNiAL?XJy=NzVsEqQ#%#-ihJ z(Ou3Kop#KOv3ATHz-fyI)(n&5Y&_>f5fh;)JPs?W>wny+w8!j9W3YEPQfA0TbE=y*;(N-mi_uYa^kkyLk;T(@-+nGK1Is_t(4jGsl&8*)Ka_=3wiiwDmTsOPB}thG-h%j}7ohGgE?xaS~nphKni zl|kD=Ur}Po*;ErtFx9rZYHJ~P6HA>1Q_5MV^>Sig$z;W;-3XD(n!+_p0oAf;@J%5n}Si8`a&V{XwM(e}e zq(u8H;a(5LXQXmco}!F3Qe1avM5uqZU1ZYWx*m=RF_AbQ8`F5%O+4R|sq_qsz!OXo z!q(-e2_YRnOb2c?Z5^W1kHqU>nx|c*lvvQ0bmx&rEtK?GmOTY=F3Zm~DW1;N*W(8B zX2LrURoe1Zy>TYm=2~^@0Ki_DdNVbbGrdp;ZNe$b+j7Q^zEEB0?b)U5N!j{ z=Gn`f0Q6|jcKjmR91qv{U9m8*9$1k`-MGssrFbwu+-zx9MT@0elj+U6CyQ~-uupF? z^0%%MBVx*?$0TXbGaDb1g&BiRq)f1$yR|oSbZbmGuy@gZ16s=pn#pcLXgJxuhNUrE47<(M zA<;_RadWFT$w4;)wbL;oCO?H9sa~;40$R*bKS}M3vMsKSQU4-BpUG7m7EGqCQX>%= z56QOGDaxdqhJMycfSXg=bViIg@;CSNyhw0WF8^|a?dEZhgg9GAF5I$qcStPdCF%!D zxsBMiT0bh9ZD!MCZL2$)w+eKsOVhuo2O+3~WT4$GMbln$$7qQO!@Q|}+iJDK3L70( z;Z4+h7xg0DgHnZEG(yqiw<&6Rh3!1JvIJ$)k(*ua7N-O*XDS*EQfphy&4H~`(v}#K z<(W?1>Y-b(vxyu37UITtJV|02Zo8v{c<^oV?_H$2LLL(X`l5 zMz#UXwxq1HoN=a9(i_$PBidBvR=b90BFa4u3_&fLbq`eZhvHNxfkU8ElxT3U<|x=`DM zv3fliu}BX?l-04hrW*+gmBy%Z4h;-E(;LDT zb{^ViX8Te11>Blzotp>RIHLb;&(^KE+$FdFEXAff_===Hr0ukfKP$Xp-Te)h)JC;D zQ_GDy+9Sqtxdk0pP~GOCc5b~Pcx;y)tzeBuD9xsJ$8xbA?`PVvhkM#Sa~ZJtrVQFR zlFCw7PEJ{0(7VG4mw@gh+PNN)BAq2XMIc%_OW590I(8(Tn=KtXwXLFp$TDRqk9>lZ zxXqDRQs+{t_fz6XO1HX{ICXo97SAjwe;-+PleS{*N#)knEcvwI=eAr)(OqLv$@Ivs zH5;{6XgQ0e;OZWj_1Z|&czKA(xu(O>KU%5*WYN`WW!R(_!sM(P9*p->Pqe-o zOl+@Wwxr)yD6?P2Xgx;vqQg+?Pjs|S6?l9mk7_gPu9iz7uhqfU>{&cmvzx>1pK=i& z|7hE$XemfBjZ@p`fsR~R-ZIlCXhmU6-FG`WyQ z2S2Sk9koh|p#zf#=Nd#Eqa@|w={H7ON_x*ixjp5=kdmVInwiL?}Dio zq#f2uCahtawLjxw$5s}jO5JX^HLOKPL3y;Zpr^iAVYdyl60_uCJ+3mb1_`_iwuHmz zZQBsmUL*mv&n#OwJBB;jR?1bb8E5cvaMETG8AmxR^RTHf;KjS zdkI+T-P>Js%gW&TBf8!gtw6MH1IM-+Z22dlu?m!U^_+07K`6Pup@SOYDn|kbq7K=P zt9hw5ZYzRqqnB9)W?#t&8BB<9cIc@E*n@ns#P_N3YK3qLHa99TKk(EMxxC5 zv^K6#!*T?pPEM`;XwB^B?2)(y!#(s*TRPmMzp;v`^;_LDP>WC_nMJn#X?u2uXqmDw ziz3lFR%2PFXd=5hkzJLO0w`hrG+Rfh?T3a-W{yW01$6Kcz4o6YT(*AjQjYYD1H zlCvWtp^bj*=3y=!8m(6GZs6v0dY!7rBO5(+=IWlkRr9)u-5pJ zagc;WrAE_L9B<$-nK@^o?G0bJb{*k5hlJ|~M9xZD0j)9Rm>5k^QU>Aws;sUT6^v*U z^hT4C6He|y7&nZokxliP9Er*wDxuyS0Y2;guNoBpBNom(kpXGWwjqzE~7@xVuSo;5` zF+R77C)=p6J;#XQiXd}BCPaJD;?qvFcxXviV658MXfpe>$=Roa$v_h3h=iATaySX& zH94A8kGr{DBL64iM$(01UJ@x3^AeuHRhLko@#QTa|B<2k>Cqa>q#3EOYX8epwI33c z4ypJCl?lcEcsIXjz?N|(Y}YOprCqz2Phq>5hw4|bWEf^mGR!r}2){`jWKGZ0Nv(LcJ)Mxoq+YCM|cO`9k6Q7a~0sET~YDqFA*i%zTI9?3v^K4 zz(Lw-ktI!m*quW~ZW^r{aK19yevu0k{BMhnCd(C_lXgYNV;73PL|Z7^w5#9wwk`Fr zpw!NxQV-k9m6~q*qovlEL5=^{on=H}fjMbcU_4^ao>}w5bA2})9=mI=6V6S%Qnq_~ zsn^r!a7)x&E`?^lQ7V>dx;fKm6dklRvri zXWtZ^HmmjEYp>{b_a5(GxBitk9@%i+OG9gVgfA*@(=&|~ie|)gHdGf&(S3dIlYkMCzwehx=#)hx|GIHT@bNg=Ee9rNIs9N*$ zkvrF4kleSWE;W1gm4gpC=A|p+ybU7%&ao~7ykUCD#cui?=nWDF>;DkBhkLuUzqG5p zJY%{yK(9+}RwQ0iJWUaf(Eq*vXCIp54IR98gST<;>Wh=S>U>^JO0&ru$F;39Yte<_ ziZxWfyt-tX%01PJF-XN3BG*)fn5x1JQMlcd0%z0Y9**oo*);DRAE+8~bpHn{bl@iS z!fW^s)vxgeB@Mc`+Pq?A-p*)nG9zm_9Dkj&aDZk;$1DR2i>GBz@bO*@2o5i49 z)09S&`S5|a>6Qw$Bh@B_rd;%cCZ$1LS}cuW@*SWvPQntn(q3C-NKQrRbo8lqgvtv;q205ZClmyhunYBZA(DNMU#~ zxCH%H122*Wt3g-+BR}XL7d8@YBhfZS^jybA?UUmN6i$A?3*!gEpa>|+#z--cuG*-- zZxCXj6qJE-Pys4I98`g7kN`L2N(fHf<3`rU~e!A>;v`% zqrn)kAJ`v^1qXlw!9n0)pf_9y)q(l6a5R_*CV^(q0;YqLzzi@G zoDB48OE04D6nPQ7ecFr60ds*~Oz1^U1$t+e7g+>O1A1$;7tyP=y@+17iz(5x5v!0xkuYfy==a;7Wk?5V;!s9Q*=Y z1Fi+vf$PBy;70IE@GGzs+ys6NZU(;tw}Ib-JHVY_1-KX72Oaq!l@G$rjcmzBO z9s_H@TJSh{0z3(x0#Acyz;obv@B&x|UIZ_Jm%(4aU%{(jJ$Mbg0XBd)!CPP>cpJO} zHi7rR```ocA@~S<3_b(jgCBs@A&+1R<)8+10JWea=mJt;7qBa61bsn2&>u8`0bn2) z1O|g4U?|uP3?; za3Qz|TnsJ&mx9Z{<=_f%CAbP)4So)O0j>epg6qKb;0ACb_$BxiSPE_ezXrbnH-lTi zZ^7@ttza3r4g4P54(W?g96L`@l+YKX?E<2v&iIz{B8AU^RFI zJPIBIYrtCYICug)37!H^gJ;0A;4k2>;8n05yarweZ-5QpP4E`j2;K(ofWLwF!3W?& z@DcbJd;&fNe+T~npMlNbpWt8MbFc;c8+-x21Yd!#!8hPr@E!Ob`~bYLAR9^c6_AZ2 zI}FH1(u@scBgrfSvXP`gfGi{F4Iqn0lQ59QBQ*kK@n{kTvUoHJ16e%MFF+QLCSf3p zM>8>y#iQvM$l~#^cswi~DI*|@N6HAu;*l}}vUsG7fGi&AAs}N%dI-qaksboFc%+Ab zEFS40Ad5$O2*~1*9s+vI0PID4g=%B z;ot}`9!vm7f}_CEU?P|VjseGl$>2C}JU9WI2&RCkU>ax!Enqr03CsX9!O7qhFbm8E zbHH3M51b0-g9TtASOiW3r-L)VV(=4iCO8Y64bB1Qf;7m0ENBI7;HThc;5=|XxBy%T zE&>;WOTeYzGH^M#7Tf@C1iu8o0!zV7;Md?c;AU_O_$~MyxD_k|w}Ib-+rb^+PVfhC z7g!GN27d%Az&+qza35F+?gtNm2f-@v5O^5;39JT>fJeb&U=3Ic9tTf=C&5$TY48kq z7CZ-@2QPqi;6?Bf_%nDJyaN6L{t8|N>%nW_b?^q*0Nw;|fsNp8@DBJJco%E}?}7Kh z2jD~S5%?H<0zL(Q2mb({fz9Bb;9uZ#um$`Zd;z`$UxBZ|H{e_F9rzyn0K6h01j3*Q zu#$)rgAx!0F;EK1Ksl%Ym4KH9M5;hFNPr}$0Ubas=mP;s0R(88|V&Fpa<9i>2C}JU9WI2&RCkU>ax!dU=s_F*pg#05ieK;1n?;a3Qz|TnsJ&mx9Z{<=_f%CAbP)4SoTx z0oQ`-!1dq;a3fd>ehYpFZUuLN<=}4cN3a6i1MUU)ftBEX@BnxatO5^#hryq~YVZhn z6g&pjfVJRp@C0}gJO!Qx&wyvabKrUK0$2xL1TTR;8n05yarweZ-5Qp zP4E`j2;K(ofWLuv!6xt?cprQKJ_H|ukHIJ4Q}B225AYe-488zgg0H~W;2ZEQ_zrvz zegIyv5CZ&}PoxO2u@Wf;B_Im)x>e~!PzK6D1*inPbTCo{szCxIK@I2tYC%WP3GnXQ zNN3OmbOrUG0dxc1K??K$JAfSlua1rE1a=0!KyT0o>;iTLji4{+2l|61FaQh$gTP=g z1Plecfni`c*d2@jBf*|vFR(Wl1@-~^g3(|M*bnRv#)1RDf#4u;FgOGp3JwF~z~SHs zFdj?*M}nik(O@Fr&($KwfMWrF3>P^L91l(aCj#C!5}697fo9MGrh}8f3@{U%3{C<3 z6g5dEitqA1nY1!6I-PI31h;7K5LFGr?KlY;X=Z7o5?lqY20sVC0M~$P!FAwza09pz{1W^MECn}#UxVL( zo53yMx8Qf+RA5 z@CMib-UM%fjo@vdH|A+bf@&}d>;v`%qrn)kAJ`v^1qXlw!9n0)a0oaQ90taL!@&_? zJeUCVibM5Na5R_*CV^wXv0ySd4jd0o04IVeU@Djfnn4Sg4o(6yz)Wy5I0eiCv%wrN z7t8~vg85(pSO^w@)4=KA46qoS1-!a!>&(K^3S536KOepaZA{9YH6ccf6>oU=ElI=7Ceee6Rp41dG7AAPq9$ z0&pR?2wV&<0hfZyz~$fya3xp=UIZ_JKZBRSE8sQoI(P$Y0B?e~z((*kcnACqybCsg z_rUvLL`)b7t^-elDW$?x&!8zbuFh_ShB1^$d;Md?c;AU_O_$}b3 zNMr_>2~Gy5fLUNRm;>g5dEitqA1nY1!6I-PxDH$oZU8reXTfvedGG>Q2VMj(fj@(n z!7Jb|;IH6SupYbyUI%Z04d6}i7T5^h2Je8sfp@_s@E&*{d;mTKAAyg-C*V`?ckmDJ z8Q2W|3H}8>2V21R;0NI8eo|y-&(F5?lqY20sVC0M~$P!FAwzpodM>9l>@F;i;tO0Am z{Q}4d?)BK}XOD)Pc^R3+M{!K?CRp zx`Pzx0d@d8f}UU}urufddV@Y-7qBa61bsn2&>u8`0bn2)1O|g4U?|uP3EI+V1Iz>`gHymPFdNJPbHO}tDwq!zfQ4WY zI1QW*&H#(SPr#YrEO0hB2b>GiAOo_X6|{jR;HThc;5=|XxB$EdPOcM90kgntFbB*9 z^T6}q1@L5N;VJMmcm_NRo&(Q=7r;93B6tb>8N3W$0e=C11+Rkj;5G0%cmr$zZ-TeL zM({Rx2mB4Z3pRoG!293>@FDmJd<;GTpMt-Ge}K=xX7Eq&FYr0o0{#uY0AGT}E<#_> z5A+93U;r2h27$p~2p9@>1H-^@ushfTi~u9So?tJqHy8!>0sDf{U<}v~><`9*1Hggc zAaEJD99#je1XqKfgI|Daz_s8ya6PyI{1W^MECn}#UxVL(o53yMx8Qf+RA5uo^r99tDqqHDE1x96SM@ z1e3Z7$ADwOWN;if9-IJ91XI9NFby<=7BC&01ZIGlU=ElI=7Ceenc!@24oHIx$bvSo z1e^~p02hLbz{TJaa4EP9Tn?@PSAwg+)!^sg7vLIjEw~O`4{iWAf?tAIN+<pdK`UZXgAEfE~b&peNV~>9s4`zco zU@n*kP6hM90ufW&f z8}Kdo4tx)O0A3Fv1j3*QL_jep0Z|YGrJxLyg9=ay;-Cstg9J!|8qfjMf{vgQr~{or z7tj^dg9gwIbO$NW1MC2H1UOD4pg(8=1HeEq2n+^8z)-Lo z7zT!e-N7DU1Q-eS1bczK!6>i~*cXfjW59l3e=rst01gBPfrG&z;81WF7zYjqM}YBQ z0yq*J1&#(2!6a}DI2KF>$ARO)3E)I91xy9gKr?6o)4@q#2ABy>2B&~oU^bWo=7M?P zR4^Yb01Lq)a2hxroBSAN4mcO2K?Y<&D`*2tz)!)?zjA z4BQ5O4{ir{fIGn-z+GTDxEuTttN{0bd%=BRCAc3v03HOZz(e3+@F%buJOUmCkAXE{ zEqEL}0iFa;fv3SU;92k-cpkg})`1tnOW@DoWzc&^p%2&v>%k4+M(|7UE3g#Y1bz*E18xSl zfZu}Ofm^{ca2u%KN$3W;gB0ii4h4sSao})p1Q-t{fQeudI0hUGCWGU^@!$k-BA5cE zf@z=`w170ofGlVQZD0xbDfk&U51bD!02hLbz{TJaa4EP9Tn?@PSAwg+)!^sgZtzF2 z0^9@c1^0n>!6xt?cprQKJ_H|ukHIJ4Q}B225AYe-4E_nWfPaH8z?a}F@HObTvrq@d zf}emh!CByTa0j>(`~lnrmV>*&ePAWHA3Oja1gpS9;1TdBcnquoYr$J!BX}FU1O5iy z1)IQo;C=7`_z-*qJ_i2+pMx#n-{1@ICHM+_4ZZ>2g73ih;0NIK5<(yhiU5zvM~XoS zh=Ld>1!bTdRDdT33QvKj!871l@Emv^ya3jL7r{&5&){Y73iu27D|i*G2d{zG!5d%$ zcoVz@J{Tf=2tEQIgHOPx;P2oc;4`op{1f~Od=9pNe}gZ;m*6Y#HTVX63%gCBtY z3Pu70VNeA4^MOb)C;?Fr1Eru0l!FRT3F4p%RD%Raf*Q~P)Pjzn6Q~27K^M>!)Pn}l z4Ri-7&;#rMb_6}aPGD!y3-ktkz%F1{&k58yE(LgWbU% zU<4Qm_5^!@y}>B357-xs24lc}V1FdGO7#If*2Sf#u+C@JFx$+ym|f_koq*e((Tz5Uc_Z zfrr7Lz-sUacoaMa)_}F(aqt9q5Og1E1#|`VpaFCP-N7}x3)h0{!1dq;a3lB?SPE_ezXrbnH-lTiZ^7@t ztza3r4g4P54(W?g96L`@l+YKX?E<2v&iIz{B7J@FDmJd<;GT zpMr0}ci?;Q1Mv0`LLdx^0DnjoDFIOs1Eru0l!FRT3F4p%RD%Raf{vgQ=nhh#2iO7Z z2zr8@z|No-=neXSUBIrO5%dN9K!4B#27rNJ5Eu-GfT3VFFboU_CxR(pDwqbEK?|4; zP6Ahe$G{q}7OV%af!DzsU;}s)yahUp5Nbhh&3_AT|qr)0Np@$kODox4q!*n6YK1-pS^U^v(v>;XoAkzh}-7uXw&0u#WI;3#l3m;*=Fao})pBsdyO1e5f~)R7Z$%?0zo zsbD@>02YEp;52YLNP`T>f>zK5mVlpv^MF?^guLC5l~4pUyCN&01Vlj$l!7u)4k|z; zh=VFn4H6&;YCs223p#>Mpbm5fT|ie*4;nx>&>f^e53mE+5%dH*ft^7w&>QptyMSFm zBj^kCLWtGsVS@g6UzH9CArJ;dK-PpR4N5>1#6T%11LdFsRDw9D0@WY^lAs27VL^gT zN2EjKGI+z(pFzkQhQx&;5CO%Y1Vlj$l!7u)4k|z;h=VFn4H6&;YCs223p#>Mpbm5f zT|ie*4;nx>&>f^e53mE+5%dH*ft^7w&>QptyMSFmBj^kIf&QQg=zRjK^;g)!AfT62 ztkz#)3q!$fU>MLF7*;?7)qrg63UoaYs0sDdd!B}trI1n5J4hDyS zL&0HS95@^t0mg#~;7D*3I2ud@lfW_HSTGrcyy3L!aN1I?kheQcD-?kUC40?gy zpbyvuG=afDbxR*H!&|8T^Ss@?{`$Ybo2Iv)w0N_x*<{q5NhmPD(MRdMC>@O@5^4(nu-qnTmgs!ctPg zPqTa$DTR6F*J9!4Dg?PS>MD6Q>qvfcw2iV*Zr=NHs`8v^F0KYSQ0w{TOWycXGTbwD z#g%@h&K65T9ga{k)4lOJr2?+7-S8|-`w*SaR(+7g0=u-%IjR2T$Rc(KD6#aJZocAooNC7auM#tu|Ds7seaEdm8YkcAP;sKrV8 zJ;SRHa@%f^f{Wu-B_v&#NXe0wa>+!MYL>c5NM?=x>n8@PK?+(Z zIDg8l#?-h)Q)kVbc2LXW33E?rnKN?QwB4o-7&LwQwB~`$&CNpw)P^R6tH+-*bDmO~ zIeq4|DGQZcsHmi&I9yV!*CeJhMI}|Ca7k%VNusBIoAf)FSH(!q2UdLocd1r6Wzu_*#sRhy7db6Jsy$_jN zLPNM<@0!2LT)gRd1$BQ?L0YTjm*A!D>2>mJ9bu>T!E+02-$*4~#EPR2gL4uSGK&b{ zUT@FKzwIsG7Ch&_0+-(WrTlV!Yk2*wz79|>&u~e3F_k2_?AcVOIsc+_`K3RReC*xw zM9(f!LhIEK^Hk7G#L~Wr&pT3>IwYxgpW54bH;~@jglgo)#p$(b@HpwM#qVaF)yt8s zup}Pxn=6}A(xde|me`FZ+=nQReN0P1?UPv9-nx*`2PXOKXQ))VlQ7Rw%!iu8a2I~ z)@Dq!Mo4r*AWrpI&S|68T&vl~1#%%a5F$+n%v?;cv#RJWqYArqqV+1v-o167kRG3V zweh3Zh9-S?!5i~};UzEEEZLig#!$>iWQpuO;C}LZQm!6>Gufy&Fy!!-lHr*K^ct^(J98-#3wpNjg6x%4JnORw!Ng1Etd zq{OL|lWi*jt=0CV+LRAsFgDv>2JZs8OWT*=TVV;+TvA=EdCBw2LSC4oPAW7V;q*c} zsSJ6=c4C+GHkMFvvCvSgPqiinCsNg3abj>Xy(G~)(bEq4UM2pVCmIvI3D!H&825@2 zO=|k{LwM>t)saL~X_dK2vrZW%QVLrYvO^+O6^eS{u+m8>I~|L?P@OT6s*^jB>MX_* zy>aWqErji@Gs5EMnCKlQ;YOD*heV^FaATs;0yipfo!nfqgd02Chji}OJDAH-db298 zea`l-OfiL+Q}0?&`e%Wvl9&6Wm%f|PqcRmqW_H4*PlVRH?+44H!g=d;i1O7Z$Z}JX z=7(r1)IBpp-@;agi7an5sxiE&q@0&f2(`Te(?D?DGYsKNRkXm-dWS|7SlOp zHmG3|+0}>(#~L*sAH~bAl1~_~%m97mK%%qc_0IN6rB>y+8YT1fQJK3&(H|};`EPZupNhcU5L{2Mvta2JuBWZAGUR(_JYHtKRUNiLf1^al#cBOk zAAo6z$K&=faeQ^Efoz`U=T9;fo<1W(z<698#d>?ZI!H-%QCH8jRCuIDI3;9Ra?%j* zD1lm*d<$kKvjtOMywa0~Cr?W@$Zd8qJ4C{?gl5O3_oQt7Z^>=apyU(s3SXvvt%J;; zt89Qe3()TeBQ+qdVw9v;m83krKjg%-)ckJMCmH>o$R|jZgKP&9@eM^w#CKbSbuI_9 z{+u?@m`E@62dlXiPLnuHKS`YI;Vz)W(mF2ZOs}-`R@flXM|l`=F=ZZ0g_9?8v}AhL zco7YyrSwTlyjp40S{Etwsa7AMTFMvE@hQUf^q~w4xSWl+^^4Y0;67PpyzM zMW5fs*AK9guojKrc4keSUeRERZIe>8_vzEV1&NyWYaLgK&d%i|RUvVG6==?{TwAz< z`(|f)l~ugaRGNmD6VpmN-7MQjF)*lg5aUwI@$$Izi#~6t&LD*lOJt{z3%UUpUu)7V zS7hJE!{JU{#_AH%6Rl0w6q{9I)o`gg90yuegNqmL9N%?kd3h8sM+mysESE)F;L{x*QbiAsz-gS{x z^Z@rc1`SpH?J8eEv9B|nD7W&Ssfk}7kzxMVXK17$(;MW-^_$Ehc@%s4R!Us@wO%K2 z%>{M!uH8QIHJ2>{ZF1o|R!H6*s{ILrFLNoc=hP_O{4Z=OfL^SxCN_WG^t`Onn$<3t zVWV&8s>r6DJzTQUt<{$0MRtcZ2Cx4Pn%lY6T}qx7>kvq^ua`P#R!J88cnV5kOfutI#h@C z!AxHYAG(k6%9DO1!_We9P^kl*s4wkm!em0#4}D(JrLSlyXW^2qtKAq}mXDDs-A588kLTY`3Vw>w- zEq%h2!9FIXy1Eu(Hr9gV{&MAwO+pKny9ytS0bHeQtc2rqLEnd|ZI>-|!5Geoh?6SM z|7LwME8VK^fI5*CRy?eac+)JzS1X~|0wHwsx)(M`@UOg#f5uh}`YO2;ek`VLq>iIi0~egiZzPC$H%ZuujD63Xh)wn_E&GhQ zB6|&SXB@4K(-*Z-Vu{?WL8u)v^|x??E`&q+;0A$hTxt`AK-mgn#Ic6*5VhY!V zc)V1OYOC*)q2}T4`r8&aLpPjn5iicB)i@(C1^*TL?$@wv5hFMx=h0C7JD` z@nYt3_FGCU!@0sr>>HkzMe8_3!3-Hk-bkTb(X?f#hF*c>TbZb>#fB|1xE<1Ks$G_OJINL%Hn*=-!fqO0s&QbmdT`9!irAO3qDCs;6=9j$ z=H|nlt{9APYz(nFmNQha{LyX}jceg9o;ryI3$n;r5>!R1T%jaBEkY12XOf+-^+!{` z*0HYkTtY`O?<(gls0`;;H?m6W>zJyv&3ii6+_}LH*-Wl`XlC?#O1xSfPz81SMA{{) z$C#r>CbKtLwi{_Dbe^4kVC(YM&-`_Hy+)I-jKhkFJuh9PJs>mwa$hv-%$58H&I+8G ziuGkv>pj_d$r)BqOm#GwYG<~%w$7|xZL3|c^y+m7PNqh0FN~v6j=1i5)p&BmihDEl z%2kg`hHY;a`!G{ltk7D;^p@JQmbuCTuadWNDvEhwE(J>t(pl%i9ztQv>3RvNX_+C) z5K=fTGf0z8rLqT3+0Uf$m2rh}WvN{(MtaIrt6)E{7F50U2`}fJ-N41{+bU61lPc)ur zN%%@bLFVO>)aNmEuFs*8(1ylha=Yv%7lqdc@9^-y!A1~$ z;Wot0ApPsccPS=>J03lM%DhA8&KYyYw3d0?QkgJg{@l|SgcQ!U=e%Uds~j+_-){W| z4(c~}@Q{JSJ+F&qL*HxHDb3RdG&K(!FmzhKBaZOAxOVSa=JY*syxYRlR-d-?l$NKP zPoB4b$m`ti&@mIn2e*ZSyF#bwwvb8_@37zeDYIKnpF97Q(K8p!n>A(ep*l?vB^AU; zDX4NOJ!Iyz`EwV{oxafa52Hu4%xamkpvChBg}i=`E=%Pur}mvYf8N~rcDtxwYSgS* zsrfTcnz3*}YJSUtmiec(H23qo%rhY`vv$;i#dD^mfJ887=1(k2YTo>rvu7@xd0NYY z#?%xGF>UUwSuN9$mAod5J2<6#T&d=D53Km1Y00z2^XHYFYZ6 z(4>k2QUr|%Dx%?p-UR8=TM$JQ0|^ia2_~V5B4Dpv6&s>}V($$rAXZfDqGHEhQ7qW? zpFK&a-Yf6E_rCYO|No=FIeT_zW@l%1XJ+SYsN{zc&!+nDIXq@Gm(5@b6Fr$RBEBGl z%H%==VKYX4ijbQ@jpp)W5;?p$st8(zdBYz8o5`Za@&yv~h&wbYBc3Ca!cS(#Br@aJ zRF06!OiqSPG=zvM;saqBRFZj;Vnj1_0FN)CCZq^O)ZrXq3X=<*O<{5PCNwcPDNuxf zDuo=w=ZTmco{-At0f~q*E+<;R6l4sdrg22^{1g$DNoBE<**q4T7n1?i$VyBelbbOf zcK#%=snjqb5C+YghleI6LQDe;C_6T+2+xfVgQpqblQo7%9VZLFd#?QH0zY9K8jRSz!U5#S^(Ss*q3z`wp0h14od2|k(A9H-hSh}er(q+DoJ)Fr+VK;*^hnEJl zajZK@G+We$VtpLbM&&^%Tp=4&w#~!NcP9k;&lZHkt~FcW2U=z7=*1SH3z$n{5)tEI zGJ`1!Cc5D`7Tmc`bp+}T;}RzTR0wo)|LBATh9%)x{NDxZ%}a%ia91{HKAY?V0xy~7 zhQXA>1-)v84HBw3IL7+Nv4yaO{1ib9N&Q%JQ^!EG7I0I9Fy*zv$ZA1li(!5^=Jk6_ zX-tmDqj@?sbqt4lPrkq-og-?71_Bk1V*7LHd zqa(T%(-s#Mg>FJ&iY5yt*wUj2C)VEp>(;zpn3C`r94^-*DOr@!%mj0wwo_baO)b)_ zzoOUXA0XGCodmO>H&2)>Uf4)YA;}A2LzV`QR2Xt?79zEwcajSo!Fs}6=?04lhnGSw zukNsH$1z205-i9(n_tt^1K0x%#QMF5jwXxsG~0f!~n)KM~x1*foEO*(XVLn;Th z8`1nr!5CrCnoMqS=P=`Ve4&UFBcw3|%ov#EY2;Lkmg|3YCW|ZriYQ=&rTz|oU~&wf z^)Jt*kr#snYl-9rGW7nl+&~okr@4Vf1j&ZX|8oOG4vh#HD$4MOyig%8vS)$W1Xp0I zho$^ZcKz6?Y{={S*QSBVkx=D;21JY6e-SNR|BGlr-qwH17cET|j2v>O=qRzt z^pAl@c369;<^%~A+>KUFj}$x;$09pNeFtB|1)0=rfj?WsL;(j=NAP{4+`xW{;|n-q zBMMo*ow6k*=3P4%N#bAtrzBom^I(EIlZ<1Df0Odg*=%5f4P$eY*#e1k#=^0ve;i>u zk46lUG-;(znma`Yg~`A%(eIJ8b7qj!$3+XA)By=+ieexP5t@<=(I&7zJ<=gzTEq_I zxwBaikrA<3;<=?6HDDItSkiAXx3>8ukt5SkFi^Q{2@0@uMPRn`Va&prT(JlGOS9b^ zw5U@Y1&DDF9)oxW%zpf&WG)*;j(#QRbW2juQ6xnmYF405S@9uku-*ksu;AHA%;b3R za!mvHq7ZfrKMsO#Y*tILl$Xt{>uf*%2^8jN%8A0Uh~JLSuaQ91f}i%6od8#)Q!~Ne zg>Qj+5P@T1f2Rke!}k{>0Fyx^j)ng_KwBcZN8{LtzXRM;z(xU+q`x2?`2KgtwG+@u zu8+sDl)pp$pUHpw< zeOLctT>q{-bpq>Ojw+a%WK0A?0?Lwr01nrZVjP}Hic!p<`5Kj@AV2w$pQ5VNF#akn6mOV97NbaAoSx}`1j&*DG#9J837C|v= z@tBdjF4Q5^O!8tPyt5{@gQsbC!Yc~2OP6*Z5+15nE@HAW&f1w z>`XNrz~u3H8A-6mXkrL$4ROrCO&maN>1C^Xs4|G$AXbwlkv8l8;MUtzgPYxDMRhW=rG4DTq0ONf^T!#lt$Dl{x8C@_Q(<-%Zuc)Nx%JYeHk z3&(~^AhZ%HDE+A|hTEzXMC~w$Z$}w|w*}i)`>D{WYDj51LhB&k_c~DxD8BVeiqSS; zCvYIi?O3gC#{dV4Z6I#9?e3gka-~Bx6xQpAqir?7fhcZ=14 zpMOC=4%M=MNm$#Vg=$m@v~~<=10EsmoIIg^bYiS)K#AKi0dPo#@i!eC3rz2bqix54 zrLc|G9tUD{$5Oz24;G2vuoSI0sPS*ALGBNxmdQ*bV<NWSB6&YHFHRKSyjVjM4AM`PlbL9@i+ne_86rG_$B^)V+N%c%*icCvrW8TI zL0J;`LaSuUdv(wN{tv`4V{wLufDhpoQ3gsL;qj?lKJ20js6ufRgG`RO00IAVr(ahf zLgvE{R}%M^#!`ed>6JKKHHexdjNuC)L8RraZ_CcU^&M?v!?cRAvZmWvSh5LQyS^Qf zK_&#mJEb^bwML!SgCFz>a&brh`+LpEJ26gq@tG-3OB`>G+E=_Z=un9B(aO@LgO2ib z7#D)K4!-j9Yv9eo!fv-7yR#mdQT1MayM5@*#NH-fJyMr;(TYvb2;28Wv-as@J#5`x zLil2-i`A*qf!ne#299&;6`!*tB3H~b@q1+PcEQV_hc(wq3dcyNwuy_Ty zz$^dB@M1p)Ud?9#nhbdT9~~=!j*;L&>3Dc|UwUi;I`se@@PG~!5Mk(rei7yZZMkrj zMz%-joZO%Z5B{OYVE{Z1fHBE;|4|cOyD}!6s=$TDe()>?OT-}G3kyL9DDcRm3lJSC zaMS@h$^ZdrsU|+40UZ?~!aPZOldy2|tO7)#w4V4VgG6ivfE|RYq2mbHm_G?rgt@|X z5P6MG(m+QnpwlD}$ZODGpf^yF3QZ7ySkPB=s0KP|L3;iJA{>>luy0VkN89RB5Orj-p9gM6Tn18AjuVZfCT_|lg7E>MF-mLc|w&)5*3ZQ&2Ch2)V7 zB61)hY|vSt(jywsmg_oIO7(*Z#+_*_Fh`C1vQFWB^t1WZIz3NC3I-Ns2_=q510N{!S6(Q%463uRA8DeJ& zkS3L5p+t7k85KzS(P1HCI?z{J!+{!n7;1Fd2T}?o@X|3rUlC1+=|=={p`uvs!^q)4 z+zA7IA$Z}-Dg*bqXlugX+7|=}3V6tPVa~?oMGN8}Emcs+{P^jzD zrasa%@%RgVsgB#B*?>@hgihc{CglyGYRk-FP{IWkqHjQ^cC0LxC!~Ui&xSCM*mtai zPETQy>Kg#nkfb1<$AM^|;p39LN6p7T^N3Jj8WMjr%`o7xKRia&TXOsPh0p`0pQ)Gb68u2X|4Kg6$Al6njK3LAwCvnD@>1uzXl{8e#8P1 z_5!L6YAgQe{1`O;V!aWI51Mq)teSxh2IJPS<0KUbLoR~ea$vGSpp#RC@j!AcM<5i@@IfwI1PVxj zSUzM^!bSl+gtSeR=GpB2QUQjDO~dU-kjUg9?G!|?IHwfgvc)?lEGjb&<(i_D(p1>C z6L*@1Yq&J`9`%DG0P6$~Y3%@0S$wvbk6@gEAtR*%Wv@a`5Ksi1YsucFrZI(NYz6ip zIB0{094i`bK+?#ABnTJ}ao+@FmKmEwNryc}4rIZ`z$4TMXiQ;KNrMjVSa3)VUxb}&2>?SB1q&&im35Go|J%S*eszYF$IZ$0*#&|_yEkNQKVon^Uj=iGWQ?UyId;>jBI*tOd?N)-vP- z(r{tBF>EWl)U(InmBs6b;D!&3u^_4~fLoAlN6SCW!-R%{nicKByeX z?Pp2S3Z;qYtvcb1JCI`$i{3QosAJ?{pqzG?eOR2>SkQT%NHR;rfbb6m#rTuC99Y-F zmR1;B1{>r8ATXfU(n&-7A^_5KauydWH)4ypf+UY#h)9*r!!+E5Oa~UvhLHC$m#QAi5MusxU7Kb@o1XOxZp7su-FK~qzg`>&gxRz7N6 zn#xhVli~GFwMVnna&M}+b-xt)V9!rQuA|$naZlFYi%Wav{X+O<*ufdfJM$)L%XUvR z@yJ@AZ8PE0Fx$0Duc*f!>RnbjJLbwE^QWp`jkCHMKei++1aVVD@f&?24G$iWsdM#H za@zx@7KW|4UcVB<{e#fW9Qe_M?=9B6152vp17<0_afD(0;inj$6}NhZs-Qfl)}N42 z_t3x(p)Yr*Tlno{Eu!y!oQW)T%-uec85xD%oQR}H3M2W^36VlU%x|+WA|=@nk*#vr z;PsQ=wxcDpqOs6nF6LI&n1q~2DLK&PUSAE_CLu|VSk>Y4dtO7gq#woaa7~rowf+0G zRXy0jgtuDUxmAxbY*&7VbSTID<$B2yPGQcQBW~fkN zj*I;&vI4f9{AO+(XhMe|icNRq3Acsx7+Iwl(frR6JS5-&Rw{D>c6-EL8}hA@$0USu z7x*m>Cs3QOejQjqr%WQe=mbmTIv^`pC^`4B1x5_?r@gl;uEhW!-VekR^^XOAS#X6M zl31vZTr?`cLm>-98#=QRd0yzmOjS$?p0)f#9ta!m|I7zRUP+6AkMK#IKy<#Plu~IQ z#eoZS{v(x)afss^V(wCb8ZoXg7%TCe)C+7m#}aw|QVym8)dGkE(4aP=6FD*M=|`tG zq8JJ~T@&>K)j?EBLp(8XEsnMzjC}GLkED+VGUx$t+t-%5%v6Ya^Pq(YY9xTSf?PZ* z>D~$94)K9XN>2*#9`V{*p=jf9SxM3q2 z=!+xe2F-fOAU%jHaiEnVsIvjaup>??IYWw1xJ5Iul!Hj0Q9uT1Pzw|!_h>#qk|&Tv z1(1OyAp069sfpwXNw0K_&>fnAe>I-}*qF5Lb2KRjL4XsT;3_^Mw}rd^JT4#?=I&1FonLdV1}o|8pHzWpkvk`oitckQ3Tl@Qv`Tu1wtG|u{`k%^5-xKXp2^} z|Ns1-=D-_yOa&N#z5?U_KmYeQFcvo8F$x7Ea!rEd6$efmHtnM-u0ScyHF1HWAru^^ zcO_Ki6~|5+hO=ehOAO>NB2r$-R34Ybb8RTNY;geLM`*Vy=}IWd;8?NjDoQp+(A%|@ zrN%voj)_nd?7#h}LGJ4ycGq&n5lW8S;SXw-m|2wX)9PECt9q5ll`ki9W!DtTpyM<( zEPzD)VMRH2OSbP#BZo*Ift3)8s{lzkGw8~MqP$F)th@##ER?Q6bVD~?G?XBa5)V-% z5uZm_C)7}(f`&o}o0Y`pvFN=BeN>{Pq1Bw4)B2z()t1uVkw6eM zF$w)!NRYfM52QkL6=hDLFj3K4q+_9;-NdYW5*C5r*ug`lF*&U;bg0gSTeHVT0T~y+^qrDyWSrjO}oMqP| zdiph+vo=5cw(obzeLFQ2FSI(QZ?UO-^N{P7I}JCE&e7Vi|J!qw$UWobViruTn_5}E zrihv0cXqT@z5Ud8nL6WR2<7_EE)o13;fSDW_mzddRsHR1zw&r@dVQT|s}elV_A~p! z{nrU87w&uy2uYWp<@xnKFMi+o-Q7wL*6kd=yUV9}LEQHj?Co}Zrd;qd-ZAK|#iT}sab#|Ee=6>LHA3gQwhjaoxl_saouKc9TbglaML5{l6I5wa*&h+}3F-8IT z$Jkl3)Ex`&q>Oz#x$dfb$xG8Q?}f_Wy-K_mR1A;06!%?c+~A7OJ&hT`t5;V|@?P=y zu0vRPti{Lbb>TbL_1m#5gSzH$@<9`>{gzVi(3wxLz><7q_VT1~Z=Ek4?N z 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 E4@o0tBme*a literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll new file mode 100644 index 0000000000000000000000000000000000000000..40afae8203d659b8d83228a9bfca9e64cd3f3ab4 GIT binary patch literal 45072 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 literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll diff --git a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml similarity index 100% rename from Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml rename to Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml From 8ff71da63dd808e38fdb020aad625253f7a19eb3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:44:56 +0800 Subject: [PATCH 140/150] ACSC Essential Eight Test Suite --- .../Tests/Push-CIPPTestsList.ps1 | 2 +- .../Public/Invoke-CIPPTestCollection.ps1 | 4 +- .../Public/Helpers/Test-E8AsrRule.ps1 | 66 ++++++++++++++++ .../Devices/Invoke-CippTestE8_AppCtrl_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppCtrl_01.ps1 | 41 ++++++++++ .../Devices/Invoke-CippTestE8_AppCtrl_02.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_02.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_03.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_03.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_04.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_04.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_05.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_05.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppHard_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_01.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_02.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_02.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_03.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_03.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_04.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_04.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_05.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_05.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_06.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_06.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_07.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_07.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_08.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_08.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_09.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_09.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_10.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_10.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_11.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_11.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_12.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_12.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_13.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_13.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_14.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_14.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_01.md | 14 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_01.ps1 | 14 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_02.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_02.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_03.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_03.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_04.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_04.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_05.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_05.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_06.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_06.ps1 | 9 +++ .../E8/Devices/Invoke-CippTestE8_Macro_07.md | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_07.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_PatchApp_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchApp_01.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchApp_02.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_02.ps1 | 41 ++++++++++ .../Devices/Invoke-CippTestE8_PatchApp_03.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_03.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchApp_04.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_04.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchOS_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_01.ps1 | 43 +++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_02.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_02.ps1 | 52 +++++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_03.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_03.ps1 | 38 ++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_04.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_04.ps1 | 42 +++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_05.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_05.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchOS_06.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_06.ps1 | 33 ++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_01.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_01.ps1 | 59 +++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_02.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_02.ps1 | 61 +++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_03.md | 16 ++++ .../Identity/Invoke-CippTestE8_Admin_03.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_04.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_04.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_05.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_05.ps1 | 69 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_06.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_06.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_07.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_07.ps1 | 75 +++++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_08.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_08.ps1 | 70 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_09.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_09.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_10.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_10.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_11.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_11.ps1 | 45 +++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_12.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_12.ps1 | 58 ++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_13.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_13.ps1 | 9 +++ .../Identity/Invoke-CippTestE8_Backup_01.md | 13 ++++ .../Identity/Invoke-CippTestE8_Backup_01.ps1 | 49 ++++++++++++ .../Identity/Invoke-CippTestE8_Backup_02.md | 13 ++++ .../Identity/Invoke-CippTestE8_Backup_02.ps1 | 8 ++ .../Identity/Invoke-CippTestE8_Backup_03.md | 12 +++ .../Identity/Invoke-CippTestE8_Backup_03.ps1 | 8 ++ .../Identity/Invoke-CippTestE8_Backup_04.md | 14 ++++ .../Identity/Invoke-CippTestE8_Backup_04.ps1 | 8 ++ .../E8/Identity/Invoke-CippTestE8_MFA_01.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_01.ps1 | 49 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_02.md | 16 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_02.ps1 | 42 +++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_03.md | 17 +++++ .../E8/Identity/Invoke-CippTestE8_MFA_03.ps1 | 39 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_04.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_04.ps1 | 40 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_05.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_05.ps1 | 38 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_06.md | 16 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_06.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_07.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_07.ps1 | 67 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_08.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_08.ps1 | 50 +++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_09.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_09.ps1 | 46 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_10.md | 17 +++++ .../E8/Identity/Invoke-CippTestE8_MFA_10.ps1 | 41 ++++++++++ Modules/CIPPTests/Public/Tests/E8/report.json | 74 ++++++++++++++++++ 130 files changed, 2665 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/report.json diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index d018d1d0aa9d5..ead567fdb9bf5 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -37,7 +37,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8') $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index e03fd8ee901f7..1963ff0b6de30 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -17,6 +17,7 @@ function Invoke-CIPPTestCollection { - CIS → Invoke-CippTestCIS_* - SMB1001 → Invoke-CippTestSMB1001_* - CopilotReadiness → Invoke-CippTestCopilotReady* + - E8 → Invoke-CippTestE8_* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function requires a ScriptGuid parameter to filter the table query) @@ -33,7 +34,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'E8', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -51,6 +52,7 @@ function Invoke-CIPPTestCollection { SMB1001 = 'Invoke-CippTestSMB1001_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' + E8 = 'Invoke-CippTestE8_*' } $SuiteStopwatch = [System.Diagnostics.Stopwatch]::StartNew() diff --git a/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 new file mode 100644 index 0000000000000..da1f1d06ec693 --- /dev/null +++ b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 @@ -0,0 +1,66 @@ +function Test-E8AsrRule { + <# + .SYNOPSIS + Internal helper used by E8 Macro/AppHard tests to verify a single Defender ASR rule child setting is enabled and assigned. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Tenant, + [Parameter(Mandatory)] [string] $TestId, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $RuleSettingId, + [Parameter(Mandatory)] [string] $FriendlyRule, + [string] $Risk = 'High', + [string] $Category, + [string] $UserImpact = 'Medium', + [string] $ImplementationEffort = 'Medium' + ) + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $AsrPolicies = $ConfigPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.technologies -like '*mdm*' -and + ($_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules') + } + + if (-not $AsrPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Defender Attack Surface Reduction policy is configured for Windows 10/11.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Matching = foreach ($P in $AsrPolicies) { + $children = $P.settings.settingInstance.groupSettingCollectionValue.children + $found = $children | Where-Object { $_.settingDefinitionId -eq $RuleSettingId } + $value = $found.choiceSettingValue.value + if ($value -like '*_block' -or $value -like '*_warn') { + [pscustomobject]@{ Policy = $P; Value = $value } + } + } + + if (-not $Matching) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "No ASR policy enables ``$FriendlyRule`` (in Block or Warn mode)." -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Assigned = $Matching | Where-Object { $_.Policy.assignments -and $_.Policy.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "ASR rule ``$FriendlyRule`` is enabled and assigned in $($Assigned.Count) policy/policies." + } else { + $Status = 'Failed' + $Result = "ASR rule ``$FriendlyRule`` is configured but not assigned to any group/device." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md new file mode 100644 index 0000000000000..504d1286c14b6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md @@ -0,0 +1,13 @@ +Application control (allowlisting) is the most effective single mitigation against malware. Implement WDAC, Smart App Control, or AppLocker on all Windows endpoints. + +**Remediation Action** + +1. Intune > Endpoint security > Account protection / Attack surface reduction > **App and browser control** or **Microsoft Defender Application Control (WDAC)**. +2. Deploy a base policy in audit mode, then move to enforced mode. +3. Assign to all Windows devices. + +**Links** +- [WDAC overview](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 new file mode 100644 index 0000000000000..1b650e5e478d0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_AppCtrl_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML1) - Application control is implemented on workstations + #> + param($Tenant) + + $TestId = 'E8_AppCtrl_01' + $Name = 'Application control (WDAC / Smart App Control / AppLocker) is configured for Windows endpoints' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + return + } + + $AppControlPolicies = $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'applicationcontrol') -or ($ids -match 'smartappcontrol') -or ($ids -match 'applocker') + } + $Assigned = $AppControlPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) application-control policy/policies (WDAC/Smart App Control/AppLocker) are configured and assigned." + } elseif ($AppControlPolicies) { + $Status = 'Failed' + $Result = "$($AppControlPolicies.Count) application-control policy/policies exist but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No WDAC, Smart App Control, or AppLocker configuration policy is deployed via Intune.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md new file mode 100644 index 0000000000000..e85bbf1e8c602 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md @@ -0,0 +1,12 @@ +ISM-0843 — application control covers more than `.exe`. Scripts (PS1/JS/VBS), DLLs, MSIs, HTAs, drivers, and control panel applets must all be subject to allowlisting. + +**Remediation Action** + +1. Author / extend WDAC policy XML to set `Enabled:Audit Mode` off for the additional file rule levels (DLL, Script, MSI, etc.). +2. Deploy via Intune > Endpoint security > Application Control for Business policy XML. + +**Links** +- [WDAC policy file rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/select-types-of-rules-to-create) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 new file mode 100644 index 0000000000000..ecc0ac4cb9a2d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - App control allowlist covers all executable types (ISM-0843) + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC/AppLocker rules cover executables, software libraries (DLLs/OCX), scripts (PS1, JS, VBS), installers (MSI/MSIX), compiled HTML, HTA, control panel applets, and drivers. The full rule contents are stored as XML inside Intune profiles which are not easily summarised; review the deployed policy in Intune.' -Risk 'High' -Name 'Application control covers all executable types (ISM-0843)' -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md new file mode 100644 index 0000000000000..34246723caa07 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md @@ -0,0 +1,12 @@ +Microsoft maintains a Recommended Block Rules list that bans known LOLBin abuse (e.g. `bginfo`, `cdb`, `csi`, `dnx`, `mshta` minus exceptions). Merge this list into your WDAC policy. + +**Remediation Action** + +1. Download the latest WDAC recommended block rules XML from Microsoft. +2. Merge with your base policy and re-deploy via Intune. + +**Links** +- [Microsoft recommended block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/applications-that-can-bypass-wdac) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 new file mode 100644 index 0000000000000..e9f531eeb1f3d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft''s **recommended block rules** (LOLBins such as bash, bginfo, cdb, msbuild, powershell_ise.exe, etc.) are deployed via WDAC. The block list is published as XML at `https://aka.ms/wdac-block-rules` and is delivered through an Intune WDAC policy XML file.' -Risk 'High' -Name 'Microsoft recommended WDAC block rules are deployed' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md new file mode 100644 index 0000000000000..5a06847acb07e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md @@ -0,0 +1,12 @@ +Microsoft's vulnerable driver blocklist prevents kernel-level BYOVD attacks. On Windows 11 22H2+ it is auto-enabled with Memory Integrity; older builds require a WDAC driver policy. + +**Remediation Action** + +1. Intune > Settings catalog > **Memory Integrity / HVCI** = Enabled. +2. Confirm `Enable Microsoft Vulnerable Driver Blocklist` is on. + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 new file mode 100644 index 0000000000000..939f67181246d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended driver block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Microsoft Vulnerable Driver Blocklist is enabled via *Memory Integrity / Core Isolation*, or via WDAC driver block XML. From Windows 11 22H2 the blocklist is on by default when Memory Integrity is enabled; verify in Settings catalog under *Defender > Allow Memory Integrity*.' -Risk 'High' -Name 'Microsoft vulnerable driver blocklist is deployed' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md new file mode 100644 index 0000000000000..6e21e03d58393 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md @@ -0,0 +1,12 @@ +App-control logs (CodeIntegrity event log, AppLocker event logs) must be centrally collected so blocked-execution events become detection signals. + +**Remediation Action** + +1. Sentinel > Data connectors > Windows Security Events via AMA — include `Microsoft-Windows-CodeIntegrity/Operational` and `Microsoft-Windows-AppLocker/EXE and DLL`. +2. Or: ingest via Defender for Endpoint Advanced Hunting (`DeviceEvents | where ActionType startswith "AppControlCodeIntegrity"`). + +**Links** +- [WDAC event logs](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/operations/event-id-explanations) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 new file mode 100644 index 0000000000000..489faed92a296 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML3) - Application control event logs are centrally collected + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC / AppLocker event logs (Microsoft-Windows-CodeIntegrity, Microsoft-Windows-AppLocker) are forwarded to a SIEM (Sentinel via the Windows Security Events connector or Defender for Endpoint AdvancedHunting).' -Risk 'Medium' -Name 'Application control event logs are centrally collected' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md new file mode 100644 index 0000000000000..b31776277713e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md @@ -0,0 +1,13 @@ +Web browsers are the most-attacked client application in the enterprise. Disable Flash (now removed), Java applets, and reduce drive-by exposure with an enterprise ad-blocker. + +**Remediation Action** + +1. Intune > Configuration profiles > Settings catalog > Microsoft Edge. +2. Disable plug-ins (`PluginsBlockedForUrls = *`) and Java; deploy an enterprise ad-blocker (uBlock Origin / NoScript / Edge tracking prevention strict). +3. Repeat for Chrome / Firefox where deployed. + +**Links** +- [ACSC Essential Eight - User Application Hardening](https://learn.microsoft.com/en-us/compliance/anz/e8-uah) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 new file mode 100644 index 0000000000000..16a0a60b1529b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_01 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Web browsers block Flash, web ads and Java content + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Edge / Chrome / Firefox managed policies disable Flash and Java plugins, and that an enterprise ad-blocking solution is in place. Browser policies (e.g. Edge ADMX *PluginsBlockedForUrls*, *DefaultPluginsSetting*) live in the Settings Catalog; confirming end-to-end enforcement requires inspection beyond what is cached.' -Risk 'High' -Name 'Web browsers block Flash, web ads, and Java content (ISM-1486)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md new file mode 100644 index 0000000000000..3d9869a1fb8fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md @@ -0,0 +1,12 @@ +Internet Explorer 11 is retired and unsupported. Even though Edge can render legacy sites in IE Mode, the standalone IE11 desktop must be disabled to prevent its insecure scripting engines being exposed. + +**Remediation Action** + +1. Intune > Settings catalog > Internet Explorer > **Disable Internet Explorer 11 as a standalone browser** = Enabled. +2. Curate the IE Mode site list in Edge Update for any legacy line-of-business apps. + +**Links** +- [Internet Explorer 11 desktop app retirement](https://learn.microsoft.com/en-us/lifecycle/announcements/internet-explorer-11-end-of-support-microsoft-365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 new file mode 100644 index 0000000000000..1da0a2a831aef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_02 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Internet Explorer 11 is disabled or removed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. IE11 has been retired by Microsoft, but the legacy MSHTML engine and IE mode still exist on Windows. Confirm IE11 desktop is disabled via the *DisableInternetExplorerApp* policy and that any IE-mode site list is curated. CIPP cannot verify per-device installation state of legacy components from Graph.' -Risk 'High' -Name 'Internet Explorer 11 is disabled or removed (ISM-1666)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md new file mode 100644 index 0000000000000..78832029e053d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md @@ -0,0 +1,12 @@ +Malicious PDFs are a long-standing delivery vector. Configure the standard PDF viewer with Protected View on, JavaScript disabled, and external content blocked. + +**Remediation Action** + +1. Intune > Settings catalog > deploy ADMX for the chosen viewer (Adobe Acrobat, Foxit, Edge built-in viewer). +2. Disable JavaScript inside PDFs and enable Protected View / Sandbox. + +**Links** +- [Acrobat enterprise hardening](https://www.adobe.com/devnet-docs/acrobatetk/tools/AdminGuide/index.html) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 new file mode 100644 index 0000000000000..d52b8cfd98777 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_03 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - PDF viewers are configured securely + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the standard organisation PDF viewer (Edge, Adobe Acrobat Reader, Foxit, etc.) is configured with Protected View / Sandbox enabled and JavaScript disabled. PDF viewer configuration is application-specific and not exposed via Graph; verify by reviewing the deployed Intune ADMX/Settings Catalog policy.' -Risk 'High' -Name 'PDF viewers are configured securely (Protected View, no JavaScript)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md new file mode 100644 index 0000000000000..7ef2b88de046e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md @@ -0,0 +1,12 @@ +.NET Framework 3.5 (which carries .NET 2.0 and 3.0 runtimes) lacks modern hardening — no AMSI, no per-app strong name verification — and is a frequent ROP gadget source. Remove it from SOEs. + +**Remediation Action** + +1. Intune > Endpoint security > Compliance policies > custom compliance script: fail when `(Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3').State -eq 'Enabled'`. +2. Remediate via PSscript: `Disable-WindowsOptionalFeature -Online -FeatureName NetFx3`. + +**Links** +- [.NET Framework lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/microsoft-net-framework) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 new file mode 100644 index 0000000000000..991de2ffee7a4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_04 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Legacy .NET Framework 3.5/2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm .NET Framework 3.5 (which includes 2.0 and 3.0) is uninstalled or never installed on standard SOEs. CIPP can list detected applications via the Intune Discovered Apps inventory but the optional Windows feature state is not surfaced; verify with an Intune Compliance Policy or PowerShell script (Get-WindowsOptionalFeature -FeatureName NetFx3).' -Risk 'High' -Name '.NET Framework 3.5/2.0 is removed (ISM-1655)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md new file mode 100644 index 0000000000000..94b748e3d3a20 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md @@ -0,0 +1,12 @@ +Windows PowerShell 2.0 lacks AMSI, ScriptBlockLogging, and the Constrained Language Mode hardening present in 5.1+. Attackers downgrade to v2 to bypass logging. Remove the optional feature. + +**Remediation Action** + +1. Intune > Compliance / Remediation script: `Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2,MicrosoftWindowsPowerShellV2Root -NoRestart`. +2. Verify with `Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2*`. + +**Links** +- [PowerShell v2 deprecation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-50) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 new file mode 100644 index 0000000000000..a79786597fc3e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_05 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Windows PowerShell 2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Windows optional feature *MicrosoftWindowsPowerShellV2* is removed from all Windows endpoints. PowerShell 2.0 lacks AMSI and ScriptBlockLogging. Verify with an Intune compliance script (Get-WindowsOptionalFeature -FeatureName MicrosoftWindowsPowerShellV2*).' -Risk 'High' -Name 'Windows PowerShell 2.0 is removed (ISM-1622)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md new file mode 100644 index 0000000000000..e867d1a70201e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md @@ -0,0 +1,13 @@ +Credential theft from `lsass.exe` (e.g. Mimikatz) is the foundation of most lateral movement attacks. The ASR rule blocks reads against LSASS memory by untrusted processes. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block credential stealing from the Windows local security authority subsystem* to **Block**. +3. Assign to all Windows endpoints; pair with Credential Guard. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 new file mode 100644 index 0000000000000..26078a0bf9864 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_06 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block credential stealing from LSASS" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_06' ` + -Name 'ASR rule "Block credential stealing from the Windows local security authority subsystem (lsass.exe)"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' ` + -FriendlyRule 'Block credential stealing from the Windows local security authority subsystem' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md new file mode 100644 index 0000000000000..cc2f7ea7a3942 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md @@ -0,0 +1,13 @@ +Email is the #1 phishing vector. The ASR rule **Block executable content from email client and webmail** prevents Outlook (or web browsers viewing webmail) from saving and launching `.exe`/`.scr`/`.js` attachments. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block executable content from email client and webmail* to **Block**. +3. Assign to all Windows endpoints. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 new file mode 100644 index 0000000000000..932ef79741997 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_07 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block executable content from email and webmail" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_07' ` + -Name 'ASR rule "Block executable content from email client and webmail"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutablecontentfromemailclientandwebmail' ` + -FriendlyRule 'Block executable content from email client and webmail' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md new file mode 100644 index 0000000000000..ecf5bbac7c7d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md @@ -0,0 +1,12 @@ +Obfuscated PowerShell, JavaScript, and VBScript are hallmarks of malware delivery. The ASR rule blocks scripts that exhibit obfuscation patterns from running. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block execution of potentially obfuscated scripts* to **Block** (start with *Warn* if you suspect false positives). + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 new file mode 100644 index 0000000000000..3f5a18f499c81 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_08 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block execution of potentially obfuscated scripts" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_08' ` + -Name 'ASR rule "Block execution of potentially obfuscated scripts"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutionofpotentiallyobfuscatedscripts' ` + -FriendlyRule 'Block execution of potentially obfuscated scripts' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md new file mode 100644 index 0000000000000..e77c618ad94f4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md @@ -0,0 +1,12 @@ +Many drive-by downloads end with a JavaScript or VBScript stub that pulls down and runs a binary. This ASR rule terminates that hand-off. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block JavaScript or VBScript from launching downloaded executable content* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 new file mode 100644 index 0000000000000..20c1cf782dcda --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_09 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block JS/VBS launching downloaded executable content" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_09' ` + -Name 'ASR rule "Block JavaScript or VBScript from launching downloaded executable content"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent' ` + -FriendlyRule 'Block JavaScript or VBScript from launching downloaded executable content' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md new file mode 100644 index 0000000000000..ef8f7ddfe8434 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md @@ -0,0 +1,13 @@ +Removable media is a common malware vector. This ASR rule blocks unsigned/untrusted executables from launching when run from USB drives. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block untrusted and unsigned processes that run from USB* to **Block**. +3. Pair with a removable storage access policy if USB is not required. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 new file mode 100644 index 0000000000000..6c9cd88b38c95 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_10 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block untrusted/unsigned processes from USB" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_10' ` + -Name 'ASR rule "Block untrusted and unsigned processes that run from USB"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuntrustedunsignedprocessesthatrunfromusb' ` + -FriendlyRule 'Block untrusted and unsigned processes that run from USB' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md new file mode 100644 index 0000000000000..4aaa8a80dbcc3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md @@ -0,0 +1,13 @@ +PsExec and WMI process creation are heavily abused for lateral movement. This ASR rule blocks processes spawned through these mechanisms unless explicitly allowed. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block process creations originating from PsExec and WMI commands* to **Block**. +3. Note: this may impact some legitimate management tooling — pilot first. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 new file mode 100644 index 0000000000000..20bcc8b1022ac --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_11 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block process creations from PsExec and WMI commands" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_11' ` + -Name 'ASR rule "Block process creations originating from PsExec and WMI commands"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockprocesscreationsfrompsexecandwmicommands' ` + -FriendlyRule 'Block process creations originating from PsExec and WMI commands' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md new file mode 100644 index 0000000000000..8d7cd18f39d19 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md @@ -0,0 +1,13 @@ +Bring-Your-Own-Vulnerable-Driver (BYOVD) is a common kernel-privilege escalation technique. This ASR rule blocks Microsoft's curated list of known-bad signed drivers from loading. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block abuse of exploited vulnerable signed drivers* to **Block**. +3. Pair with the Microsoft vulnerable driver blocklist (Smart App Control / WDAC). + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 new file mode 100644 index 0000000000000..7f8efedb98f31 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_12 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block abuse of exploited vulnerable signed drivers" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_12' ` + -Name 'ASR rule "Block abuse of exploited vulnerable signed drivers"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockabuseofexploitedvulnerablesigneddrivers' ` + -FriendlyRule 'Block abuse of exploited vulnerable signed drivers' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md new file mode 100644 index 0000000000000..e07093adf7364 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md @@ -0,0 +1,12 @@ +WMI event subscriptions are a stealthy persistence mechanism (ATT&CK T1546.003). This ASR rule blocks creation of new WMI event consumers/filters/bindings. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block persistence through WMI event subscription* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 new file mode 100644 index 0000000000000..5a88b0185e7c2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_13 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block persistence through WMI event subscription" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_13' ` + -Name 'ASR rule "Block persistence through WMI event subscription"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockpersistencethroughwmieventsubscription' ` + -FriendlyRule 'Block persistence through WMI event subscription' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md new file mode 100644 index 0000000000000..1441fa643b5b4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md @@ -0,0 +1,13 @@ +Defender's advanced ransomware protection rule uses cloud-delivered ML to detect and block ransomware behaviour patterns even when the binary itself is unknown. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Use advanced protection against ransomware* to **Block**. +3. Pair with Controlled Folder Access for additional anti-ransomware defence. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 new file mode 100644 index 0000000000000..25bf3b6c77be4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_14 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Use advanced protection against ransomware" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_14' ` + -Name 'ASR rule "Use advanced protection against ransomware"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_useadvancedprotectionagainstransomware' ` + -FriendlyRule 'Use advanced protection against ransomware' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md new file mode 100644 index 0000000000000..bda0c651ef616 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md @@ -0,0 +1,14 @@ +Office macros are a major delivery vector for malware. The Defender Attack Surface Reduction rule **Block Win32 API calls from Office macros** stops a macro from invoking the Windows API directly, which is how most macro-based loaders detonate. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction > Create policy (Windows 10+, Endpoint detection and response/Attack Surface Reduction Rules). +2. Set *Block Win32 API calls from Office macros* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [ACSC Essential Eight - Configure Microsoft Office macros](https://learn.microsoft.com/en-us/compliance/anz/e8-macro) +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 new file mode 100644 index 0000000000000..8403fac123f86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 @@ -0,0 +1,14 @@ +function Invoke-CippTestE8_Macro_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Win32 API calls from Office macros are blocked via ASR + #> + param($Tenant) + + $TestId = 'E8_Macro_01' + $Name = 'ASR rule "Block Win32 API calls from Office macros" is enabled and assigned' + $RuleId = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros' + $Friendly = 'Block Win32 API calls from Office macros' + + Test-E8AsrRule -Tenant $Tenant -TestId $TestId -Name $Name -RuleSettingId $RuleId -FriendlyRule $Friendly -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md new file mode 100644 index 0000000000000..3f90f556da36d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md @@ -0,0 +1,13 @@ +Office macros frequently drop and execute payloads. The ASR rule **Block Office applications from creating executable content** prevents Word/Excel/PowerPoint from writing `.exe`/`.dll`/`.scr`/macros that drop binaries to disk. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from creating executable content* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 new file mode 100644 index 0000000000000..0f7d019aeaa2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating executable content via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_02' ` + -Name 'ASR rule "Block Office applications from creating executable content" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfromcreatingexecutablecontent' ` + -FriendlyRule 'Block Office applications from creating executable content' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md new file mode 100644 index 0000000000000..70785a432a746 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md @@ -0,0 +1,13 @@ +Macro-based attacks routinely launch PowerShell, cmd, or wscript as a child of Word/Excel. The ASR rule **Block all Office applications from creating child processes** blocks this entire technique class. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block all Office applications from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 new file mode 100644 index 0000000000000..8089fc95e1c9d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_03' ` + -Name 'ASR rule "Block all Office applications from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses' ` + -FriendlyRule 'Block all Office applications from creating child processes' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md new file mode 100644 index 0000000000000..eabf6b3df9abb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md @@ -0,0 +1,13 @@ +Code injection is a common technique used by malicious macros to evade detection by piggy-backing on a legitimate process. The ASR rule **Block Office applications from injecting code into other processes** disrupts this. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from injecting code into other processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 new file mode 100644 index 0000000000000..f524854516dd0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office apps blocked from injecting code via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_04' ` + -Name 'ASR rule "Block Office applications from injecting code into other processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfrominjectingcodeintootherprocesses' ` + -FriendlyRule 'Block Office applications from injecting code into other processes' ` + -Risk 'High' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md new file mode 100644 index 0000000000000..085a3c6f0fb51 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md @@ -0,0 +1,13 @@ +Outlook is a frequent first-stage delivery vector. The ASR rule **Block Office communication application from creating child processes** blocks Outlook from spawning PowerShell, cmd, or scripting hosts — a key step in many phishing kill-chains. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office communication application from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 new file mode 100644 index 0000000000000..a128ebe36cbad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office communication apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_05' ` + -Name 'ASR rule "Block Office communication application from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficecommunicationappfromcreatingchildprocesses' ` + -FriendlyRule 'Block Office communication application from creating child processes' ` + -Risk 'Medium' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md new file mode 100644 index 0000000000000..1584b4db20158 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md @@ -0,0 +1,13 @@ +Microsoft now blocks VBA macros from the internet by default in supported Office versions, but the policy must be enforced via Office Cloud Policy or Intune ADMX templates to be guaranteed in-tenant. Verify the *Block macros from running in Office files from the Internet* policy is on for Word, Excel, PowerPoint, Visio, and Outlook. + +**Remediation Action** + +1. Office Cloud Policy Service ([config.office.com](https://config.office.com)) > Customization > Policy configurations. +2. Search **Block macros from running in Office files from the Internet**. +3. Enable for each Office app and assign to all users. + +**Links** +- [Macros from the internet are blocked by default in Office](https://learn.microsoft.com/en-us/deployoffice/security/internet-macros-blocked) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 new file mode 100644 index 0000000000000..314dbba5b1b25 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Macros from the internet are blocked + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_06' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the *Block macros from running in Office files from the Internet* policy is set in the Office Cloud Policy Service (or the corresponding Microsoft Endpoint Manager Settings Catalog ADMX values for Word/Excel/PowerPoint/Visio/Outlook). The setting lives under each application''s Trust Center.' -Risk 'High' -Name 'Macros from the internet are blocked' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md new file mode 100644 index 0000000000000..05b5c8a89743a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md @@ -0,0 +1,12 @@ +Office VBA integrates with the Antimalware Scan Interface (AMSI) so Defender can scan macro contents at runtime. Confirm AMSI/Defender is enabled and that the *Macro Runtime Scan Scope* policy is set to *Enable for all documents*. + +**Remediation Action** + +1. Office Cloud Policy / Intune ADMX > **Macro Runtime Scan Scope** = *Enable for all documents*. +2. Confirm Defender Antivirus is the active AV (or third-party with macro AMSI integration). + +**Links** +- [Office VBA + AMSI](https://learn.microsoft.com/en-us/microsoft-365-apps/security/integration-with-amsi) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 new file mode 100644 index 0000000000000..9eaf219728b40 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML3) - Macros are scanned by anti-virus software + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_07' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Office *Macro Runtime Scan Scope* policy is set to *Enable for all documents* and that Microsoft Defender Antivirus AMSI is enabled on all Windows endpoints. AMSI integration with Office macros is on by default on supported builds; this control is verified by inspecting Defender + Office configuration which is not exposed via Graph in a deterministic way.' -Risk 'High' -Name 'Macros are scanned by anti-virus software' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md new file mode 100644 index 0000000000000..ac259b5b35e68 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md @@ -0,0 +1,13 @@ +Office and other internet-facing apps must auto-update so vulnerabilities are remediated within the E8 windows (2 weeks ML1, 48 hours ML2, fully supported only ML3). + +**Remediation Action** + +1. Office Cloud Policy > **Update Channel** = *Current Channel* or *Monthly Enterprise*; **Enable Automatic Updates** = On. +2. Edge update policies (`UpdateDefault` = 1). +3. Where third-party browsers / PDF viewers are deployed, configure their auto-update. + +**Links** +- [Choose Microsoft 365 Apps update channel](https://learn.microsoft.com/en-us/deployoffice/updates/overview-update-channels) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 new file mode 100644 index 0000000000000..4c89da953fcc8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Office and supported applications use automatic updates + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft 365 Apps is set to a current channel (Current/Monthly Enterprise) with automatic updates, and that browsers (Edge, Chrome, Firefox) and PDF viewers self-update. Office update channel can be enforced via Office Cloud Policy *UpdateChannel*; Edge auto-update via *UpdateDefault*.' -Risk 'High' -Name 'Office and supported applications use automatic updates' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md new file mode 100644 index 0000000000000..4271b8f6435d1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md @@ -0,0 +1,12 @@ +A device that has not synced with Intune for two weeks is invisible to MDM-based patching and detection. Patch state cannot be enforced or measured for these endpoints. + +**Remediation Action** + +1. Contact users of the listed devices and ensure they bring them online and sign in. +2. If a device has been offline >30 days, retire/wipe it from Intune and re-enrol when returned. + +**Links** +- [Intune device sync troubleshooting](https://learn.microsoft.com/en-us/mem/intune/remote-actions/device-sync) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 new file mode 100644 index 0000000000000..7f810f28621fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_PatchApp_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Managed devices have synced with Intune within the last 14 days + #> + param($Tenant) + + $TestId = 'E8_PatchApp_02' + $Name = 'Managed devices have synced with Intune within the last 14 days' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + return + } + + $Threshold = (Get-Date).AddDays(-14) + $Stale = foreach ($D in $Devices) { + $LastSync = $D.lastSyncDateTime + if (-not $LastSync) { [pscustomobject]@{ Device = $D.deviceName; LastSync = 'never' }; continue } + $LastSyncDt = [datetime]::Parse($LastSync) + if ($LastSyncDt -lt $Threshold) { [pscustomobject]@{ Device = $D.deviceName; LastSync = $LastSyncDt.ToString('yyyy-MM-dd') } } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($Devices.Count) managed device(s) have synced with Intune within the last 14 days." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($Devices.Count) managed device(s) have not synced for >14 days; their patch state is unknown:`n`n| Device | Last sync |`n| :----- | :-------- |`n") + foreach ($S in ($Stale | Select-Object -First 50)) { $null = $Sb.Append("| $($S.Device) | $($S.LastSync) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md new file mode 100644 index 0000000000000..84d1c86b6c3da --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md @@ -0,0 +1,12 @@ +E8 ML2 requires patching of applications with critical vulnerabilities within 48 hours of an exploit becoming public. Use Microsoft Defender Vulnerability Management to identify exposed apps and prioritise. + +**Remediation Action** + +1. Defender > Vulnerability management > Weaknesses; sort by Exposed devices and Public exploit available. +2. Patch listed apps via Intune Win32 / MSIX or vendor auto-update. + +**Links** +- [Microsoft Defender Vulnerability Management](https://learn.microsoft.com/en-us/defender-vulnerability-management/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 new file mode 100644 index 0000000000000..b0c0bd90748d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML2) - Vulnerable applications detected on managed devices are reviewed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Use Microsoft Defender Vulnerability Management (or the Intune Discovered Apps inventory) to triage applications with known CVEs. Patch internet-facing apps within 48 hours of an exploit being known and within 2 weeks otherwise. Determining "critical" CVE status programmatically requires Defender Vulnerability Management licensing and is not surfaced in the local cache.' -Risk 'High' -Name 'Vulnerable applications are patched within 48 hours of an exploit becoming public' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md new file mode 100644 index 0000000000000..483fef3401021 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md @@ -0,0 +1,12 @@ +ISM-1467 — applications that are no longer supported by the vendor must be removed because they will not receive security fixes. Examples: Office 2016 (out of support October 2025), Java 8 unsupported builds, Adobe Reader 11. + +**Remediation Action** + +1. Inventory installed apps via Intune > Apps > Discovered apps or Defender Vulnerability Management software inventory. +2. Schedule replacement / uninstall for unsupported versions. + +**Links** +- [Microsoft product lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 new file mode 100644 index 0000000000000..7ba4fc795a099 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML3) - Unsupported applications are removed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Identify and remove applications that are no longer supported by the vendor (e.g. Office 2016/2019 past support, Adobe Reader 11, Java 8 unpatched, Flash). Use the Intune Discovered Apps inventory or Defender Vulnerability Management software inventory to enumerate.' -Risk 'High' -Name 'Unsupported applications are removed (ISM-1467)' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md new file mode 100644 index 0000000000000..9fc6820e60f93 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md @@ -0,0 +1,13 @@ +Windows Update for Business is the cloud-native way to deliver OS quality and feature updates. At least one Update Ring policy must be deployed and assigned for E8 patch-OS controls to be measurable. + +**Remediation Action** + +1. Intune > Devices > Windows > Update rings for Windows 10 and later > Create. +2. Set service channel, deferral periods, and deadlines (see ML2 quality deferral test). +3. Assign to all Windows devices. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 new file mode 100644 index 0000000000000..58b895b81594c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestE8_PatchOS_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A Windows Update Ring policy is configured and assigned + #> + param($Tenant) + + $TestId = 'E8_PatchOS_01' + $Name = 'A Windows Update Ring policy is configured and assigned' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + $LegacyPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + + $UpdateRings = @() + if ($ConfigPolicies) { + $UpdateRings += $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'windowsupdate') -or ($ids -match 'update_ring') + } + } + if ($LegacyPolicies) { + $UpdateRings += $LegacyPolicies | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' + } + } + + if (-not $UpdateRings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows Update for Business / Update Ring configuration policy is deployed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Assigned = $UpdateRings | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows Update Ring policy/policies are configured and assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "$($UpdateRings.Count) Windows Update Ring policy/policies exist but none are assigned to any group/device." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md new file mode 100644 index 0000000000000..961afb55c4b12 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md @@ -0,0 +1,13 @@ +Only Windows 10 22H2 (build 19045) and Windows 11 (22H2 / 23H2 / 24H2 — builds 22621/22631/26100) currently receive monthly security updates. Older builds must be upgraded. + +**Remediation Action** + +1. Use Intune Feature Update profiles to roll devices to a supported feature release. +2. For devices that cannot upgrade, retire and replace. + +**Links** +- [Windows 10 release information](https://learn.microsoft.com/en-us/windows/release-health/release-information) +- [Windows 11 release information](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 new file mode 100644 index 0000000000000..e3caead2bd8ef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 @@ -0,0 +1,52 @@ +function Invoke-CippTestE8_PatchOS_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - All managed Windows devices run a supported OS build + #> + param($Tenant) + + $TestId = 'E8_PatchOS_02' + $Name = 'All managed Windows devices run a supported Windows build (Win10 22H2 / Win11 22H2+)' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Windows = $Devices | Where-Object { $_.operatingSystem -eq 'Windows' } + if (-not $Windows) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Windows managed devices found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + # Win10 22H2 = 19045.x ; Win11 22H2 = 22621.x ; Win11 23H2 = 22631.x ; Win11 24H2 = 26100.x + $Unsupported = foreach ($D in $Windows) { + $V = $D.osVersion + if (-not $V) { continue } + $parts = $V.Split('.') + if ($parts.Count -lt 3) { continue } + $build = [int]$parts[2] + $Reason = $null + if ($build -lt 19045) { $Reason = 'Windows 10 build < 22H2 (out of support)' } + elseif ($build -ge 20000 -and $build -lt 22621) { $Reason = 'Windows 11 build < 22H2 (out of support)' } + if ($Reason) { [pscustomobject]@{ Device = $D.deviceName; OSVersion = $V; Reason = $Reason } } + } + + if (-not $Unsupported) { + $Status = 'Passed' + $Result = "All $($Windows.Count) Windows device(s) run a supported build." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unsupported.Count) of $($Windows.Count) Windows device(s) are on unsupported builds:`n`n| Device | OS version | Reason |`n| :----- | :--------- | :----- |`n") + foreach ($U in ($Unsupported | Select-Object -First 50)) { $null = $Sb.Append("| $($U.Device) | $($U.OSVersion) | $($U.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md new file mode 100644 index 0000000000000..e62d844e3c748 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md @@ -0,0 +1,13 @@ +A device compliance policy that sets `osMinimumVersion` causes Conditional Access to block sign-ins from devices on out-of-support builds, providing a strong forcing function for OS patching. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows 10 / 11 policy. +2. Set **Minimum OS version** to the latest supported build (e.g. `10.0.19045.0` for Windows 10 22H2). +3. Assign to all Windows devices and pair with a CA policy requiring compliant device. + +**Links** +- [Windows compliance settings](https://learn.microsoft.com/en-us/mem/intune/protect/compliance-policy-create-windows) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 new file mode 100644 index 0000000000000..790ccf31f1e88 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_PatchOS_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A device compliance policy enforces a minimum OS version + #> + param($Tenant) + + $TestId = 'E8_PatchOS_03' + $Name = 'A device compliance policy enforces a minimum Windows OS version' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithMinVersion = $Win | Where-Object { $_.osMinimumVersion } + $Assigned = $WithMinVersion | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) Windows compliance policy/policies enforce a minimum OS version (e.g. $($Assigned[0].osMinimumVersion))." + } elseif ($WithMinVersion) { + $Status = 'Failed' + $Result = "$($WithMinVersion.Count) Windows compliance policy/policies set a minimum OS version but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No Windows compliance policy enforces a minimum OS version (`osMinimumVersion`).' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md new file mode 100644 index 0000000000000..5a1c94be5763f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md @@ -0,0 +1,13 @@ +E8 ML2 requires patches for the operating system within 2 weeks of release (and 48 hours for known exploited vulnerabilities). Update Rings must therefore not defer quality updates beyond 14 days. + +**Remediation Action** + +1. Intune > Devices > Update rings > each ring > Settings. +2. **Quality update deferral period (days)** = `0` for production, up to `7` for pilot. +3. Configure a deadline of 2 days for installation/restart. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 new file mode 100644 index 0000000000000..848d61102f5a1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_PatchOS_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - Quality update deferral is 14 days or less + #> + param($Tenant) + + $TestId = 'E8_PatchOS_04' + $Name = 'Windows Update Ring quality update deferral is 14 days or less' + + try { + $Legacy = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + $Rings = $Legacy | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' } + + if (-not $Rings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No `windowsUpdateForBusinessConfiguration` Update Ring policies cached for this tenant; quality deferral cannot be evaluated automatically.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + return + } + + $Bad = foreach ($R in $Rings) { + $Defer = $R.qualityUpdatesDeferralPeriodInDays + if ($null -ne $Defer -and $Defer -gt 14) { + [pscustomobject]@{ Ring = $R.displayName; Deferral = $Defer } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Rings.Count) Update Ring policy/policies defer quality updates by 14 days or less." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) Update Ring policy/policies defer quality updates by more than 14 days:`n`n| Ring | Deferral (days) |`n| :--- | :-------------: |`n") + foreach ($B in $Bad) { $null = $Sb.Append("| $($B.Ring) | $($B.Deferral) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md new file mode 100644 index 0000000000000..a39c3d8fdf26a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md @@ -0,0 +1,13 @@ +Feature Update profiles target a specific feature release (e.g. Windows 11 23H2) so devices stay on a supported branch automatically. + +**Remediation Action** + +1. Intune > Devices > Windows > Feature updates for Windows 10 and later > Create profile. +2. Choose the latest supported feature update version. +3. Assign to all Windows devices. + +**Links** +- [Feature updates in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-feature-updates) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 new file mode 100644 index 0000000000000..60440e405aa86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchOS_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - A Windows Feature Update profile is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchOS_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a Windows Feature Update profile (Intune > Devices > Windows > Feature updates for Windows 10 and later) is deployed targeting the latest supported feature release. Feature update profiles are stored in a Graph endpoint that is not currently part of the cached IntuneConfigurationPolicies/DeviceConfigurations collections.' -Risk 'Medium' -Name 'A Windows Feature Update profile is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md new file mode 100644 index 0000000000000..030d15321e7a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md @@ -0,0 +1,13 @@ +Disk encryption protects data on lost or stolen devices. The compliance policy must require BitLocker (or `storageRequireEncryption`) so devices that aren't encrypted are blocked by Conditional Access. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows policy > **Require BitLocker** = Require. +2. Pair with a *BitLocker* configuration profile (Endpoint security > Disk encryption) to actually enable it. +3. Assign to all Windows devices. + +**Links** +- [BitLocker policy settings](https://learn.microsoft.com/en-us/mem/intune/protect/encrypt-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 new file mode 100644 index 0000000000000..1681bcd1224d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestE8_PatchOS_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML3) - BitLocker / disk encryption is required by compliance policy + #> + param($Tenant) + + $TestId = 'E8_PatchOS_06' + $Name = 'Compliance policy requires storage to be encrypted (BitLocker)' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithEnc = $Win | Where-Object { $_.bitLockerEnabled -eq $true -or $_.storageRequireEncryption -eq $true } + $Assigned = $WithEnc | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows compliance policy/policies require encryption (BitLocker) and are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } elseif ($WithEnc) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Encryption is required by $($WithEnc.Count) compliance policy/policies but none are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows compliance policy requires storage encryption / BitLocker.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md new file mode 100644 index 0000000000000..7c8e9fb156d0d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md @@ -0,0 +1,14 @@ +ISM-0445 — privileged users are issued dedicated privileged accounts that are separate from their unprivileged identities. In a Microsoft 365 tenant the strongest implementation is **cloud-only** privileged accounts so the on-premises directory cannot compromise privileged identities. This test fails any privileged role member whose `onPremisesSyncEnabled` is true. + +**Remediation Action** + +1. Create a dedicated cloud-only account on the `.onmicrosoft.com` domain for each administrator. +2. Reassign privileged roles to the cloud-only account. +3. Remove privileged role assignments from synced accounts. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-0445](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 new file mode 100644 index 0000000000000..43ebe72c74335 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 @@ -0,0 +1,59 @@ +function Invoke-CippTestE8_Admin_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are dedicated cloud-only accounts (ISM-0445) + #> + param($Tenant) + + $TestId = 'E8_Admin_01' + $Name = 'Privileged accounts are dedicated cloud-only accounts (ISM-0445)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $NonCompliant = $PrivUsers | Where-Object { $_.onPremisesSyncEnabled -eq $true } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) are cloud-only." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUsers.Count) privileged user(s) are synced from on-premises Active Directory:`n`n| UPN |`n| :-- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { $null = $Sb.Append("| $($U.userPrincipalName) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md new file mode 100644 index 0000000000000..0964cc5c24c56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md @@ -0,0 +1,14 @@ +ISM-1175 — privileged accounts must be prevented from accessing the internet, email and web services. In a Microsoft 365 tenant the cleanest implementation is to leave privileged accounts unlicensed (no Exchange / Teams / SharePoint mailbox or apps) so that the account cannot send or receive mail, browse SharePoint, or run Office. Entra ID P2 is provisioned via group-based licensing if needed for PIM, but no productivity SKU should be attached. + +**Remediation Action** + +1. Identify each privileged user listed in the test results. +2. Remove all `Microsoft 365 / Office 365` and `Business Premium` style licenses. +3. Where an admin needs email, use a separate unprivileged mailbox. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1175](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 new file mode 100644 index 0000000000000..4be920fd7608d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 @@ -0,0 +1,61 @@ +function Invoke-CippTestE8_Admin_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts have no productivity licenses (ISM-1175) + #> + param($Tenant) + + $TestId = 'E8_Admin_02' + $Name = 'Privileged accounts have no productivity licenses (ISM-1175)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Licensed = $PrivUsers | Where-Object { $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 } + + if (-not $Licensed) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) have no licenses assigned." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Licensed.Count) of $($PrivUsers.Count) privileged user(s) have productivity licenses assigned. ACSC ISM-1175 requires admins not to use mail/Teams/internet on the privileged account.`n`n| UPN | License count |`n| :-- | :-----------: |`n") + foreach ($U in ($Licensed | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.assignedLicenses.Count) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md new file mode 100644 index 0000000000000..62eee976a6839 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md @@ -0,0 +1,16 @@ +Privileged sign-ins must come from devices the organisation manages. ISM-1380, 1688 and 1689 require admins to operate from a privileged operating environment; in a cloud-first tenant the equivalent is a Conditional Access policy that requires the device to be Intune-compliant or hybrid Azure AD joined before privileged roles are activated. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users > Directory roles: include all privileged roles. +3. Cloud apps: All cloud apps. +4. Grant: **Require compliant device** (and/or *Require Hybrid Azure AD joined device*). +5. Enable. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [Require managed devices in CA](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/require-managed-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 new file mode 100644 index 0000000000000..9de335c058c5b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Conditional Access requires a compliant device for privileged sign-ins + #> + param($Tenant) + + $TestId = 'E8_Admin_03' + $Name = 'Conditional Access requires a compliant or hybrid-joined device for privileged role sign-ins' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + ( + ($_.grantControls.builtInControls -contains 'compliantDevice') -or + ($_.grantControls.builtInControls -contains 'domainJoinedDevice') + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require a compliant/domain-joined device for privileged role sign-ins:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with a *Require compliant device* or *Require hybrid Azure AD joined device* grant. Privileged accounts may sign in from unmanaged endpoints.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md new file mode 100644 index 0000000000000..1808623cdfa8e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md @@ -0,0 +1,14 @@ +Restricting user consent for OAuth applications stops attackers from luring users (including admins) into authorising malicious third-party apps to read mail or files. The Essential Eight ISM-1883 control limits which online services privileged accounts can authorise. + +**Remediation Action** + +1. Entra ID > Enterprise applications > Consent and permissions > User consent settings. +2. Set to **Allow user consent for apps from verified publishers, for selected permissions** (low-impact) — or **Do not allow user consent**. +3. Entra ID > User settings > **Users can register applications** = No. + +**Links** +- [Configure user consent settings](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 new file mode 100644 index 0000000000000..492cb3a3da390 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are blocked from authorising risky OAuth grants (ISM-1883) + #> + param($Tenant) + + $TestId = 'E8_Admin_04' + $Name = 'User consent for risky OAuth applications is restricted (ISM-1883)' + + try { + $AuthPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + if (-not $AuthPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Cfg = $AuthPolicy | Select-Object -First 1 + $Permissions = $Cfg.defaultUserRolePermissions + # The assigned consent policies live at the top level of the authorization policy, not under defaultUserRolePermissions. + $UserConsent = $Cfg.permissionGrantPolicyIdsAssignedToDefaultUserRole + + $Issues = [System.Collections.Generic.List[string]]::new() + if ($Permissions.allowedToCreateApps -eq $true) { + $Issues.Add('defaultUserRolePermissions.allowedToCreateApps is true — non-admin users can register new applications.') + } + if ($Cfg.allowUserConsentForRiskyApps -eq $true) { + $Issues.Add('allowUserConsentForRiskyApps is true — users can consent to applications Microsoft flags as risky.') + } + if ($UserConsent -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy') { + $Issues.Add('Legacy user consent policy in effect (`ManagePermissionGrantsForSelf.microsoft-user-default-legacy`). Switch to `microsoft-user-default-low` or admin-only.') + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'User consent for OAuth apps is restricted to low-impact (or admin-only).' + } else { + $Status = 'Failed' + $Result = "Risky OAuth consent configuration:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md new file mode 100644 index 0000000000000..692fbc06241a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md @@ -0,0 +1,14 @@ +ISM-1648 — privileged access disabled after 45 days of inactivity. Stale privileged accounts are a common foothold for attackers; if no one is signing in, the account either should not exist or should be disabled. The control window is signal-of-life via Entra ID `signInActivity.lastSignInDateTime`. + +**Remediation Action** + +1. Review each stale privileged account listed. +2. Disable, delete, or remove privileged role assignments where appropriate. +3. Where the account is genuinely needed for monthly tasks, document the exception and consider PIM eligible assignment instead of permanent. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1648](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 new file mode 100644 index 0000000000000..7f7873cbbe148 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 @@ -0,0 +1,69 @@ +function Invoke-CippTestE8_Admin_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - No privileged account is inactive for more than 45 days (ISM-1648) + #> + param($Tenant) + + $TestId = 'E8_Admin_05' + $Name = 'No privileged account inactive for more than 45 days (ISM-1648)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) -and $_.accountEnabled -eq $true } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $Threshold = (Get-Date).AddDays(-45) + $Stale = foreach ($U in $PrivUsers) { + $Last = $U.signInActivity.lastSignInDateTime + if (-not $Last) { continue } + $LastDt = [datetime]::Parse($Last) + if ($LastDt -lt $Threshold) { + [pscustomobject]@{ UPN = $U.userPrincipalName; LastSignIn = $LastDt.ToString('yyyy-MM-dd') } + } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) enabled privileged user(s) signed in within the last 45 days (or have no recorded sign-in)." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($PrivUsers.Count) enabled privileged user(s) have not signed in for more than 45 days:`n`n| UPN | Last sign-in |`n| :-- | :----------- |`n") + foreach ($S in ($Stale | Sort-Object LastSignIn | Select-Object -First 50)) { + $null = $Sb.Append("| $($S.UPN) | $($S.LastSignIn) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md new file mode 100644 index 0000000000000..804215e13c39a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md @@ -0,0 +1,14 @@ +ISM-1509 — privileged access events are centrally logged. The Entra ID Sign-in log and Audit log must be forwarded to a SIEM and retained for 12 months. This is configured under *Entra ID > Diagnostic settings* and depends on a target Log Analytics workspace / event hub / storage account that lives outside the tenant CIPP can read; verify manually. + +**Remediation Action** + +1. Entra ID > Diagnostic settings > Add diagnostic setting. +2. Send `SignInLogs`, `AuditLogs`, `RiskyUsers`, `UserRiskEvents`, `ServicePrincipalSignInLogs` to a Log Analytics workspace / Sentinel / Event Hub. +3. Confirm retention ≥ 12 months on the destination. + +**Links** +- [Entra ID diagnostic settings](https://learn.microsoft.com/en-us/azure/active-directory/reports-monitoring/howto-integrate-activity-logs-with-azure-monitor-logs) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 new file mode 100644 index 0000000000000..4402d31eb7bd3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Privileged access events are forwarded to a SIEM (ISM-1509) + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_06' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Entra ID Sign-in and Audit logs (and Microsoft 365 Unified Audit Log) are forwarded to a SIEM (Sentinel, Splunk, etc.) and retained for at least 12 months. CIPP cannot verify diagnostic settings or external SIEM connectivity from the partner tenant.' -Risk 'Medium' -Name 'Privileged access events are centrally logged (ISM-1509)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md new file mode 100644 index 0000000000000..50f9723f57f05 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md @@ -0,0 +1,14 @@ +Microsoft and ACSC both recommend 2-4 dedicated cloud-only Global Administrator break-glass accounts on the `*.onmicrosoft.com` domain that are excluded from MFA-enforcing Conditional Access policies. Their purpose is recovery during a Conditional Access misconfiguration or MFA service outage. + +**Remediation Action** + +1. Maintain 2 (recommended) or up to 4 cloud-only `*.onmicrosoft.com` GA accounts. +2. Exclude them from MFA-required Conditional Access policies. +3. Protect them with FIDO2 / hardware tokens, store credentials in a sealed envelope, monitor sign-ins via Sentinel. + +**Links** +- [Manage emergency access accounts](https://learn.microsoft.com/en-us/azure/active-directory/roles/security-emergency-access) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 new file mode 100644 index 0000000000000..5d9a8b2267b2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 @@ -0,0 +1,75 @@ +function Invoke-CippTestE8_Admin_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Break-glass accounts exist and are excluded from MFA enforcement + #> + param($Tenant) + + $TestId = 'E8_Admin_07' + $Name = 'Break-glass accounts (2-4) exist and are excluded from at least one MFA Conditional Access policy' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $Roles -or -not $Users -or -not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, Users or ConditionalAccessPolicies) not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not found in the Roles cache.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id) { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaUsers = $Users | Where-Object { $GaUserIds.Contains($_.id) } + $BreakGlass = $GaUsers | Where-Object { $_.userPrincipalName -like '*onmicrosoft.com' -and $_.accountEnabled -eq $true } + + if (-not $BreakGlass -or $BreakGlass.Count -lt 2) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Only $($BreakGlass.Count) Global Administrator(s) on the *.onmicrosoft.com domain. ACSC guidance recommends 2-4 dedicated cloud-only break-glass accounts." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + if ($BreakGlass.Count -gt 4) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "$($BreakGlass.Count) cloud-only Global Administrators exist. Excessive break-glass accounts increase risk; reduce to 2-4." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $BreakGlassIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$BreakGlass.id) + $MfaPolicies = $CA | Where-Object { + $_.state -eq 'enabled' -and + (($_.grantControls.builtInControls -contains 'mfa') -or $_.grantControls.authenticationStrength) + } + $WithExclusion = foreach ($P in $MfaPolicies) { + $Excluded = $P.conditions.users.excludeUsers + if ($Excluded -and (@($Excluded) | Where-Object { $BreakGlassIds.Contains($_) }).Count -gt 0) { $P } + } + + if ($WithExclusion) { + $Status = 'Passed' + $Result = "$($BreakGlass.Count) break-glass account(s) found. They are excluded from $((@($WithExclusion)).Count) MFA-enforcing Conditional Access policy/policies." + } else { + $Status = 'Failed' + $Result = "$($BreakGlass.Count) break-glass account(s) exist but no MFA-enforcing Conditional Access policy excludes them. A token-service MFA outage will lock the tenant." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md new file mode 100644 index 0000000000000..f83c1b86a154b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md @@ -0,0 +1,14 @@ +ISM-1508 — privileged accounts are time-bound and reauthenticated for each session. Microsoft's equivalent is Privileged Identity Management (PIM): admins are *eligible* for a role and must activate it for a limited time. This test fails when highly-privileged roles have permanent (active) assignments rather than eligible-only. + +**Remediation Action** + +1. Entra ID > Privileged Identity Management > Microsoft Entra roles. +2. For each role listed, convert all members from *Active* to *Eligible*. +3. Set max activation duration ≤ 8 hours; require justification + MFA on activation. + +**Links** +- [PIM in Microsoft Entra](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 new file mode 100644 index 0000000000000..5c7a0fee1911f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 @@ -0,0 +1,70 @@ +function Invoke-CippTestE8_Admin_08 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - Just-in-Time / PIM is used for highly privileged roles (ISM-1508) + #> + param($Tenant) + + $TestId = 'E8_Admin_08' + $Name = 'Just-in-Time activation (PIM eligibility) is used for highly privileged roles' + + $HighlyPriv = @('Global Administrator','Privileged Role Administrator','Privileged Authentication Administrator','Conditional Access Administrator','Intune Administrator','Security Administrator') + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # Without PIM active-assignment data we cannot distinguish permanent assignments from + # PIM-eligible activation, so treating a missing cache as "compliant" would be a false pass. + if (-not $RoleAssignmentScheduleInstances) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleAssignmentScheduleInstances (PIM active assignments) cache not found — cannot verify whether highly-privileged roles use Just-in-Time activation.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $TargetRoles = $Roles | Where-Object { $_.displayName -in $HighlyPriv } + if (-not $TargetRoles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No highly-privileged roles found in cache.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not a directory role instance ID. + $TargetRoleTemplates = @{} + foreach ($R in $TargetRoles) { + $Tid = if ($R.roleTemplateId) { [string]$R.roleTemplateId } elseif ($R.RoletemplateId) { [string]$R.RoletemplateId } else { $null } + if ($Tid) { $TargetRoleTemplates[$Tid] = $R.displayName } + } + $PermanentByRole = @{} + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $TargetRoleTemplates.ContainsKey([string]$A.roleDefinitionId)) { + $key = [string]$A.roleDefinitionId + if (-not $PermanentByRole.ContainsKey($key)) { $PermanentByRole[$key] = [System.Collections.Generic.HashSet[string]]::new() } + [void]$PermanentByRole[$key].Add([string]$A.principalId) + } + } + + $RolesWithPermanent = foreach ($Tid in $TargetRoleTemplates.Keys) { + $count = if ($PermanentByRole.ContainsKey($Tid)) { $PermanentByRole[$Tid].Count } else { 0 } + if ($count -gt 0) { [pscustomobject]@{ Role = $TargetRoleTemplates[$Tid]; Permanent = $count } } + } + + if (-not $RolesWithPermanent) { + $Status = 'Passed' + $Result = "No permanent role assignments found for highly-privileged roles ($($TargetRoles.displayName -join ', ')). All access appears to be PIM-eligible." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("Permanent (non-PIM) assignments to highly-privileged roles:`n`n| Role | Permanent assignees |`n| :--- | :-----------------: |`n") + foreach ($R in $RolesWithPermanent) { $null = $Sb.Append("| $($R.Role) | $($R.Permanent) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md new file mode 100644 index 0000000000000..f9849564a60ea --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md @@ -0,0 +1,13 @@ +PIM activation should require MFA and a typed justification for every privileged role activation. This raises the bar against stolen tokens and provides an audit trail. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles. +2. For each role, open *Role settings* > *Edit*. +3. On Activation, tick **Azure MFA** and **Require justification on activation**. + +**Links** +- [Configure Microsoft Entra role settings in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 new file mode 100644 index 0000000000000..ad1add866ded9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_09 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires MFA and justification + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_09' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. In Entra ID > PIM > Microsoft Entra roles > Settings, confirm each highly-privileged role requires MFA on activation and a justification. The full PIM rule set is not exposed in cached `RoleManagementPolicies` (rules require `$expand=rules` per role).' -Risk 'High' -Name 'PIM activation requires MFA and justification' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md new file mode 100644 index 0000000000000..a8cc878a93236 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md @@ -0,0 +1,13 @@ +For tier-zero roles (Global Administrator, Privileged Role Administrator) activation should require approval from a second administrator. This adds a four-eyes control on the most damaging roles. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles > *Global Administrator* > Role settings > Edit. +2. On Activation, enable **Require approval to activate** and add at least one approver. +3. Repeat for *Privileged Role Administrator*. + +**Links** +- [Approve activation requests in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-approval-workflow) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 new file mode 100644 index 0000000000000..5ef104d433cc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_10 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires approval for highly privileged roles + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_10' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Global Administrator and Privileged Role Administrator activations require approval (PIM > Role settings > Activation > Require approval to activate). The full PIM rule set is not exposed in the cached `RoleManagementPolicies` collection.' -Risk 'High' -Name 'PIM activation requires approval for Global Administrator and Privileged Role Administrator' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md new file mode 100644 index 0000000000000..6f06c280ce97a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md @@ -0,0 +1,13 @@ +ISM-1647 — privileges are reviewed at least annually. PIM eligibility assignments must therefore expire within 12 months so the next assignment is a deliberate decision. Permanent eligibility defeats the purpose of access reviews. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Assignments > Eligible. +2. For each assignment without expiry, set an end date ≤ 12 months from now. +3. Tighten role settings so future assignments cannot exceed 12 months. + +**Links** +- [Assign Microsoft Entra roles in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 new file mode 100644 index 0000000000000..fa90401cba9ad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestE8_Admin_11 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM eligibility is reviewed at least every 12 months (ISM-1647) + #> + param($Tenant) + + $TestId = 'E8_Admin_11' + $Name = 'PIM role eligibility expires within 12 months (no permanent eligibility) (ISM-1647)' + + try { + $Schedules = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleEligibilitySchedules' + if (-not $Schedules) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleEligibilitySchedules cache not found (no PIM in use, or P2 not licensed).' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $Now = Get-Date + $MaxFuture = $Now.AddDays(366) + $Bad = foreach ($S in $Schedules) { + $Type = $S.scheduleInfo.expiration.type + $End = $S.scheduleInfo.expiration.endDateTime + if ($Type -eq 'noExpiration' -or -not $End) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = 'No expiration' } + } elseif ([datetime]::Parse($End) -gt $MaxFuture) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = "Expires $End (>12 months)" } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Schedules.Count) PIM eligibility schedule(s) expire within 12 months." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) of $($Schedules.Count) PIM eligibility schedule(s) do not expire within 12 months:`n`n| Principal | Role | Reason |`n| :-------- | :--- | :----- |`n") + foreach ($B in ($Bad | Select-Object -First 50)) { $null = $Sb.Append("| $($B.Principal) | $($B.RoleId) | $($B.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md new file mode 100644 index 0000000000000..3bfb761392c9b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md @@ -0,0 +1,13 @@ +Microsoft and ACSC recommend a minimum of 2 and a maximum of 4 Global Administrators. Two is the minimum to avoid total lockout; more than four needlessly increases attack surface. + +**Remediation Action** + +1. Identify excess Global Administrators in Entra ID > Roles and administrators > Global Administrator. +2. Replace with least-privileged roles (e.g. *Exchange Administrator*, *User Administrator*). +3. If fewer than 2 — add a second cloud-only break-glass GA. + +**Links** +- [Best practices for roles in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 new file mode 100644 index 0000000000000..a01b938a4e951 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestE8_Admin_12 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Global Administrator count is between 2 and 4 + #> + param($Tenant) + + $TestId = 'E8_Admin_12' + $Name = 'Global Administrator count is between 2 and 4' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not present in cache.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + # Only count user accounts as Global Administrators — service principals holding the role + # (e.g. the CIPP-SAM application) are not human admins and cannot be reduced by delegation. + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaCount = $GaUserIds.Count + + if ($GaCount -ge 2 -and $GaCount -le 4) { + $Status = 'Passed' + $Result = "$GaCount Global Administrator(s) — within recommended range of 2-4." + } elseif ($GaCount -lt 2) { + $Status = 'Failed' + $Result = "Only $GaCount Global Administrator(s). At least 2 are required so a single account loss does not lock the tenant." + } else { + $Status = 'Failed' + $Result = "$GaCount Global Administrators — exceeds the recommended maximum of 4. Reduce by delegating finer-grained roles." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md new file mode 100644 index 0000000000000..1f1dc92d11784 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md @@ -0,0 +1,13 @@ +High-privilege OAuth grants (e.g. application permissions on Microsoft Graph such as `Directory.ReadWrite.All`, `RoleManagement.ReadWrite.Directory`, `Application.ReadWrite.All`, `Mail.ReadWrite`) effectively grant Global-Admin-equivalent access via a service principal. Review these regularly. + +**Remediation Action** + +1. Entra ID > Enterprise applications > All applications. +2. For each app with admin-consented permissions, review *Permissions* and revoke unused. +3. Use Microsoft Defender for Cloud Apps / Microsoft 365 Defender to alert on new high-privilege consents. + +**Links** +- [Investigate risky OAuth apps](https://learn.microsoft.com/en-us/defender-cloud-apps/investigate-risky-oauth) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 new file mode 100644 index 0000000000000..d0a1d832cb1a0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_13 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - High-privilege OAuth grants are reviewed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_13' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Review enterprise applications and OAuth2 permission grants for high-privilege scopes (Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, Application.ReadWrite.All, Mail.ReadWrite, full_access_as_app). The OAuth2PermissionGrants and ServicePrincipals collections are not currently cached for analysis here; use the CIPP *Application Approvals* and *Enterprise Applications* views instead.' -Risk 'Medium' -Name 'High-privilege OAuth grants are reviewed' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md new file mode 100644 index 0000000000000..421c17398fdbc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md @@ -0,0 +1,13 @@ +Litigation hold and retention policies prevent permanent loss of mailbox content from accidental or malicious deletion. While not a true backup, they provide point-in-time recovery for E8 ML1. + +**Remediation Action** + +1. Apply a Microsoft 365 retention policy to user mailboxes covering Exchange, OneDrive, and SharePoint. +2. Or enable per-mailbox litigation hold with a duration of at least the organisation's retention requirement. + +**Links** +- [Litigation hold](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold) +- [Microsoft 365 retention policies](https://learn.microsoft.com/en-us/purview/retention) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 new file mode 100644 index 0000000000000..081976351991d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_Backup_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - User mailboxes have litigation hold or retention applied + #> + param($Tenant) + + $TestId = 'E8_Backup_01' + $Name = 'User mailboxes have litigation hold or a retention policy applied' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No Mailboxes cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # Inactive (soft-deleted) mailboxes carry WhenSoftDeleted; there is no IsInactiveMailbox field in the cache. + $UserMailboxes = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'UserMailbox' -and -not $_.WhenSoftDeleted } + if (-not $UserMailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No user mailboxes found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # The built-in "Default MRM Policy" is present on every mailbox and only manages archive/deletion tags — + # it is not a data-protection retention control, so it does not count as protected. + $Unprotected = $UserMailboxes | Where-Object { + -not ($_.LitigationHoldEnabled -eq $true) -and + -not ($_.ComplianceTagHoldApplied -eq $true) -and + ([string]::IsNullOrWhiteSpace($_.RetentionPolicy) -or $_.RetentionPolicy -eq 'Default MRM Policy') -and + -not $_.InPlaceHolds + } + + if (-not $Unprotected) { + $Status = 'Passed' + $Result = "All $($UserMailboxes.Count) user mailbox(es) have at least one of: litigation hold, a non-default retention policy, or compliance hold applied." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unprotected.Count) of $($UserMailboxes.Count) user mailbox(es) have no litigation hold, retention policy, or compliance tag applied:`n`n| UPN |`n| :-- |`n") + foreach ($M in ($Unprotected | Select-Object -First 50)) { $null = $Sb.Append("| $($M.UPN) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md new file mode 100644 index 0000000000000..885790e1ad9e6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md @@ -0,0 +1,13 @@ +SharePoint version history and the recycle bin together provide point-in-time recovery for documents. Confirm versioning is on (default 500 versions for new libraries) and the recycle bin retention is 93 days. + +**Remediation Action** + +1. SharePoint admin centre > Sites > Active sites. +2. For critical sites, confirm library versioning is enabled and the version count is acceptable. +3. Recycle bin retention is fixed at 93 days for SharePoint and OneDrive. + +**Links** +- [SharePoint versioning](https://learn.microsoft.com/en-us/sharepoint/enable-disable-versioning) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 new file mode 100644 index 0000000000000..b22052da74da8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - SharePoint Online retains versions and recycle bin items + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_02' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm SharePoint Online sites have versioning enabled (default minimum 100 versions) and the second-stage recycle bin retention is at least 93 days. Site-level versioning is configured per library and is not exposed centrally; review via SharePoint admin centre or PnP PowerShell.' -Risk 'Medium' -Name 'SharePoint Online versioning and recycle bin retention is configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md new file mode 100644 index 0000000000000..c1cd843c1a17d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md @@ -0,0 +1,12 @@ +Known Folder Move redirects users' Desktop, Documents, and Pictures to OneDrive so file changes are continuously synced and version-protected. + +**Remediation Action** + +1. Intune > Settings catalog > **OneDrive** > *Silently move Windows known folders to OneDrive* = Enabled with the tenant ID. +2. Optionally enable *Use OneDrive Files On-Demand* and *Prevent users from redirecting Windows known folders to their PC*. + +**Links** +- [Redirect known folders to OneDrive](https://learn.microsoft.com/en-us/sharepoint/redirect-known-folders) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 new file mode 100644 index 0000000000000..f51b96dd5987d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - OneDrive Known Folder Move (KFM) is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_03' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm OneDrive Known Folder Move is configured to redirect Desktop, Documents, and Pictures to OneDrive on all Windows endpoints. Configure via Intune Settings catalog: *OneDrive > Silently move Windows known folders to OneDrive*.' -Risk 'Medium' -Name 'OneDrive Known Folder Move (KFM) is enforced' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md new file mode 100644 index 0000000000000..97d502bf95dc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md @@ -0,0 +1,14 @@ +ISM-1547 — backups must be performed regularly, retained for an organisation-determined period, stored separately from the source data, and **tested**. Microsoft retention is not a backup. Use Microsoft 365 Backup or a third-party solution (Veeam, Datto, Druva, AvePoint, etc.) and run a documented restore test at least quarterly. + +**Remediation Action** + +1. Provision a Microsoft 365 backup solution covering Exchange, OneDrive, SharePoint, and Teams. +2. Schedule a quarterly test restore and record the results in your backup runbook. +3. Verify the backup admin account is not synced from on-premises and uses an isolated identity. + +**Links** +- [Microsoft 365 Backup](https://learn.microsoft.com/en-us/microsoft-365/backup/) +- [ACSC Essential Eight - Regular Backups](https://learn.microsoft.com/en-us/compliance/anz/e8-backup) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 new file mode 100644 index 0000000000000..5052f18712213 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML2) - A tested backup and restore process exists for Microsoft 365 data + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_04' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a third-party (or Microsoft 365 Backup) solution is in place for mailboxes, OneDrive, SharePoint, and Teams data, and that restore tests are performed at least quarterly with documented results. Microsoft retention is **not** a backup — it does not protect against admin deletion or compliance policy changes.' -Risk 'High' -Name 'Microsoft 365 data is backed up by a tested process (ISM-1547)' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'E8 ML2 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md new file mode 100644 index 0000000000000..977487368e95c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md @@ -0,0 +1,14 @@ +All staff (and any service accounts that interact with the tenant) must be registered for multifactor authentication. The Essential Eight requires MFA at Maturity Level 1 across all users so a stolen password cannot, on its own, grant access to corporate data. + +**Remediation Action** + +1. Identify users without MFA registration (this test lists them). +2. Enable a Conditional Access policy or Security Defaults to force registration on next sign-in. +3. Validate via Entra ID > Authentication methods > User registration details that `isMfaCapable = true`. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan an Authentication methods deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-methods-activity) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 new file mode 100644 index 0000000000000..ad4c52391273d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_MFA_01 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - All member users are MFA capable + #> + param($Tenant) + + $TestId = 'E8_MFA_01' + $Name = 'All member users are registered for MFA' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $RegByUpn = @{} + foreach ($R in ($Reg | Where-Object { $_.userPrincipalName })) { + $RegByUpn[$R.userPrincipalName.ToLower()] = $R + } + + $MemberUsers = $Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' } + $NotMfaCapable = foreach ($U in $MemberUsers) { + $R = $RegByUpn[[string]$U.userPrincipalName.ToLower()] + if (-not $R -or $R.isMfaCapable -ne $true) { $U } + } + + if (-not $NotMfaCapable) { + $Status = 'Passed' + $Result = "All $($MemberUsers.Count) enabled member users are MFA capable." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NotMfaCapable.Count) of $($MemberUsers.Count) enabled member users are not MFA capable:`n`n") + $null = $Sb.Append("| UPN | Display Name |`n| :-- | :----------- |`n") + foreach ($U in ($NotMfaCapable | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.displayName) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md new file mode 100644 index 0000000000000..2cee23be4ddbf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md @@ -0,0 +1,16 @@ +A tenant-wide Conditional Access policy that requires MFA on every sign-in to every cloud app is the baseline control for Essential Eight ML1. Without it, MFA remains optional and attackers can bypass it via legacy clients or unprotected applications. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: All users (exclude break-glass). +3. Cloud apps: All cloud apps. +4. Grant: Require multifactor authentication (or an Authentication Strength). +5. Enable policy. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Common CA policy: Require MFA for all users](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 new file mode 100644 index 0000000000000..9d30202b53d56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_MFA_02 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - A Conditional Access policy enforces MFA for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_02' + $Name = 'A Conditional Access policy enforces MFA for all users on all cloud apps' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + ( + ($_.grantControls.builtInControls -contains 'mfa') -or + $_.grantControls.authenticationStrength + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce MFA on all users for all cloud apps:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets All Users + All Cloud Apps with an MFA grant control.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md new file mode 100644 index 0000000000000..37065b793d250 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md @@ -0,0 +1,17 @@ +Legacy authentication protocols (POP, IMAP, SMTP AUTH, older Outlook clients, ActiveSync basic auth) cannot enforce MFA. If they are reachable, an attacker with stolen credentials defeats Essential Eight MFA controls. + +**Remediation Action** + +1. Entra ID > Conditional Access > Create policy. +2. Users: All users (exclude break-glass + service accounts that genuinely need legacy auth). +3. Cloud apps: All cloud apps. +4. Conditions > Client apps: tick *Exchange ActiveSync clients* and *Other clients*. +5. Grant: Block access. +6. Enable. + +**Links** +- [Block legacy authentication](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-block-legacy) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 new file mode 100644 index 0000000000000..217c9b3799f35 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestE8_MFA_03 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - Legacy authentication is blocked + #> + param($Tenant) + + $TestId = 'E8_MFA_03' + $Name = 'Legacy authentication is blocked tenant-wide' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.clientAppTypes -contains 'exchangeActiveSync' -or $_.conditions.clientAppTypes -contains 'other') -and + ($_.grantControls.builtInControls -contains 'block') + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies block legacy auth:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy blocks legacy authentication clients (`exchangeActiveSync`/`other`). MFA can be bypassed via legacy protocols if not blocked.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md new file mode 100644 index 0000000000000..268ec4164e343 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 2 requires phishing-resistant MFA for privileged users. Before users can be required to use it, the tenant must have at least one phishing-resistant method (FIDO2 security keys, Windows Hello for Business, or certificate-based authentication) enabled in the Authentication methods policy. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Enable at least one of: FIDO2 security key, Windows Hello for Business, Certificate-based authentication. +3. Target the appropriate user/group scope and save. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Authentication methods policy](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-methods-manage) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 new file mode 100644 index 0000000000000..f1e6e15ad81c8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestE8_MFA_04 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - At least one phishing-resistant authentication method is enabled + #> + param($Tenant) + + $TestId = 'E8_MFA_04' + $Name = 'A phishing-resistant authentication method is enabled in the tenant' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + return + } + + # Windows Hello for Business is provisioned via Intune / device registration and is not part of + # authenticationMethodConfigurations, so it cannot be evaluated from this policy. + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Enabled = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Fido2','X509Certificate') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Enabled.Add($Id) } + } + + if ($Enabled.Count -gt 0) { + $Status = 'Passed' + $Result = "Phishing-resistant authentication method(s) enabled: $($Enabled -join ', ')." + } else { + $Status = 'Failed' + $Result = 'No phishing-resistant authentication method (FIDO2 security key or X509 certificate-based auth) is enabled in the tenant. Windows Hello for Business is managed via Intune and is not evaluated by this test.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md new file mode 100644 index 0000000000000..eee46bb0e1586 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md @@ -0,0 +1,14 @@ +SMS, Voice and Email OTP are not phishing-resistant: an attacker who can intercept SMS or proxy a sign-in page can capture the code. ACSC Essential Eight ML2/ML3 requires phasing these out in favour of FIDO2, Windows Hello, or certificate-based authentication. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Set *SMS*, *Voice call*, and *Email OTP* to Disabled (or restrict to a tightly-scoped group during transition). +3. Make sure users have a phishing-resistant or Authenticator App method registered first. + +**Links** +- [Phishing-resistant MFA in Entra ID](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 new file mode 100644 index 0000000000000..4e3cfe13e30fb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_MFA_05 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Weak authentication methods (SMS / Voice / Email OTP) are disabled + #> + param($Tenant) + + $TestId = 'E8_MFA_05' + $Name = 'Weak MFA methods (SMS, Voice call, Email OTP) are disabled' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + return + } + + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Issues = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Sms','Voice','Email') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Issues.Add($Id) } + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'SMS, Voice and Email OTP methods are all disabled.' + } else { + $Status = 'Failed' + $Result = "The following weak (non phishing-resistant) MFA methods are still enabled: $($Issues -join ', ')." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md new file mode 100644 index 0000000000000..59d59d0279f02 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md @@ -0,0 +1,16 @@ +Privileged identities are the highest-value targets in a tenant. ACSC Essential Eight Maturity Level 2 mandates phishing-resistant MFA for them. In Entra ID this is enforced by a Conditional Access policy that targets directory roles with the built-in *Phishing-resistant MFA* authentication strength. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: include Directory roles (all privileged roles such as Global Administrator, Privileged Role Administrator, Security Administrator, etc.). +3. Cloud apps: All cloud apps. +4. Grant > Require authentication strength > **Phishing-resistant MFA**. +5. Enable. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 new file mode 100644 index 0000000000000..6998fc66f35e9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_MFA_06 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Phishing-resistant MFA strength is required for privileged roles + #> + param($Tenant) + + $TestId = 'E8_MFA_06' + $Name = 'Phishing-resistant authentication strength is required for privileged roles' + # Built-in Phishing-resistant MFA strength + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require phishing-resistant MFA for privileged roles:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with the built-in *Phishing-resistant MFA* authentication strength.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md new file mode 100644 index 0000000000000..3bd85f92ec72d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md @@ -0,0 +1,14 @@ +A Conditional Access policy can require phishing-resistant MFA, but it only takes effect once each privileged user has actually registered such a method (FIDO2 key, Windows Hello, certificate, or device-bound passkey). This test enumerates privileged role members whose `methodsRegistered` list does not yet include a phishing-resistant method. + +**Remediation Action** + +1. Issue FIDO2 keys (or enable Windows Hello / certificate auth / passkeys) to each privileged user. +2. Have them register the method at https://aka.ms/mysecurityinfo before the Conditional Access policy is enforced. +3. Re-run this test once registrations complete. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [User registration details API](https://learn.microsoft.com/en-us/graph/api/resources/userregistrationdetails) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 new file mode 100644 index 0000000000000..69aaa8442ef98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 @@ -0,0 +1,67 @@ +function Invoke-CippTestE8_MFA_07 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Privileged users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_07' + $Name = 'All privileged users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Reg -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + + if ($PrivUserIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $NonCompliant = foreach ($R in $Reg | Where-Object { $PrivUserIds.Contains($_.id) }) { + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { + if ($PhishMethods -contains $M) { $HasPhish = $true; break } + } + if (-not $HasPhish) { $R } + } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUserIds.Count) privileged users have at least one phishing-resistant method registered." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUserIds.Count) privileged users have no phishing-resistant method registered:`n`n| UPN | Methods Registered |`n| :-- | :----------------- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $(($U.methodsRegistered) -join ', ') |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md new file mode 100644 index 0000000000000..ce78ca97c809e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 3 extends the phishing-resistant MFA requirement from privileged users to **all** users. Every enabled member account must have at least one phishing-resistant method (FIDO2, Windows Hello for Business, certificate-based authentication, or device-bound passkey) registered. + +**Remediation Action** + +1. Roll out FIDO2 security keys, Windows Hello for Business, or device-bound passkeys to all staff. +2. Run a registration campaign — make sure each user registers at least one phishing-resistant method. +3. Once coverage is complete, enforce via a tenant-wide Conditional Access policy with the *Phishing-resistant MFA* authentication strength. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan a passwordless authentication deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-passwordless-deployment) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 new file mode 100644 index 0000000000000..715ffd6898656 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 @@ -0,0 +1,50 @@ +function Invoke-CippTestE8_MFA_08 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - All member users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_08' + $Name = 'All member users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $MemberIds = [System.Collections.Generic.HashSet[string]]::new( + [string[]]($Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' }).id) + + if ($MemberIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled member users found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $Total = 0; $NonCompliant = 0 + foreach ($R in $Reg | Where-Object { $MemberIds.Contains($_.id) }) { + $Total++ + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { if ($PhishMethods -contains $M) { $HasPhish = $true; break } } + if (-not $HasPhish) { $NonCompliant++ } + } + + if ($NonCompliant -eq 0) { + $Status = 'Passed' + $Result = "All $Total enabled member users have a phishing-resistant method registered." + } else { + $Status = 'Failed' + $Result = "$NonCompliant of $Total enabled member users have no phishing-resistant authentication method registered (FIDO2, Windows Hello for Business, X509 cert, or device-bound passkey)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md new file mode 100644 index 0000000000000..d51eb2f1c84be --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md @@ -0,0 +1,14 @@ +Number matching defeats MFA fatigue / push-bombing attacks by forcing the user to read a number from the sign-in screen and type it into the Authenticator app. Microsoft made it default in 2023, but tenants that previously customized the policy can still have it disabled or scoped narrowly. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies > **Microsoft Authenticator**. +2. Configure features > **Require number matching for push notifications** = Enabled. +3. Set the include target to **All users**. + +**Links** +- [How number matching works](https://learn.microsoft.com/en-us/azure/active-directory/authentication/how-to-mfa-number-match) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 new file mode 100644 index 0000000000000..4902fa2ed428e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestE8_MFA_09 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Number matching is enforced for Microsoft Authenticator + #> + param($Tenant) + + $TestId = 'E8_MFA_09' + $Name = 'Microsoft Authenticator number matching is enforced' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $MsAuth = $AuthMethodsPolicy.authenticationMethodConfigurations | Where-Object { $_.id -eq 'MicrosoftAuthenticator' } + if (-not $MsAuth -or $MsAuth.state -ne 'enabled') { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'Microsoft Authenticator method is not enabled in the tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $NumberMatching = $MsAuth.featureSettings.numberMatchingRequiredState + $Issues = [System.Collections.Generic.List[string]]::new() + if ($NumberMatching.state -ne 'enabled') { + $Issues.Add("numberMatchingRequiredState.state is '$($NumberMatching.state)' (expected 'enabled').") + } + if ($NumberMatching.includeTarget.id -and $NumberMatching.includeTarget.id -ne 'all_users') { + $Issues.Add("numberMatchingRequiredState.includeTarget is '$($NumberMatching.includeTarget.id)' (expected 'all_users').") + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Microsoft Authenticator number matching is enabled and targets all users.' + } else { + $Status = 'Failed' + $Result = "Microsoft Authenticator number matching is not fully enforced:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md new file mode 100644 index 0000000000000..32ee5f5e7c810 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md @@ -0,0 +1,17 @@ +At Maturity Level 3 the Essential Eight requires phishing-resistant MFA for every user, not just admins. The technical control is a tenant-wide Conditional Access policy that targets *All users* + *All cloud apps* with the built-in **Phishing-resistant MFA** authentication strength. + +**Remediation Action** + +1. Confirm test E8_MFA_08 is passing (every user has a phishing-resistant method registered). +2. Entra ID > Conditional Access > New policy. +3. Users: All users (exclude break-glass). +4. Cloud apps: All cloud apps. +5. Grant > Require authentication strength > **Phishing-resistant MFA**. +6. Enable the policy. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 new file mode 100644 index 0000000000000..53b0e061cfcbb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_MFA_10 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Phishing-resistant authentication strength is required for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_10' + $Name = 'Phishing-resistant authentication strength is required for all users' + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce phishing-resistant MFA tenant-wide:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy enforces the *Phishing-resistant MFA* authentication strength on All Users + All Cloud Apps.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/report.json b/Modules/CIPPTests/Public/Tests/E8/report.json new file mode 100644 index 0000000000000..13f01c3c3295f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/report.json @@ -0,0 +1,74 @@ +{ + "name": "ACSC Essential Eight", + "description": "Australian Cyber Security Centre (ACSC) Essential Eight Maturity Model — eight mitigation strategies for adversary defence (MFA, Restrict Administrative Privileges, Application Control, Patch Applications, Patch Operating Systems, Configure Microsoft Office Macro Settings, User Application Hardening, Regular Backups). CIPP tests cover what Microsoft 365 / Entra / Intune / Defender APIs expose. Some lower-level enforcement controls (e.g., kernel-mode WDAC rule contents, Credential Guard runtime state, SAW physical separation) cannot be validated from cloud telemetry and are flagged as manual.", + "version": "November 2023", + "source": "https://learn.microsoft.com/en-us/compliance/anz/e8-overview", + "category": "ACSC Essential Eight", + "IdentityTests": [ + "E8_MFA_01", + "E8_MFA_02", + "E8_MFA_03", + "E8_MFA_04", + "E8_MFA_05", + "E8_MFA_06", + "E8_MFA_07", + "E8_MFA_08", + "E8_MFA_09", + "E8_MFA_10", + "E8_Admin_01", + "E8_Admin_02", + "E8_Admin_03", + "E8_Admin_04", + "E8_Admin_05", + "E8_Admin_06", + "E8_Admin_07", + "E8_Admin_08", + "E8_Admin_09", + "E8_Admin_10", + "E8_Admin_11", + "E8_Admin_12", + "E8_Admin_13", + "E8_Backup_01", + "E8_Backup_02", + "E8_Backup_03", + "E8_Backup_04" + ], + "DevicesTests": [ + "E8_AppCtrl_01", + "E8_AppCtrl_02", + "E8_AppCtrl_03", + "E8_AppCtrl_04", + "E8_AppCtrl_05", + "E8_PatchApp_01", + "E8_PatchApp_02", + "E8_PatchApp_03", + "E8_PatchApp_04", + "E8_PatchOS_01", + "E8_PatchOS_02", + "E8_PatchOS_03", + "E8_PatchOS_04", + "E8_PatchOS_05", + "E8_PatchOS_06", + "E8_Macro_01", + "E8_Macro_02", + "E8_Macro_03", + "E8_Macro_04", + "E8_Macro_05", + "E8_Macro_06", + "E8_Macro_07", + "E8_AppHard_01", + "E8_AppHard_02", + "E8_AppHard_03", + "E8_AppHard_04", + "E8_AppHard_05", + "E8_AppHard_06", + "E8_AppHard_07", + "E8_AppHard_08", + "E8_AppHard_09", + "E8_AppHard_10", + "E8_AppHard_11", + "E8_AppHard_12", + "E8_AppHard_13", + "E8_AppHard_14" + ] +} From 5d29976356c421815e064f9abbc8efe7bbcdbd6a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:45:14 +0800 Subject: [PATCH 141/150] Role lookup fixes --- .../CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 | 9 +++++---- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 | 5 +++-- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 | 5 +++-- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 | 5 +++-- .../ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 | 14 ++++++++------ .../ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 | 11 ++++++++--- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 index 9c69b5aea1c4e..9b0eaedac7c9e 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 @@ -6,7 +6,7 @@ function Invoke-CippTestCIS_1_1_4 { param($Tenant) try { - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' @@ -18,9 +18,10 @@ function Invoke-CippTestCIS_1_1_4 { $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $PrivilegedUserIds = [System.Collections.Generic.HashSet[string]]::new() - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) } foreach ($Member in @($Role.members)) { diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 index 3cd5c1eca8f6b..6778b8ceeeaca 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_1 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 index c30b77edc38a0..a8170cb48189d 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_4 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 index bfc12f0bceede..8ef0c74e6d816 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestCIS_5_2_2_5 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $Strengths = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationStrengths' if (-not $CA -or -not $Roles) { @@ -15,7 +15,8 @@ function Invoke-CippTestCIS_5_2_2_5 { return } - $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) $PhishResistantId = '00000000-0000-0000-0000-000000000004' # Built-in 'Phishing-resistant MFA' strength $Matching = $CA | Where-Object { diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 index 8d4ed2870e3b6..71dc891fce1b5 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestZTNA21782 { try { $UserRegistrationDetails = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' if ($null -eq $UserRegistrationDetails -or $null -eq $Roles) { @@ -17,17 +17,19 @@ function Invoke-CippTestZTNA21782 { $PhishResistantMethods = @('passKeyDeviceBound', 'passKeyDeviceBoundAuthenticator', 'windowsHelloForBusiness') + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, so key the privileged role set by template ID. $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $RoleNamesById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) - $RoleNamesById[[string]$Role.id] = $Role.displayName + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) + $RoleNamesById[$RoleTemplateId] = $Role.displayName } } $PrivilegedPrincipalsById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { + foreach ($Role in @($Roles)) { foreach ($Member in @($Role.members)) { if (-not $Member.id) { continue } $principalId = [string]$Member.id diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 index 76ee3e4300cda..9dd3017715458 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestZTNA21783 { #tested try { $CAPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CAPolicies -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' return } - $PrivilegedRoles = $Roles | Where-Object { $_.isPrivileged -and $_.isBuiltIn } + # Get-CippDbRole -IncludePrivilegedRoles already returns the privileged built-in directory roles. + $PrivilegedRoles = @($Roles) if (-not $PrivilegedRoles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged built-in roles found in tenant' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' @@ -31,7 +32,11 @@ function Invoke-CippTestZTNA21783 { $CoveredRoleIds = $PhishResistantPolicies.conditions.users.includeRoles | Select-Object -Unique - $UnprotectedRoles = $PrivilegedRoles | Where-Object { $_.id -notin $CoveredRoleIds } + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $UnprotectedRoles = $PrivilegedRoles | Where-Object { + $Tid = if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } else { $null } + $Tid -notin $CoveredRoleIds + } if ($UnprotectedRoles.Count -eq 0) { $Status = 'Passed' From 7a69da158b7e802562fa9a17afc4e29648d5d383 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:42:03 +0800 Subject: [PATCH 142/150] Harden app template creation to check app type --- .../Invoke-ExecCreateAppTemplate.ps1 | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 index 78945aa34b2a2..3723e94b01930 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 @@ -34,12 +34,12 @@ function Invoke-ExecCreateAppTemplate { [PSCustomObject]@{ id = 'app' method = 'GET' - url = "/applications(appId='$AppId')?`$select=id,appId,displayName,requiredResourceAccess" + url = "/applications(appId='$AppId')?`$select=id,appId,displayName,signInAudience,requiredResourceAccess" } [PSCustomObject]@{ id = 'splist' method = 'GET' - url = '/servicePrincipals?$top=999&$select=id,appId,displayName' + url = '/servicePrincipals?$top=999&$select=id,appId,displayName,signInAudience' } ) @@ -52,6 +52,20 @@ function Invoke-ExecCreateAppTemplate { # Find the specific service principal in the list $SPResult = $TenantInfo | Where-Object { $_.appId -eq $AppId } | Select-Object -First 1 + # Determine the source app's sign-in audience so we only build Enterprise App + # templates for genuinely multi-tenant apps. A single-tenant app (AzureADMyOrg) + # cannot be deployed via an appId-based service principal, and copying it to the + # partner tenant as multi-tenant produces a template that fails to deploy. Those + # apps must use a Manifest (single-tenant) template instead. + $MultiTenantAudiences = @('AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount') + $SignInAudience = $AppResult.body.signInAudience + if ([string]::IsNullOrWhiteSpace($SignInAudience)) { + $SignInAudience = $SPResult.signInAudience + } + if (-not [string]::IsNullOrWhiteSpace($SignInAudience) -and $SignInAudience -notin $MultiTenantAudiences) { + throw "Application '$DisplayName' is single-tenant (signInAudience '$SignInAudience') and cannot be used as an Enterprise App template. Create a Manifest (single-tenant) template for this app instead." + } + # Get the app details based on type if ($Type -eq 'servicePrincipal') { if (-not $SPResult) { From 7227962fede36bd8ed16d691df84d396163a1c4e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 1 Jul 2026 12:29:14 -0400 Subject: [PATCH 143/150] fix: update sherweb functions to support API client callers --- .../Sherweb/Remove-SherwebSubscription.ps1 | 20 ++++++++++++++----- .../Sherweb/Set-SherwebSubscription.ps1 | 20 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 index b24e173dd4881..83bcd58aec88c 100644 --- a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 @@ -15,18 +15,28 @@ function Remove-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb Licenses.' + throw 'This caller is not allowed to modify Sherweb Licenses.' } } } diff --git a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 index 62a2c5ccf2ffa..b2a6766bd8354 100644 --- a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 @@ -18,18 +18,28 @@ function Set-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb subscriptions.' + throw 'This caller is not allowed to modify Sherweb subscriptions.' } } } From 915355cb977ad97350507fe82b616b9756e5d192 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 1 Jul 2026 13:12:37 -0400 Subject: [PATCH 144/150] feat: move edituser to reusable set-cippuser function add scheduling capability in API add pester test --- Modules/CIPPCore/Public/Set-CIPPUser.ps1 | 264 +++++++++++++++++ .../Administration/Users/Invoke-EditUser.ps1 | 276 +++--------------- Tests/Endpoint/Invoke-EditUser.Tests.ps1 | 24 ++ 3 files changed, 321 insertions(+), 243 deletions(-) create mode 100644 Modules/CIPPCore/Public/Set-CIPPUser.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 new file mode 100644 index 0000000000000..691801cb6706e --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 @@ -0,0 +1,264 @@ +function Set-CIPPUser { + [CmdletBinding()] + param ( + $UserObj, + $APIName = 'Edit User', + $Headers + ) + + $Results = [System.Collections.Generic.List[object]]::new() + $licenses = ($UserObj.licenses).value + $Aliases = if ($UserObj.AddedAliases) { ($UserObj.AddedAliases) -split '\s' } + $AddToGroups = $UserObj.AddToGroups + $RemoveFromGroups = $UserObj.RemoveFromGroups + + + #Edit the user + try { + $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" + $normalizedOtherMails = @( + @($UserObj.otherMails) | ForEach-Object { + if ($null -ne $_) { + [string]$_ -split ',' + } + } | ForEach-Object { + $_.Trim() + } | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + } + ) + $BodyToship = [pscustomobject] @{ + 'givenName' = $UserObj.givenName + 'surname' = $UserObj.surname + 'displayName' = $UserObj.displayName + 'department' = $UserObj.department + 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname + 'userPrincipalName' = $UserPrincipalName + 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation + 'jobTitle' = $UserObj.jobTitle + 'mobilePhone' = $UserObj.mobilePhone + 'streetAddress' = $UserObj.streetAddress + 'city' = $UserObj.city + 'state' = $UserObj.state + 'postalCode' = $UserObj.postalCode + 'country' = $UserObj.country + 'companyName' = $UserObj.companyName + 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() + 'otherMails' = $normalizedOtherMails + 'passwordProfile' = @{ + 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass + } + } | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | + Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + # Explicit clears: the frontend lists the profile fields the user actively emptied. + # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while + # untouched empty fields stay omitted. Whitelisted to safe attributes + $ClearableFields = @( + 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', + 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', + 'businessPhones', 'otherMails' + ) + $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($Prop in $ClearList) { + if ($Prop -notin $ClearableFields) { continue } + # Pass @() literally; routing it through a variable unrolls it back to $null. + if ($Prop -in 'businessPhones', 'otherMails') { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force + } else { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force + } + } + if ($UserObj.defaultAttributes) { + $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($UserObj.defaultAttributes.$($_.Name).value)) { + Write-Host "Editing user and adding $($_.Name) with value $($UserObj.defaultAttributes.$($_.Name).value)" + $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.defaultAttributes.$($_.Name).value -Force + } + } + } + if ($UserObj.customData) { + $UserObj.customData | Get-Member -MemberType NoteProperty | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($UserObj.customData.$($_.Name))) { + Write-Host "Editing user and adding custom data $($_.Name) with value $($UserObj.customData.$($_.Name))" + $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.customData.$($_.Name) -Force + } + } + } + $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToship -Compress + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $BodyToship -verbose + $Results.Add( 'Success. The user has been edited.' ) + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Edited user $($UserObj.DisplayName) with id $($UserObj.id)" -Sev Info + if ($UserObj.password) { + $passwordProfile = [pscustomobject]@{'passwordProfile' = @{ 'password' = $UserObj.password; 'forceChangePasswordNextSignIn' = [boolean]$UserObj.MustChangePass } } | ConvertTo-Json + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $PasswordProfile -Verbose + $Results.Add("Success. The password has been set to $($UserObj.password)") + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Reset $($UserObj.DisplayName)'s Password" -Sev Info + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "User edit API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $Results.Add( "Failed to edit user. $($ErrorMessage.NormalizedError)") + } + + + #Reassign the licenses + try { + + if ($licenses -or $UserObj.removeLicenses) { + if ($UserObj.sherwebLicense.value) { + $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 + $Results.Add('Added Sherweb License, scheduling assignment') + $taskObject = [PSCustomObject]@{ + TenantFilter = $UserObj.tenantFilter + Name = "Assign License: $UserPrincipalName" + Command = @{ + value = 'Set-CIPPUserLicense' + } + Parameters = [pscustomobject]@{ + UserId = $UserObj.id + APIName = 'Sherweb License Assignment' + AddLicenses = $licenses + UserPrincipalName = $UserPrincipalName + } + ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. + PostExecution = @{ + Webhook = [bool]$UserObj.PostExecution.webhook + Email = [bool]$UserObj.PostExecution.email + PSA = [bool]$UserObj.PostExecution.psa + } + } + Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers + } else { + $CurrentLicenses = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter + #if the list of skuIds in $CurrentLicenses.assignedLicenses is EXACTLY the same as $licenses, we don't need to do anything, but the order in both can be different. + if (($CurrentLicenses.assignedLicenses.skuId -join ',') -eq ($licenses -join ',') -and $UserObj.removeLicenses -eq $false) { + Write-Host "$($CurrentLicenses.assignedLicenses.skuId -join ',') $(($licenses -join ','))" + $Results.Add( 'Success. User license is already correct.' ) + } else { + if ($UserObj.removeLicenses) { + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName + $Results.Add($licResults) + } else { + #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses + $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName + $Results.Add($licResults) + } + + } + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "License assign API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $Results.Add( "We've failed to assign the license. $($ErrorMessage.NormalizedError)") + Write-Warning "License assign API failed. $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + } + + #Add Aliases, removal currently not supported. + try { + if ($Aliases) { + Write-Host ($Aliases | ConvertTo-Json) + foreach ($Alias in $Aliases) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$Alias`"}" -Verbose + } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$UserPrincipalName`"}" -Verbose + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Added Aliases to $($UserObj.DisplayName)" -Sev Info + $Results.Add( 'Success. Added aliases to user.') + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add aliases to user $($UserObj.DisplayName). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + + if ($UserObj.CopyFrom.value) { + $CopyFrom = Set-CIPPCopyGroupMembers -Headers $Headers -CopyFromId $UserObj.CopyFrom.value -UserID $UserPrincipalName -TenantFilter $UserObj.tenantFilter + $Results.AddRange(@($CopyFrom)) + } + + if ($AddToGroups) { + $AddToGroups | ForEach-Object { + + $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null + $GroupID = $_.value + $GroupName = $_.label + Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" + + try { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + Write-Host 'Adding to group via Add-DistributionGroupMember' + $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } + $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true + } else { + Write-Host 'Adding to group via Graph' + $UserBody = [PSCustomObject]@{ + '@odata.id' = "https://graph.microsoft.com/beta/directoryObjects/$($UserObj.id)" + } + $UserBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $UserBody + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/`$ref" -tenantid $UserObj.tenantFilter -type POST -body $UserBodyJSON -Verbose + } + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Added $($UserObj.DisplayName) to $GroupName group" -Sev Info + $Results.Add("Success. $($UserObj.DisplayName) has been added to $GroupName") + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add member $($UserObj.DisplayName) to $GroupName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + } + } + + if ($RemoveFromGroups) { + $RemoveFromGroups | ForEach-Object { + + $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null + $GroupID = $_.value + $GroupName = $_.label + Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" + + try { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + Write-Host 'Removing From group via Remove-DistributionGroupMember' + $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } + $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true + } else { + Write-Host 'Removing From group via Graph' + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/$($UserObj.id)/`$ref" -tenantid $UserObj.tenantFilter -type DELETE + } + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Removed $($UserObj.DisplayName) from $GroupName group" -Sev Info + $Results.Add("Success. $($UserObj.DisplayName) has been removed from $GroupName") + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to remove member $($UserObj.DisplayName) from $GroupName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + } + } + + if ($UserObj.setManager.value) { + $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) + } + + if ($UserObj.setSponsor.value) { + $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) + } + + return @{ + Results = $Results + UserPrincipalName = $UserPrincipalName + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index 84590fda70b59..c9576f8b7fd4b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -19,262 +19,52 @@ function Invoke-EditUser { StatusCode = [HttpStatusCode]::BadRequest Body = $Body }) - return } - $Results = [System.Collections.Generic.List[object]]::new() - $licenses = ($UserObj.licenses).value - $Aliases = if ($UserObj.AddedAliases) { ($UserObj.AddedAliases) -split '\s' } - $AddToGroups = $Request.Body.AddToGroups - $RemoveFromGroups = $Request.Body.RemoveFromGroups - - #Edit the user - try { - $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" - $normalizedOtherMails = @( - @($UserObj.otherMails) | ForEach-Object { - if ($null -ne $_) { - [string]$_ -split ',' + if ($UserObj.Scheduled.Enabled) { + try { + $TaskBody = [pscustomobject]@{ + TenantFilter = $UserObj.tenantFilter + Name = "Edit user: $($UserObj.DisplayName)" + Command = @{ + value = 'Set-CIPPUser' + label = 'Set-CIPPUser' } - } | ForEach-Object { - $_.Trim() - } | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) - } - ) - $BodyToship = [pscustomobject] @{ - 'givenName' = $UserObj.givenName - 'surname' = $UserObj.surname - 'displayName' = $UserObj.displayName - 'department' = $UserObj.department - 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname - 'userPrincipalName' = $UserPrincipalName - 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation - 'jobTitle' = $UserObj.jobTitle - 'mobilePhone' = $UserObj.mobilePhone - 'streetAddress' = $UserObj.streetAddress - 'city' = $UserObj.city - 'state' = $UserObj.state - 'postalCode' = $UserObj.postalCode - 'country' = $UserObj.country - 'companyName' = $UserObj.companyName - 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() - 'otherMails' = $normalizedOtherMails - 'passwordProfile' = @{ - 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass - } - } | ForEach-Object { - $NonEmptyProperties = $_.PSObject.Properties | - Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | - Select-Object -ExpandProperty Name - $_ | Select-Object -Property $NonEmptyProperties - } - # Explicit clears: the frontend lists the profile fields the user actively emptied. - # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while - # untouched empty fields stay omitted. Whitelisted to safe attributes - $ClearableFields = @( - 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', - 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', - 'businessPhones', 'otherMails' - ) - $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - foreach ($Prop in $ClearList) { - if ($Prop -notin $ClearableFields) { continue } - # Pass @() literally; routing it through a variable unrolls it back to $null. - if ($Prop -in 'businessPhones', 'otherMails') { - $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force - } else { - $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force - } - } - if ($UserObj.defaultAttributes) { - $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.defaultAttributes.$($_.Name).value)) { - Write-Host "Editing user and adding $($_.Name) with value $($UserObj.defaultAttributes.$($_.Name).value)" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.defaultAttributes.$($_.Name).value -Force + Parameters = [pscustomobject]@{ UserObj = $UserObj } + ScheduledTime = $UserObj.Scheduled.date + Reference = $UserObj.reference ?? $null + PostExecution = @{ + Webhook = [bool]$Request.Body.PostExecution.Webhook + Email = [bool]$Request.Body.PostExecution.Email + PSA = [bool]$Request.Body.PostExecution.PSA } } - } - if ($UserObj.customData) { - $UserObj.customData | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.customData.$($_.Name))) { - Write-Host "Editing user and adding custom data $($_.Name) with value $($UserObj.customData.$($_.Name))" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.customData.$($_.Name) -Force - } + Add-CIPPScheduledTask -Task $TaskBody -hidden $false -DisallowDuplicateName $true -Headers $Headers + $body = [pscustomobject]@{ + 'Results' = @("Successfully created scheduled task to edit user $($UserObj.DisplayName)") } - } - $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToship -Compress - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $BodyToship -verbose - $Results.Add( 'Success. The user has been edited.' ) - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Edited user $($UserObj.DisplayName) with id $($UserObj.id)" -Sev Info - if ($UserObj.password) { - $passwordProfile = [pscustomobject]@{'passwordProfile' = @{ 'password' = $UserObj.password; 'forceChangePasswordNextSignIn' = [boolean]$UserObj.MustChangePass } } | ConvertTo-Json - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $PasswordProfile -Verbose - $Results.Add("Success. The password has been set to $($UserObj.password)") - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Reset $($UserObj.DisplayName)'s Password" -Sev Info - } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "User edit API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "Failed to edit user. $($ErrorMessage.NormalizedError)") - } - - - #Reassign the licenses - try { - - if ($licenses -or $UserObj.removeLicenses) { - if ($UserObj.sherwebLicense.value) { - $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 - $Results.Add('Added Sherweb License, scheduling assignment') - $taskObject = [PSCustomObject]@{ - TenantFilter = $UserObj.tenantFilter - Name = "Assign License: $UserPrincipalName" - Command = @{ - value = 'Set-CIPPUserLicense' - } - Parameters = [pscustomobject]@{ - UserId = $UserObj.id - APIName = 'Sherweb License Assignment' - AddLicenses = $licenses - UserPrincipalName = $UserPrincipalName - } - ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. - PostExecution = @{ - Webhook = [bool]$Request.Body.PostExecution.webhook - Email = [bool]$Request.Body.PostExecution.email - PSA = [bool]$Request.Body.PostExecution.psa - } - } - Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers - } else { - $CurrentLicenses = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter - #if the list of skuIds in $CurrentLicenses.assignedLicenses is EXACTLY the same as $licenses, we don't need to do anything, but the order in both can be different. - if (($CurrentLicenses.assignedLicenses.skuId -join ',') -eq ($licenses -join ',') -and $UserObj.removeLicenses -eq $false) { - Write-Host "$($CurrentLicenses.assignedLicenses.skuId -join ',') $(($licenses -join ','))" - $Results.Add( 'Success. User license is already correct.' ) - } else { - if ($UserObj.removeLicenses) { - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } else { - #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses - $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } - - } + } catch { + $body = [pscustomobject]@{ + 'Results' = @("Failed to create scheduled task to edit user $($UserObj.DisplayName): $($_.Exception.Message)") } + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "License assign API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "We've failed to assign the license. $($ErrorMessage.NormalizedError)") - Write-Warning "License assign API failed. $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - } - - #Add Aliases, removal currently not supported. - try { - if ($Aliases) { - Write-Host ($Aliases | ConvertTo-Json) - foreach ($Alias in $Aliases) { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$Alias`"}" -Verbose + } else { + try { + $EditResults = Set-CIPPUser -UserObj $UserObj -APIName $APIName -Headers $Headers + $body = [pscustomobject]@{ 'Results' = @($EditResults.Results) } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $body = [pscustomobject]@{ + 'Results' = @("Failed to edit user. $($ErrorMessage.NormalizedError)") } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$UserPrincipalName`"}" -Verbose - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Added Aliases to $($UserObj.DisplayName)" -Sev Info - $Results.Add( 'Success. Added aliases to user.') + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add aliases to user $($UserObj.DisplayName). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - - if ($Request.Body.CopyFrom.value) { - $CopyFrom = Set-CIPPCopyGroupMembers -Headers $Headers -CopyFromId $Request.Body.CopyFrom.value -UserID $UserPrincipalName -TenantFilter $UserObj.tenantFilter - $Results.AddRange(@($CopyFrom)) - } - - if ($AddToGroups) { - $AddToGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { - Write-Host 'Adding to group via Add-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Adding to group via Graph' - $UserBody = [PSCustomObject]@{ - '@odata.id' = "https://graph.microsoft.com/beta/directoryObjects/$($UserObj.id)" - } - $UserBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $UserBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/`$ref" -tenantid $UserObj.tenantFilter -type POST -body $UserBodyJSON -Verbose - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Added $($UserObj.DisplayName) to $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been added to $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add member $($UserObj.DisplayName) to $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($RemoveFromGroups) { - $RemoveFromGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { - Write-Host 'Removing From group via Remove-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Removing From group via Graph' - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/$($UserObj.id)/`$ref" -tenantid $UserObj.tenantFilter -type DELETE - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Removed $($UserObj.DisplayName) from $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been removed from $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to remove member $($UserObj.DisplayName) from $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($Request.body.setManager.value) { - $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResults.Result) - } - - if ($Request.body.setSponsor.value) { - $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResults.Result) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{'Results' = @($Results) } + StatusCode = $StatusCode ? $StatusCode : [HttpStatusCode]::OK + Body = $Body }) } diff --git a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 index 81aee9276912f..6681b230f4f53 100644 --- a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 +++ b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 @@ -6,6 +6,7 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1' + $CorePath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPUser.ps1' class HttpResponseContext { [object]$StatusCode @@ -24,6 +25,10 @@ BeforeAll { param($uri, $tenantid, $type, $body, [switch]$verbose) if ($null -eq $script:lastBody) { $script:lastBody = $body } } + function Add-CIPPScheduledTask { + param($Task, $hidden, $DisallowDuplicateName, $Headers) + $script:lastScheduledTask = $Task + } function New-EditRequest { param([hashtable]$Extra) @@ -44,6 +49,7 @@ BeforeAll { } } + . $CorePath . $FunctionPath } @@ -92,3 +98,21 @@ Describe 'Invoke-EditUser body construction' { $script:lastBody | Should -Not -Match '"displayName":(null|"")' } } + +Describe 'Invoke-EditUser scheduling' { + BeforeEach { + $script:lastBody = $null + $script:lastScheduledTask = $null + } + + It 'schedules a Set-CIPPUser task instead of editing immediately when Scheduled.Enabled is set' { + $request = New-EditRequest -Extra @{ Scheduled = @{ Enabled = $true; date = 1234567890 } } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -BeNullOrEmpty + $script:lastScheduledTask.Command.value | Should -Be 'Set-CIPPUser' + $script:lastScheduledTask.Parameters.UserObj.id | Should -Be $request.Body.id + $script:lastScheduledTask.ScheduledTime | Should -Be 1234567890 + } +} From ea8259f8c0a016db2558f2c88dd67b7c323b8833 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:45:38 +0200 Subject: [PATCH 145/150] add package to exclusions --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index da6a537fc21fc..7ea75b264fac1 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -35,7 +35,8 @@ function Compare-CIPPIntuneObject { 'isSynced' 'locationInfo', 'templateId', - 'source' + 'source', + 'package' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties From b326768d6b4fe2e78fcce088fd3568d8e4a6d2fc Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:52:55 +0200 Subject: [PATCH 146/150] fix(tests): resolve function paths by filename Existing tests hardcoded module paths that break whenever a function moves between modules. Resolve the function under test by filename under Modules/ instead, so tests keep passing after file moves. Fixes the Intune/Alert/Standards tests currently failing on dev. --- ...et-CIPPAlertGlobalAdminAllowList.Tests.ps1 | 14 +++- ...t-CIPPAlertIntunePolicyConflicts.Tests.ps1 | 2 +- .../Invoke-AddIntuneReusableSetting.Tests.ps1 | 5 +- ...AddIntuneReusableSettingTemplate.Tests.ps1 | 10 ++- ...stIntuneReusableSettingTemplates.Tests.ps1 | 5 +- ...nvoke-ListIntuneReusableSettings.Tests.ps1 | 68 ++++++++++++------- ...voke-RemoveIntuneReusableSetting.Tests.ps1 | 8 ++- ...oveIntuneReusableSettingTemplate.Tests.ps1 | 7 +- ...StandardReusableSettingsTemplate.Tests.ps1 | 5 +- 9 files changed, 89 insertions(+), 35 deletions(-) diff --git a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 index 7a8ae25cc5909..c2a9e70906284 100644 --- a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 @@ -3,13 +3,19 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $AlertPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $AlertPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPAlertGlobalAdminAllowList.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $AlertPath) { throw 'Could not locate Get-CIPPAlertGlobalAdminAllowList.ps1 under Modules/' } # Provide minimal stubs so Mock has commands to replace during tests function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } function Write-AlertMessage { param($tenant, $message) } function Get-NormalizedError { param($message) $message } + # The error path now normalises via Get-CippException and logs via Write-LogMessage. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Write-LogMessage { param($API, $tenant, $message, $sev, $Headers, $LogData) } . $AlertPath } @@ -47,6 +53,12 @@ Describe 'Get-CIPPAlertGlobalAdminAllowList' { param($tenant, $message) $script:CapturedErrorMessage = $message } + + # The function logs failures through Write-LogMessage, so capture the error from there. + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $Headers, $LogData) + $script:CapturedErrorMessage = $message + } } It 'emits per-admin alerts when AlertEachAdmin is true' { diff --git a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 index f44bb399798e0..7cfd7476d483e 100644 --- a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 @@ -122,7 +122,7 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $AppIssue = $CapturedData | Where-Object { $_.Type -eq 'Application' } $AppIssue.FailedDeviceCount | Should -Be 3 - $AppIssue.Message | Should -Match "failed to install on 3 device" + $AppIssue.Message | Should -Match 'failed to install on 3 device' } It 'skips processing when license check fails' { diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 index 555008547f998..4d926187dfaf3 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-AddIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSetting.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 index d29a4530611fb..7889fc9ffbd88 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -3,8 +3,14 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1' - $MetadataPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1' + # Resolve by name under Modules/ so the tests survive functions moving between modules. + $ModulesRoot = Join-Path $RepoRoot 'Modules' + $FunctionPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Invoke-AddIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSettingTemplate.ps1 under Modules/' } + $MetadataPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Remove-CIPPReusableSettingMetadata.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $MetadataPath) { throw 'Could not locate Remove-CIPPReusableSettingMetadata.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 index 3813c7de946b9..095fb82996528 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettingTemplates.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettingTemplates.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 index 5a66f62a43a44..730b9ee2f4364 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 @@ -1,66 +1,82 @@ # Pester tests for Invoke-ListIntuneReusableSettings -# Validates listing and filtering of live reusable settings +# Validates listing of live reusable settings, the report-DB branch, and tenant validation. BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettings.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettings.ps1 under Modules/' } + # Azure Functions binding types do not exist outside the Functions host - fake them. class HttpResponseContext { [int]$StatusCode [object]$Body } - Add-Type -AssemblyName System.Net.Http - - function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) } - function Get-CippException { param($Exception) $Exception } - function New-GraphGETRequest { param($uri, $tenantid) } + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Get-CIPPIntuneReusableSettingsReport { param($TenantFilter) } + function New-GraphGetRequest { param($uri, $tenantid) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } . $FunctionPath } Describe 'Invoke-ListIntuneReusableSettings' { BeforeEach { - $script:lastUri = $null + $script:logs = @() + Mock -CommandName Write-LogMessage -MockWith { $script:logs += $message } + Mock -CommandName Get-CippException -MockWith { param($Exception) @{ NormalizedError = "$Exception" } } } - It 'returns reusable settings with raw JSON when tenantFilter is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { + It 'returns OK and the live Graph results on the happy path' { + Mock -CommandName New-GraphGetRequest -MockWith { @( - [pscustomobject]@{ id = 'one'; displayName = 'A Item'; description = 'A description'; version = 1 }, - [pscustomobject]@{ id = 'two'; displayName = 'Z Item'; description = 'Z description'; version = 2 } + [pscustomobject]@{ id = 'setting-1'; displayName = 'Reusable A' }, + [pscustomobject]@{ id = 'setting-2'; displayName = 'Reusable B' } ) } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com' } } + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com' } + } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) - $response.Body.Count | Should -Be 2 - $response.Body[0].displayName | Should -Be 'A Item' + $response.Body | Should -HaveCount 2 + # The function enriches each item with a compact RawJSON copy. $response.Body[0].RawJSON | Should -Not -BeNullOrEmpty + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*reusablePolicySettings*' -and $tenantid -eq 'contoso.onmicrosoft.com' } -Times 1 } - It 'requests a specific setting when ID is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { - param($uri, $tenantid) - $script:lastUri = $uri - @([pscustomobject]@{ id = 'beta'; displayName = 'Beta' }) + It 'reads from the reporting DB when UseReportDB is true' { + Mock -CommandName Get-CIPPIntuneReusableSettingsReport -MockWith { + @([pscustomobject]@{ id = 'cached-1'; displayName = 'Cached' }) + } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; UseReportDB = 'true' } } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com'; ID = 'beta' } } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null - $lastUri | Should -Match '/reusablePolicySettings/beta' - $response.Body.Count | Should -Be 1 - $response.Body[0].displayName | Should -Be 'Beta' - $response.Body[0].RawJSON | Should -Match '"id":"beta"' + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Get-CIPPIntuneReusableSettingsReport -Times 1 + Should -Invoke New-GraphGetRequest -Times 0 } It 'returns BadRequest when tenantFilter is missing' { - $request = [pscustomobject]@{ query = @{} } + $request = [pscustomobject]@{ Body = [pscustomobject]@{} ; Query = [pscustomobject]@{} } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'tenantFilter is required' } } diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 index da1acacfab221..290ab4bb0a710 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 @@ -2,8 +2,14 @@ # Validates deletion and required parameters BeforeAll { + # Locate the function by name under Modules/ so the test survives the function being + # moved between modules (it has already moved from CIPPCore to CIPPHTTP once). $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { + throw 'Could not locate Invoke-RemoveIntuneReusableSetting.ps1 under Modules/' + } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 index e39bb1dfa0b7f..0e12a26d892d2 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-RemoveIntuneReusableSettingTemplate.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode @@ -15,6 +18,8 @@ BeforeAll { function Remove-AzDataTableEntity { param([switch]$Force, $Entity) $script:lastRemoved = $Entity; $script:lastForce = $Force } function Write-LogMessage { param($Headers, $API, $message, $sev, $LogData) $script:logs += $message } function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception } } + # The ID is sanitised for OData before the table lookup; stub it to pass the value through. + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) $Value } . $FunctionPath } diff --git a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 index 9777672690c5f..82e43e1d0d4b7 100644 --- a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $StandardPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardReusableSettingsTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardReusableSettingsTemplate.ps1 under Modules/' } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } function Get-CippTable { param($tablename) } From b6ceba4140b3260ff015d2dcf3e9cce9082f4544 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:53:33 +0200 Subject: [PATCH 147/150] refactor(mailbox-perms): share permission helper Set-CIPPMailboxAccess, Invoke-ExecEditMailboxPermissions and Invoke-ExecModifyMBPerms each duplicated the permission-level to EXO cmdlet mapping. Route all three through the existing Set-CIPPMailboxPermission helper (~230 fewer lines) and add Pester coverage for the three refactored functions. Intended behavior changes: - ExecEditMailboxPermissions drops a redundant Graph id lookup; the UPN is passed straight to EXO as identity. - SendOnBehalf now syncs the permission cache, matching FullAccess/SendAs. Bulk processing in ExecModifyMBPerms is unchanged. --- .../CIPPCore/Public/Set-CIPPMailboxAccess.ps1 | 20 +- .../Invoke-ExecEditMailboxPermissions.ps1 | 121 ++------ .../Invoke-ExecModifyMBPerms.ps1 | 260 +++++----------- ...nvoke-ExecEditMailboxPermissions.Tests.ps1 | 127 ++++++++ .../Invoke-ExecModifyMBPerms.Tests.ps1 | 278 ++++++++++++++++++ Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 | 79 +++++ 6 files changed, 576 insertions(+), 309 deletions(-) create mode 100644 Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index b16d867d6f411..54a1a075c040f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -7,7 +7,7 @@ function Set-CIPPMailboxAccess { $TenantFilter, $APIName = 'Manage Shared Mailbox Access', $Headers, - [array]$AccessRights + [array]$AccessRights # Retained for caller compatibility; this helper grants FullAccess ) # Ensure AccessUser is always an array @@ -22,20 +22,12 @@ function Set-CIPPMailboxAccess { $Results = [system.collections.generic.list[string]]::new() - # Process each access user + # Delegate each grant to Set-CIPPMailboxPermission so the permission-level -> EXO cmdlet mapping, + # logging, cache sync, and error handling all live in one place. This helper grants FullAccess. foreach ($User in $AccessUser) { - try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $User; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid - - $Message = "Successfully added $($User) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter - $Results.Add($Message) - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add mailbox permissions for $($User) on $($userid). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage - $Results.Add($Message) - } + $Results.Add( + (Set-CIPPMailboxPermission -UserId $userid -AccessUser $User -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $Automap -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers) + ) } return $Results diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 index aa7996657fee8..478bc1aa4ef83 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 @@ -17,110 +17,27 @@ function Invoke-ExecEditMailboxPermissions { $Username = $request.body.userID $Tenantfilter = $request.body.tenantfilter if ($username -eq $null) { exit } - $userid = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($username)" -tenantid $Tenantfilter).id $Results = [System.Collections.ArrayList]@() - $RemoveFullAccess = ($Request.body.RemoveFullAccess).value - foreach ($RemoveUser in $RemoveFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-mailboxpermission' -cmdParams @{Identity = $userid; user = $RemoveUser; accessRights = @('FullAccess'); } - $results.add("Removed $($removeuser) from $($username) Shared Mailbox permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $($RemoveUser) from $($username) Shared Mailbox permission" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $RemoveUser -PermissionType 'FullAccess' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($removeuser) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($removeuser) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccess = ($Request.body.AddFullAccess).value - - foreach ($UserAutomap in $AddFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserAutomap; accessRights = @('FullAccess'); automapping = $true } - $results.add( "Granted $($UserAutomap) access to $($username) Mailbox with automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $($UserAutomap) access to $($username) Mailbox with automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserAutomap -PermissionType 'FullAccess' -Action 'Add' - - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add( "Could not add $($UserAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccessNoAutoMap = ($Request.body.AddFullAccessNoAutoMap).value - - foreach ($UserNoAutomap in $AddFullAccessNoAutoMap) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserNoAutomap; accessRights = @('FullAccess'); automapping = $false } - $results.add( "Granted $UserNoAutomap access to $($username) Mailbox without automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserNoAutomap access to $($username) Mailbox without automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserNoAutomap -PermissionType 'FullAccess' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserNoAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserNoAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendAS = ($Request.body.AddSendAs).value - - foreach ($UserSendAs in $AddSendAS) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Granted $UserSendAs access to $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendAs access to $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendAs = ($Request.body.RemoveSendAs).value - - foreach ($UserSendAs in $RemoveSendAs) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Removed $UserSendAs from $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendAs from $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendOnBehalf = ($Request.body.AddSendOnBehalf).value - - foreach ($UserSendOnBehalf in $AddSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; add = $UserSendOnBehalf }; } - $results.add( "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendOnBehalf = ($Request.body.RemoveSendOnBehalf).value - - foreach ($UserSendOnBehalf in $RemoveSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; remove = $UserSendOnBehalf }; } - $results.add( "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not Remove send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") + # Each request-body bucket maps to a (PermissionLevel, Action) pair. Delegate to + # Set-CIPPMailboxPermission so the EXO cmdlet mapping, logging, cache sync, and error handling + # all live in one place. The mailbox UPN is passed straight through as the identity - EXO accepts + # it, so no Graph id lookup is required. + $PermissionBuckets = @( + @{ Bucket = 'RemoveFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'AddFullAccessNoAutoMap'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $false } + @{ Bucket = 'AddSendAs'; PermissionLevel = 'SendAs'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendAs'; PermissionLevel = 'SendAs'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Remove'; AutoMap = $true } + ) + + foreach ($Bucket in $PermissionBuckets) { + foreach ($AccessUser in ($Request.body.($Bucket.Bucket)).value) { + $null = $Results.Add( + (Set-CIPPMailboxPermission -UserId $Username -AccessUser $AccessUser -PermissionLevel $Bucket.PermissionLevel -Action $Bucket.Action -AutoMap $Bucket.AutoMap -TenantFilter $Tenantfilter -APIName $APIName -Headers $Headers) + ) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 index a97e3a4148255..3f92aa8715991 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecModifyMBPerms { +function Invoke-ExecModifyMBPerms { <# .FUNCTIONALITY Entrypoint @@ -9,41 +9,42 @@ Function Invoke-ExecModifyMBPerms { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'Accessed this API' -Sev 'Debug' + $Headers = $Request.Headers + Write-LogMessage -headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' # Extract mailbox requests - handle all three formats $MailboxRequests = $null $Results = [System.Collections.ArrayList]::new() # Direct array format - if ($request.body -is [array]) { - $MailboxRequests = $request.body + if ($Request.Body -is [array]) { + $MailboxRequests = $Request.Body } # Bulk format with mailboxRequests property - elseif ($request.body.mailboxRequests) { - $MailboxRequests = $request.body.mailboxRequests + elseif ($Request.Body.mailboxRequests) { + $MailboxRequests = $Request.Body.mailboxRequests } # Legacy single mailbox format - elseif ($request.body.userID -and $request.body.permissions) { + elseif ($Request.Body.userID -and $Request.Body.permissions) { $MailboxRequests = @([PSCustomObject]@{ - userID = $request.body.userID - tenantFilter = $request.body.tenantFilter - permissions = $request.body.permissions - }) + userID = $Request.Body.userID + tenantFilter = $Request.Body.tenantFilter + permissions = $Request.Body.permissions + }) } if (-not $MailboxRequests -or $MailboxRequests.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No mailbox requests provided' -Sev 'Error' - $body = [pscustomobject]@{'Results' = @("No mailbox requests provided") } + Write-LogMessage -headers $Headers -API $APIName -message 'No mailbox requests provided' -Sev 'Error' + $body = [pscustomobject]@{'Results' = @('No mailbox requests provided') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::BadRequest - Body = $Body - }) + StatusCode = [HttpStatusCode]::BadRequest + Body = $Body + }) return } - $TenantFilter = $Request.body.tenantFilter - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter + $TenantFilter = $Request.Body.tenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter # Build cmdlet array for processing $CmdletArray = [System.Collections.ArrayList]::new() @@ -51,12 +52,16 @@ Function Invoke-ExecModifyMBPerms { $GuidToMetadataMap = @{} # Map GUIDs to our metadata $UserLookupCache = @{} + # Permission levels Set-CIPPMailboxPermission understands (its ValidateSet). Levels outside this + # set are silently skipped, matching the behaviour of the inline switch this used to carry. + $SupportedPermissionLevels = @('FullAccess', 'SendAs', 'SendOnBehalf', 'ReadPermission', 'ExternalAccount', 'DeleteItem', 'ChangePermission', 'ChangeOwner') + foreach ($MailboxRequest in $MailboxRequests) { $Username = $MailboxRequest.userID $Permissions = $MailboxRequest.permissions if ([string]::IsNullOrEmpty($Username)) { - $null = $Results.Add("Skipped mailbox with missing userID") + $null = $Results.Add('Skipped mailbox with missing userID') continue } @@ -65,18 +70,16 @@ Function Invoke-ExecModifyMBPerms { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)" -tenantid $TenantFilter $UserLookupCache[$Username] = $UserObject.userPrincipalName - } - catch { + } catch { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userPrincipalName eq '$Username'" -tenantid $TenantFilter if ($UserObject.value -and $UserObject.value.Count -gt 0) { $UserLookupCache[$Username] = $UserObject.value[0].userPrincipalName } else { - throw "User not found" + throw 'User not found' } - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Could not find user $($Username)") continue } @@ -99,7 +102,7 @@ Function Invoke-ExecModifyMBPerms { $AutoMap = if ($Permission.PSObject.Properties.Name -contains 'AutoMap') { $Permission.AutoMap } else { $true } # Handle multiple permission levels - $PermissionLevelArray = if ($PermissionLevels -like "*,*") { + $PermissionLevelArray = if ($PermissionLevels -like '*,*') { $PermissionLevels -split ',' | ForEach-Object { $_.Trim() } } else { @($PermissionLevels.Trim()) @@ -125,160 +128,35 @@ Function Invoke-ExecModifyMBPerms { foreach ($TargetUser in $TargetUsers) { foreach ($PermissionLevel in $PermissionLevelArray) { - # Create cmdlet parameters based on permission type and action - $CmdletParams = @{} - $CmdletName = "" - $ExpectedResult = "" + # Build the EXO cmdlet for this change via Set-CIPPMailboxPermission's + # -AsCmdletObject mode - the single source of truth for the permission-level -> + # cmdlet/parameter mapping. It returns @{ CmdletName; Parameters; ExpectedResult } + # for supported combinations, or a plain string for unsupported ones (e.g. an Add + # on a remove-only level), which we skip. The bulk machinery below is unchanged. + if ($PermissionLevel -notin $SupportedPermissionLevels) { continue } + $Action = if ($Modification -eq 'Remove') { 'Remove' } else { 'Add' } - switch ($PermissionLevel) { - 'FullAccess' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) from $($Username) FullAccess permissions" - } else { - $CmdletName = 'Add-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - automapping = $AutoMap - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) FullAccess to $($Username) with automapping $($AutoMap)" - } - } - 'SendAs' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendAs permissions from $($Username)" - } else { - $CmdletName = 'Add-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendAs permissions to $($Username)" - } - } - 'SendOnBehalf' { - $CmdletName = 'Set-Mailbox' - if ($Modification -eq 'Remove') { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - remove = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendOnBehalf permissions from $($Username)" - } else { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - add = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendOnBehalf permissions to $($Username)" - } - } - 'ReadPermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ReadPermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ReadPermission from $($Username)" - } - } - 'ExternalAccount' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ExternalAccount') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ExternalAccount permissions from $($Username)" - } - } - 'DeleteItem' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('DeleteItem') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) DeleteItem permissions from $($Username)" - } - } - 'ChangePermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangePermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangePermission from $($Username)" - } - } - 'ChangeOwner' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangeOwner') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangeOwner permissions from $($Username)" - } - } - } + $Mapping = Set-CIPPMailboxPermission -UserId $UserId -AccessUser $TargetUser -PermissionLevel $PermissionLevel -Action $Action -AutoMap $AutoMap -TenantFilter $TenantFilter -AsCmdletObject - if ($CmdletName) { + if ($Mapping -is [hashtable] -and $Mapping.CmdletName) { # Generate unique GUID for this operation $OperationGuid = [Guid]::NewGuid().ToString() $CmdletObj = @{ - CmdletInput = @{ - CmdletName = $CmdletName - Parameters = $CmdletParams + CmdletInput = @{ + CmdletName = $Mapping.CmdletName + Parameters = $Mapping.Parameters } OperationGuid = $OperationGuid # Add GUID to cmdlet object } $CmdletMetadata = [PSCustomObject]@{ - ExpectedResult = $ExpectedResult - Mailbox = $Username - TargetUser = $TargetUser - Permission = $PermissionLevel - Action = $Modification - OperationGuid = $OperationGuid + ExpectedResult = $Mapping.ExpectedResult + Mailbox = $Username + TargetUser = $TargetUser + Permission = $PermissionLevel + Action = $Modification + OperationGuid = $OperationGuid } $null = $CmdletArray.Add($CmdletObj) @@ -293,12 +171,12 @@ Function Invoke-ExecModifyMBPerms { } if ($CmdletArray.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter - $body = [pscustomobject]@{'Results' = @("No valid permission changes to process") } + Write-LogMessage -headers $Headers -API $APIName -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter + $body = [pscustomobject]@{'Results' = @('No valid permission changes to process') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) return } @@ -306,7 +184,7 @@ Function Invoke-ExecModifyMBPerms { if ($CmdletArray.Count -gt 1) { # Use bulk processing with GUID tracking try { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($CmdletArray) -ReturnWithCommand $true # Process bulk results using GUID mapping @@ -321,13 +199,13 @@ Function Invoke-ExecModifyMBPerms { if ($result.error) { $ErrorMessage = try { (Get-CippException -Exception $result.error).NormalizedError } catch { $result.error } $null = $Results.Add("Error processing $($metadata.Permission) for $($metadata.TargetUser) on $($metadata.Mailbox): $ErrorMessage") - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter } else { $null = $Results.Add($metadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter } } else { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter # Fallback for unmapped results if ($result.error) { @@ -348,10 +226,9 @@ Function Invoke-ExecModifyMBPerms { } } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request completed successfully" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message 'Bulk request completed successfully' -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter # Fallback to individual processing for ($i = 0; $i -lt $CmdletArray.Count; $i++) { @@ -360,31 +237,28 @@ Function Invoke-ExecModifyMBPerms { try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - } - catch { + } catch { $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } } - } - else { + } else { # Use individual processing for single operation $CmdletObj = $CmdletArray[0] $CmdletMetadata = $CmdletMetadataArray[0] try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } $body = [pscustomobject]@{'Results' = @($Results) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) } diff --git a/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..f223485d6c55e --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 @@ -0,0 +1,127 @@ +# Pester tests for Invoke-ExecEditMailboxPermissions +# The endpoint now delegates every request bucket (RemoveFullAccess / AddFullAccess / +# AddFullAccessNoAutoMap / AddSendAs / RemoveSendAs / AddSendOnBehalf / RemoveSendOnBehalf) to +# Set-CIPPMailboxPermission, so these tests assert each bucket maps to the right +# PermissionLevel / Action / AutoMap. The EXO cmdlet mapping, logging, and cache sync are the +# delegate's responsibility and are covered by Set-CIPPMailboxPermission.Tests.ps1. +# +# NOTE: the early `if ($username -eq $null) { exit }` guard is deliberately NOT exercised - `exit` +# inside a dot-sourced function terminates the Pester runspace, so it cannot be tested in-process. +# Every test below supplies a userID. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecEditMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecEditMailboxPermissions.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode] (the Functions host supplies `using namespace + # System.Net`). Register a type accelerator so it resolves when the function is dot-sourced here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + # Build a request whose body carries a single bucket of delegates (mirrors the frontend's + # { value = @(...) } shape that the function reads via ($Request.body.).value). + function New-EditRequest { + param([string]$Bucket, [string[]]$Users, [string]$UserID = 'shared@contoso.com') + $body = [pscustomobject]@{ userID = $UserID; tenantfilter = 'contoso.com' } + $body | Add-Member -NotePropertyName $Bucket -NotePropertyValue ([pscustomobject]@{ value = $Users }) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEditMailboxPermissions' } + Headers = @{ Authorization = 'token' } + Body = $body + } + } +} + +Describe 'Invoke-ExecEditMailboxPermissions' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "$Action $PermissionLevel for $AccessUser" } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns OK and passes the mailbox UPN through as the identity' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' -and $TenantFilter -eq 'contoso.com' } + } + + It 'RemoveFullAccess -> FullAccess / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Remove' -and $AccessUser -eq 'user@contoso.com' + } + } + + It 'AddFullAccess -> FullAccess / Add with AutoMap $true' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $true + } + } + + It 'AddFullAccessNoAutoMap -> FullAccess / Add with AutoMap $false' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccessNoAutoMap' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $false + } + } + + It 'AddSendAs -> SendAs / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + } + + It 'RemoveSendAs -> SendAs / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Remove' } + } + + It 'AddSendOnBehalf -> SendOnBehalf / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Add' } + } + + It 'RemoveSendOnBehalf -> SendOnBehalf / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Remove' } + } + + It 'processes every user in a multi-user bucket and collects their results' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('a@contoso.com', 'b@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Contain 'Add SendAs for a@contoso.com' + $response.Body.Results | Should -Contain 'Add SendAs for b@contoso.com' + } + + It 'surfaces the delegate failure string in Results and still returns OK' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' } + + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($response.Body.Results -join "`n") | Should -Match 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + } +} diff --git a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 new file mode 100644 index 0000000000000..1bb74948d83a5 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 @@ -0,0 +1,278 @@ +# Pester tests for Invoke-ExecModifyMBPerms +# The endpoint delegates the permission-level -> EXO cmdlet/parameter mapping to +# Set-CIPPMailboxPermission (dot-sourced below and called in -AsCmdletObject mode), so these tests +# focus on what the endpoint owns: the single-operation vs bulk (New-ExoBulkRequest + GUID mapping) +# execution paths, the bulk-failure fallback, the three accepted input shapes, the user-lookup +# fallback, and the request guards. The mapping itself is covered by Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecModifyMBPerms.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecModifyMBPerms.ps1 under Modules/' } + + # Dot-source the REAL Set-CIPPMailboxPermission so -AsCmdletObject produces real mappings - the + # endpoint now delegates the level -> cmdlet mapping to it. Its -AsCmdletObject path only builds a + # hashtable and returns early, so none of the EXO / log stubs below are exercised by it. + $PermissionFunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $PermissionFunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode]; register a type accelerator so it resolves here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $ReturnWithCommand) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $PermissionFunctionPath + . $FunctionPath + + # Build a request in the bulk 'mailboxRequests' shape. + function New-ModifyRequest { + param($Mailboxes) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com'; mailboxRequests = $Mailboxes } + } + } + + # Build a single mailbox request object with one permission. + function New-Perm { + param($Level, $Modification, $TargetUser = 'user@contoso.com', $AutoMap = $true) + [pscustomobject]@{ + PermissionLevel = $Level + Modification = $Modification + AutoMap = $AutoMap + UserID = @([pscustomobject]@{ value = $TargetUser }) + } + } + function New-Mailbox { + param($UserID = 'shared@contoso.com', $Permissions) + [pscustomobject]@{ userID = $UserID; permissions = $Permissions } + } +} + +Describe 'Invoke-ExecModifyMBPerms' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ userPrincipalName = 'shared@contoso.com' } } + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + # Echo each operation back as a success keyed by cmdlet name (GUID round-trips so the + # function can map results to its metadata). + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid } + } + $h + } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'Single-operation execution and per-level mapping' { + It 'FullAccess Add -> individual New-ExoRequest with Add-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.Identity -eq 'shared@contoso.com' -and $cmdParams.automapping -eq $true + } + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'FullAccess Remove -> Remove-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' } + $response.Body.Results | Should -Contain 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'SendAs Add -> Add-RecipientPermission with Trustee' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' } + } + + It 'SendOnBehalf Add -> Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.add -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + } + + It 'SendOnBehalf Remove -> Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' } + } + + It 'default-level Remove () -> Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level $Level -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' -and $cmdParams.accessRights -contains $Level } + } + + It 'default-level Add produces no cmdlet -> OK with a no-op message' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'ReadPermission' -Modification 'Add')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'No valid permission changes to process' + } + } + + Context 'Bulk execution path' { + It 'two operations -> New-ExoBulkRequest with GUID-mapped results, no individual calls' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + $response.Body.Results | Should -Contain 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps a per-operation error from the bulk result to an error string' { + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid; error = 'kaboom' } + } + $h + } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Error processing FullAccess for user@contoso.com on shared@contoso.com: boom' + } + + It 'falls back to individual New-ExoRequest calls when the bulk request throws' { + Mock -CommandName New-ExoBulkRequest -MockWith { throw 'bulk endpoint down' } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 2 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + } + + Context 'Input shapes' { + It 'accepts the legacy single-mailbox format (userID + permissions on the body)' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.com' + userID = 'shared@contoso.com' + permissions = @(New-Perm -Level 'FullAccess' -Modification 'Add') + } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + } + + Context 'User lookup' { + It 'falls back to a userPrincipalName filter query when the direct Graph lookup fails' { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ value = @([pscustomobject]@{ userPrincipalName = 'shared@contoso.com' }) } } -ParameterFilter { $uri -like '*filter*' } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'direct lookup failed' } + + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*filter*' } + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + + It 'records a "could not find user" message when both lookups fail' { + # Graph fails for 'ghost' on both the direct and filter lookups; a second valid mailbox + # keeps CmdletArray non-empty so the specific message is not discarded by the + # "No valid permission changes" guard (which returns only a generic string). + Mock -CommandName New-GraphGetRequest -MockWith { throw 'not found' } -ParameterFilter { $uri -like '*ghost@contoso.com*' } + + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID 'ghost@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Could not find user ghost@contoso.com' + } + } + + Context 'Guards' { + It 'returns BadRequest when no mailbox requests are provided' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com' } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Contain 'No mailbox requests provided' + } + + It 'skips a mailbox request that is missing its userID' { + # A second valid mailbox keeps CmdletArray non-empty so the "Skipped" message survives + # (the empty-array guard would otherwise replace Results with a generic string). + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID '' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly + ($response.Body.Results -join "`n") | Should -Match 'Skipped mailbox with missing userID' + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 new file mode 100644 index 0000000000000..d837ba181dabe --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester tests for Set-CIPPMailboxAccess +# Set-CIPPMailboxAccess now delegates each grant to Set-CIPPMailboxPermission (FullAccess / Add), so +# these tests cover the per-user fan-out, extraction of frontend objects with a .value property, +# AutoMap pass-through, and that one user's failure does not stop the rest (the delegate returns an +# error string rather than throwing). The EXO cmdlet mapping itself is covered by +# Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxAccess.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxAccess.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxAccess' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "Granted $AccessUser FullAccess to $UserId with automapping $AutoMap" } + } + + It 'delegates a single user to Set-CIPPMailboxPermission as a FullAccess Add' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $result | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'processes an array of users, one delegate call per user' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('a@contoso.com', 'b@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'extracts the .value property from frontend objects' { + $accessUsers = @([pscustomobject]@{ value = 'picked@contoso.com'; label = 'Picked User' }) + + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser $accessUsers ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AccessUser -eq 'picked@contoso.com' } + } + + It 'passes AutoMap through to the delegate when disabled' { + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $false -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $false } + } + + It 'continues to the next user when one user returns a failure string' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { + if ($AccessUser -eq 'bad@contoso.com') { + 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + } else { + "Granted $AccessUser FullAccess to shared@contoso.com with automapping True" + } + } + + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('bad@contoso.com', 'good@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + ($result -join "`n") | Should -Match 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + ($result -join "`n") | Should -Match 'Granted good@contoso.com FullAccess to shared@contoso.com' + } +} From 877a4668df5d13e3aa5ffb8367b50c5688ebd5b4 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:54:07 +0200 Subject: [PATCH 148/150] test: add mailbox permission coverage Pester coverage for Set-CIPPMailboxPermission, Set-CIPPMailboxVacation and Remove-CIPPMailboxPermissions: permission-level to EXO cmdlet mapping, cache sync, Add/Remove actions and error paths. Tests dot-source the function under test and stub CIPP helpers, per house style. --- .../Remove-CIPPMailboxPermissions.Tests.ps1 | 147 ++++++++++++++ .../Set-CIPPMailboxPermission.Tests.ps1 | 185 ++++++++++++++++++ .../Private/Set-CIPPMailboxVacation.Tests.ps1 | 132 +++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 diff --git a/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..cb6a7508c0dfa --- /dev/null +++ b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 @@ -0,0 +1,147 @@ +# Pester tests for Remove-CIPPMailboxPermissions +# Covers the per-level single-mailbox removal branches (SendOnBehalf -> Set-Mailbox + GrantSendonBehalfTo, +# SendAs -> Remove-RecipientPermission, FullAccess -> Remove-MailboxPermission), cache-sync gating, +# the "already removed" message selection, the -UseCache report-driven path, and the error path. +# +# NOTE: the 'AllUsers' branch uses ForEach-Object -Parallel, which runs each iteration in a separate +# runspace that re-imports the real CIPPCore/AzBobbyTables modules. Pester mocks live in the test +# runspace and cannot cross that boundary, so that branch is intentionally NOT unit-tested here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Remove-CIPPMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPMailboxPermissions.ps1 under Modules/' } + + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams, $Select) } + function Get-CippException { param($Exception) } + function Get-CIPPMailboxPermissionReport { param($TenantFilter, [switch]$ByUser) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Remove-CIPPMailboxPermissions' { + BeforeEach { + # A successful EXO removal returns a truthy response that does NOT contain an error substring. + # (The branches use `$result -notlike '*error*'`; note that with a $null response, + # $null -notlike '' is $false, which would route to the "already removed" message.) + Mock -CommandName New-ExoRequest -MockWith { 'OK' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'Single-mailbox removal branches' { + It 'removes SendOnBehalf via Set-Mailbox with a GrantSendonBehalfTo remove hashtable' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and + $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + $result | Should -Match "Removed SendOnBehalf permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'removes SendAs via Remove-RecipientPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq 'SendAs' -and $Action -eq 'Remove' + } + $result | Should -Match "Removed SendAs permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports SendAs as already-removed when EXO says the ACE is not present' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove the ACL because the ACE isn't present" } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + # cache is still synced regardless of whether the permission existed + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'SendAs' } + $result | Should -Match "were already removed or don't exist" + } + + It 'removes FullAccess via Remove-MailboxPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-MailboxPermission' -and + $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.accessRights -contains 'FullAccess' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + $result | Should -Match "Removed FullAccess permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports FullAccess as already-removed when EXO says the ACE does not exist' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove because the ACE doesn't exist on the object." } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Match "were already removed or don't exist" + } + + It 'processes multiple permission levels in one call' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess', 'SendAs', 'SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 3 -Exactly + $result.Count | Should -Be 3 + } + } + + Context '-UseCache path' { + It 'removes every cached permission for the user via recursion and returns per-mailbox results' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { + [pscustomobject]@{ + User = 'user@contoso.com' + MailboxCount = 2 + Permissions = @( + [pscustomobject]@{ MailboxUPN = 'sharedA@contoso.com'; AccessRights = 'FullAccess' } + [pscustomobject]@{ MailboxUPN = 'sharedB@contoso.com'; AccessRights = 'SendAs' } + ) + } + } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke Get-CIPPMailboxPermissionReport -Times 1 -Exactly + # one EXO call per recursive removal (FullAccess + SendAs) + Should -Invoke New-ExoRequest -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'returns an informational message when no cached permissions exist' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke New-ExoRequest -Times 0 -Exactly + $result | Should -Be 'No mailbox permissions found for user@contoso.com in cached data' + } + } + + Context 'Error path' { + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Be 'Could not remove mailbox permissions for shared@contoso.com. Error: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 new file mode 100644 index 0000000000000..761057d51287c --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 @@ -0,0 +1,185 @@ +# Pester tests for Set-CIPPMailboxPermission +# Covers the permission-level -> EXO cmdlet/parameter mapping (via -AsCmdletObject, no execution), +# the execute path (New-ExoRequest + logging), cache-sync gating, and the error path. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function Get-CippException { param($Exception) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxPermission' { + + Context '-AsCmdletObject mapping matrix (no execution)' { + + It 'maps FullAccess Add to Add-MailboxPermission with automapping and InheritanceType' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $true -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-MailboxPermission' + $result.Parameters.Identity | Should -Be 'shared@contoso.com' + $result.Parameters.user | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.automapping | Should -BeTrue + $result.Parameters.InheritanceType | Should -Be 'all' + $result.Parameters.Confirm | Should -BeFalse + $result.ExpectedResult | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'passes automapping through as $false when AutoMap is disabled' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $false -TenantFilter 'contoso.com' -AsCmdletObject + + $result.Parameters.automapping | Should -BeFalse + $result.ExpectedResult | Should -Match 'automapping False' + } + + It 'maps FullAccess Remove to Remove-MailboxPermission' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.Keys | Should -Not -Contain 'automapping' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'maps SendAs Add to Add-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('SendAs') + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps SendAs Remove to Remove-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendAs permissions from shared@contoso.com' + } + + It 'maps SendOnBehalf Add to Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo['@odata.type'] | Should -Be '#Exchange.GenericHashTable' + $result.Parameters.GrantSendonBehalfTo.add | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'remove' + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendOnBehalf permissions to shared@contoso.com' + } + + It 'maps SendOnBehalf Remove to Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo.remove | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'add' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendOnBehalf permissions from shared@contoso.com' + } + + It 'maps default-level Remove () to Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @($Level) + $result.ExpectedResult | Should -Be "Removed user@contoso.com $Level from shared@contoso.com" + } + + It 'returns an unsupported-action string for default-level Add ()' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result | Should -Be "Add action is not supported for $Level" + } + } + + Context 'Execute path' { + BeforeEach { + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + It 'invokes New-ExoRequest with the mapped cmdlet/params anchored on the mailbox and returns the result string' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and + $Anchor -eq 'shared@contoso.com' -and + $tenantid -eq 'contoso.com' -and + $cmdParams.user -eq 'user@contoso.com' + } + $result | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'logs an Info message on success' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Info' } + } + + It 'syncs the cache for cached permission types ()' -ForEach @( + @{ Level = 'FullAccess' } + @{ Level = 'SendAs' } + @{ Level = 'SendOnBehalf' } + ) { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq $Level -and $Action -eq 'Add' + } + } + + It 'does not sync the cache for non-cached permission types' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'ReadPermission' -Action 'Remove' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + $result | Should -Be 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 new file mode 100644 index 0000000000000..f1aeeacced3ce --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 @@ -0,0 +1,132 @@ +# Pester tests for Set-CIPPMailboxVacation +# Covers the mailbox-permission loop (delegating to Set-CIPPMailboxPermission), the calendar-permission +# loop (Set-CIPPCalendarPermission), hashtable vs PSCustomObject entry access, missing-field skips, +# Action propagation, and the calendar error path. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxVacation.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxVacation.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Set-CIPPCalendarPermission { param($TenantFilter, $UserID, $FolderName, $APIName, $Headers, $RemoveAccess, $UserToGetPermissions, $Permissions, $CanViewPrivateItems) } + function Get-CippException { param($Exception) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxVacation' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'mailbox-perm-result' } + Mock -CommandName Set-CIPPCalendarPermission -MockWith { 'calendar-perm-result' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + Context 'Mailbox permissions' { + It 'forwards each mailbox permission to Set-CIPPMailboxPermission with the requested Action' { + $perms = @( + [pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess'; AutoMap = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $results | Should -Contain 'mailbox-perm-result' + } + + It 'propagates the Remove action to the delegate cmdlet' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'SendAs' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $Action -eq 'Remove' } + } + + It 'accepts hashtable entries as well as PSCustomObject entries' { + $perms = @(@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' } + } + + It 'defaults AutoMap to $true when not supplied' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $true } + } + + It 'skips entries with missing required fields and records a skip message' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; PermissionLevel = 'FullAccess' }) # no AccessUser + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped mailbox permission with missing fields' + } + } + + Context 'Calendar permissions' { + It 'forwards Add calendar permissions with delegate, permissions and private-items flag' { + $cal = @( + [pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com'; FolderName = 'Calendar'; Permissions = 'Editor'; CanViewPrivateItems = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $UserID -eq 'shared@contoso.com' -and + $UserToGetPermissions -eq 'user@contoso.com' -and + $Permissions -eq 'Editor' -and + $CanViewPrivateItems -eq $true + } + $results | Should -Contain 'calendar-perm-result' + } + + It 'uses RemoveAccess when the action is Remove' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $RemoveAccess -eq 'user@contoso.com' + } + } + + It 'defaults the calendar folder name to Calendar' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { $FolderName -eq 'Calendar' } + } + + It 'skips calendar entries with missing required fields' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com' }) # no delegate + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped calendar permission with missing fields' + } + + It 'records a failure message when the calendar permission throws' { + Mock -CommandName Set-CIPPCalendarPermission -MockWith { throw 'cal down' } + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + $results | Should -Match 'Failed calendar permission for user@contoso.com on shared@contoso.com: boom' + } + } +} From dab2d8f75c051744842ce31f673f71724ccd8738 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:54:42 +0200 Subject: [PATCH 149/150] feat(tests): add test runner and scaffolder Invoke-CippTests.ps1 runs the Pester suite with optional -CI (NUnit results) and -Coverage output; New-CippTest.ps1 scaffolds a runnable test skeleton for any function (helper stubs, fake HTTP contexts, move-resilient path resolution) to lower the barrier to adding backend tests. Ignore the runner's result/coverage artifacts. --- .gitignore | 4 + Tests/Invoke-CippTests.ps1 | 101 ++++++++++++++ Tests/New-CippTest.ps1 | 263 +++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 Tests/Invoke-CippTests.ps1 create mode 100644 Tests/New-CippTest.ps1 diff --git a/.gitignore b/.gitignore index 4f4c7911d7505..326a9b52c6735 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Shared/CIPPSharp/obj/ !/profile.ps1 .DS_Store proxyman.pem + +# Pester test runner artifacts (Invoke-CippTests.ps1 -CI / -Coverage) +Tests/TestResults.xml +Tests/coverage.xml diff --git a/Tests/Invoke-CippTests.ps1 b/Tests/Invoke-CippTests.ps1 new file mode 100644 index 0000000000000..e6c672ee710f6 --- /dev/null +++ b/Tests/Invoke-CippTests.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs the CIPP-API Pester test suite. + +.DESCRIPTION + Thin, opinionated wrapper around Pester 5 so the backend tests can be run with a + single command regardless of the caller's current directory. Pester only discovers + files named '*.Tests.ps1', so the non-Pester helper scripts that also live under + Tests/ (Test-ODataFilterInjection.ps1, Test-SchedulerBlocklist.ps1) are ignored + automatically. + +.PARAMETER Path + Limit the run to a specific test file or folder (relative to the repo root or absolute). + Defaults to the entire Tests/ directory. + +.PARAMETER Tag + Only run It/Describe blocks carrying one of these Pester tags. + +.PARAMETER CI + Emit a NUnit result file (Tests/TestResults.xml). Use this from automation / GitHub Actions. + Note: a non-zero exit on failure is the DEFAULT (not gated on -CI) - see -NoExitCode. + +.PARAMETER NoExitCode + Do not set the process exit code from the test result. Use this in an interactive session + where a failing run should not terminate your shell. By default the script exits with the + failed-test count so scripts/agents relying on process status see red as red. + +.PARAMETER Coverage + Also collect code coverage over Modules/**/Public and write Tests/coverage.xml. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 + Runs the whole suite with detailed console output. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 -Path Tests/Endpoint -CI + Runs only the Endpoint tests and produces a CI result file + exit code. +#> +[CmdletBinding()] +param( + [string[]]$Path, + [string[]]$Tag, + [switch]$CI, + [switch]$NoExitCode, + [switch]$Coverage +) + +$ErrorActionPreference = 'Stop' + +# Tests/ is one level under the repo root. +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot + +# Pester 5 is required for the Should -Invoke / -ParameterFilter syntax the suite uses. +$pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0.0' } | Sort-Object Version -Descending | Select-Object -First 1 +if (-not $pester) { + throw "Pester 5+ is required but was not found. Install it with: Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser" +} +Import-Module $pester -ErrorAction Stop + +# Resolve -Path entries against the repo root when they are not already absolute, +# so callers can pass repo-relative paths like 'Tests/Endpoint' from anywhere. +$runPaths = if ($Path) { + foreach ($p in $Path) { + if ([System.IO.Path]::IsPathRooted($p)) { $p } + elseif (Test-Path -LiteralPath (Join-Path $RepoRoot $p)) { Join-Path $RepoRoot $p } + else { $p } + } +} else { + $TestsRoot +} + +$config = New-PesterConfiguration +$config.Run.Path = $runPaths +$config.Run.PassThru = $true +$config.Output.Verbosity = 'Detailed' + +if ($Tag) { + $config.Filter.Tag = $Tag +} + +if ($CI) { + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = Join-Path $TestsRoot 'TestResults.xml' +} + +if ($Coverage) { + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = Join-Path $RepoRoot 'Modules' + $config.CodeCoverage.OutputPath = Join-Path $TestsRoot 'coverage.xml' +} + +$result = Invoke-Pester -Configuration $config + +# Surface a real exit code by default so agents / pipelines that key off process status +# see a red suite as red. -NoExitCode opts out for interactive shells. +if (-not $NoExitCode) { + exit $result.FailedCount +} diff --git a/Tests/New-CippTest.ps1 b/Tests/New-CippTest.ps1 new file mode 100644 index 0000000000000..cdfdb80cfe377 --- /dev/null +++ b/Tests/New-CippTest.ps1 @@ -0,0 +1,263 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Scaffolds a Pester test for a CIPP-API backend function. + +.DESCRIPTION + Locates a function by name under Modules/, parses it with the PowerShell AST, and + emits a starter .Tests.ps1 under Tests// that already contains: + * a move-resilient path resolver (find the function by filename, never hardcode a module), + * stub functions for every CIPP helper the function calls (so Pester Mock can replace them), + * fake HttpResponseContext / HttpRequestContext classes for HTTP endpoints, + * a Describe block with placeholder It cases (happy path + one per required field), + each marked with # TODO where a human or Claude fills in real assertions. + + It deliberately does NOT try to write meaningful assertions - that requires reading the + function's intent. The goal is to remove the ~30 lines of boilerplate and dependency + guesswork, then hand a runnable skeleton to the author. + +.PARAMETER FunctionName + The function to scaffold a test for, e.g. Invoke-ListIntuneReusableSettings. + +.PARAMETER Force + Overwrite an existing test file. + +.PARAMETER Area + Override the auto-detected Tests/ subfolder (Endpoint, Standards, Alerts, Private). + +.EXAMPLE + pwsh CIPP-API/Tests/New-CippTest.ps1 -FunctionName Invoke-ListIntuneReusableSettings +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$FunctionName, + + [switch]$Force, + + [ValidateSet('Endpoint', 'Standards', 'Alerts', 'Private')] + [string]$Area +) + +$ErrorActionPreference = 'Stop' + +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot +$ModulesRoot = Join-Path $RepoRoot 'Modules' + +# --- 1. Locate the function file by name (this is what avoids hardcoded, rot-prone paths) --- +$matches = @(Get-ChildItem -Path $ModulesRoot -Recurse -Filter "$FunctionName.ps1" -File -ErrorAction SilentlyContinue) +if ($matches.Count -eq 0) { + throw "No file named '$FunctionName.ps1' found under $ModulesRoot. Check the function name." +} +if ($matches.Count -gt 1) { + $list = ($matches.FullName | ForEach-Object { " $_" }) -join "`n" + throw "Multiple files named '$FunctionName.ps1' found - cannot disambiguate:`n$list" +} +$FunctionFile = $matches[0].FullName + +# --- 2. Parse with the AST --- +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($FunctionFile, [ref]$tokens, [ref]$parseErrors) + +$funcAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $FunctionName + }, $true) +if (-not $funcAst) { + throw "File '$FunctionFile' does not define a function called '$FunctionName'." +} + +# Endpoint detection: HTTP entrypoints take $Request and $TriggerMetadata. +$paramNames = @() +if ($funcAst.Body.ParamBlock) { + $paramNames = $funcAst.Body.ParamBlock.Parameters.Name.VariablePath.UserPath +} +$isEndpoint = ($paramNames -contains 'Request' -and $paramNames -contains 'TriggerMetadata') + +# --- 3. Discover called CIPP helpers (to emit as stubbable functions) --- +# Only stub commands that look like CIPP helpers; never stub PowerShell built-ins. +$helperPattern = '(?i)(cipp|graph.*request|azdatatable|write-logmessage|write-alert|write-standardsalert|get-normalizederror)' +$commandAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.CommandAst] + }, $true) +$helpers = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($c in $commandAsts) { + $name = $c.GetCommandName() + if ($name -and $name -ne $FunctionName -and $name -match $helperPattern) { + [void]$helpers.Add($name) + } +} + +# --- 4. Discover input fields read from $Request.Body.* / $Request.Query.* --- +# Track which container each field comes from so the happy-path request seeds the right bag. +$bodyFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$queryFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if ($isEndpoint) { + $memberAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.MemberExpressionAst] + }, $true) + foreach ($m in $memberAsts) { + $inner = $m.Expression + if ($inner -is [System.Management.Automation.Language.MemberExpressionAst] -and + $inner.Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and + $inner.Expression.VariablePath.UserPath -eq 'Request' -and + $inner.Member.Value -in @('Body', 'Query') -and + $m.Member -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $field = $m.Member.Value + if ($inner.Member.Value -eq 'Body') { [void]$bodyFields.Add($field) } + else { [void]$queryFields.Add($field) } + } + } +} +$usesBody = $bodyFields.Count -gt 0 +$usesQuery = $queryFields.Count -gt 0 + +# --- 5. Discover required-field guards from ' is required' literals --- +$requiredFields = [System.Collections.Generic.List[string]]::new() +$strings = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true) +foreach ($s in $strings) { + if ($s.Value -match '^(\w+)\s+is required') { + if (-not $requiredFields.Contains($Matches[1])) { $requiredFields.Add($Matches[1]) } + } +} + +# --- 6. Determine the Area (Tests/ subfolder) --- +if (-not $Area) { + $rel = $FunctionFile.Substring($ModulesRoot.Length).Replace('\', '/') + $Area = switch -Regex ($rel) { + 'Entrypoints/HTTP Functions' { 'Endpoint'; break } + '/Standards/' { 'Standards'; break } + '/Alerts/' { 'Alerts'; break } + default { 'Private' } + } +} +$OutDir = Join-Path $TestsRoot $Area +$OutFile = Join-Path $OutDir "$FunctionName.Tests.ps1" +if ((Test-Path $OutFile) -and -not $Force) { + throw "Test already exists: $OutFile (use -Force to overwrite)." +} + +# --- 7. Build the test file content --- +$nl = "`n" +$sb = [System.Text.StringBuilder]::new() +[void]$sb.Append("# Pester tests for $FunctionName$nl") +[void]$sb.Append("# Scaffolded by New-CippTest.ps1 - replace every # TODO with real assertions.$nl$nl") +[void]$sb.Append("BeforeAll {$nl") +[void]$sb.Append(" # Resolve by name under Modules/ so the test survives the function moving between modules.$nl") +[void]$sb.Append(" `$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent `$PSCommandPath))$nl") +[void]$sb.Append(" `$FunctionPath = Get-ChildItem -Path (Join-Path `$RepoRoot 'Modules') -Recurse -Filter '$FunctionName.ps1' -File -ErrorAction SilentlyContinue |$nl") +[void]$sb.Append(" Select-Object -First 1 -ExpandProperty FullName$nl") +[void]$sb.Append(" if (-not `$FunctionPath) { throw 'Could not locate $FunctionName.ps1 under Modules/' }$nl$nl") + +if ($isEndpoint) { + [void]$sb.Append(" # Azure Functions binding types do not exist outside the Functions host - fake them.$nl") + [void]$sb.Append(" class HttpResponseContext {$nl [int]`$StatusCode$nl [object]`$Body$nl }$nl$nl") +} + +if ($helpers.Count -gt 0) { + [void]$sb.Append(" # Stub every CIPP helper the function calls so Pester's Mock has a command to replace.$nl") + foreach ($h in $helpers) { + [void]$sb.Append(" function $h { }$nl") + } + [void]$sb.Append($nl) +} + +[void]$sb.Append(" . `$FunctionPath$nl") +[void]$sb.Append("}$nl$nl") + +[void]$sb.Append("Describe '$FunctionName' {$nl") +[void]$sb.Append(" BeforeEach {$nl") +foreach ($h in $helpers) { + [void]$sb.Append(" Mock -CommandName $h -MockWith { } # TODO: return realistic data / capture args$nl") +} +[void]$sb.Append(" }$nl$nl") + +if ($isEndpoint) { + # Render a container literal (Body/Query) seeded with the fields it actually exposes. + function Format-Container { + param([System.Collections.Generic.SortedSet[string]]$Fields) + $pairs = foreach ($f in $Fields) { + if ($f -ieq 'tenantFilter') { "$f = 'contoso.onmicrosoft.com'" } else { "$f = 'TODO'" } + } + '[pscustomobject]@{ ' + ($pairs -join '; ') + ' }' + } + + # Happy-path case with a sample request built from discovered input fields. + [void]$sb.Append(" It 'returns OK on the happy path' {$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{$nl") + [void]$sb.Append(" Params = @{ CIPPEndpoint = '$($FunctionName -replace '^Invoke-', '')' }$nl") + [void]$sb.Append(" Headers = @{ Authorization = 'token' }$nl") + if ($usesBody) { + [void]$sb.Append(" Body = $(Format-Container $bodyFields)$nl") + } + if ($usesQuery) { + [void]$sb.Append(" Query = $(Format-Container $queryFields)$nl") + } + [void]$sb.Append(" }$nl$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)$nl") + [void]$sb.Append(" # TODO: assert `$response.Body and Should -Invoke the helpers with -ParameterFilter$nl") + [void]$sb.Append(" }$nl") + + # Negative cases: endpoints validate required fields in order, so an all-empty request + # would always trip the FIRST guard. Instead start from a baseline that satisfies every + # guard, then drop ONLY the field under test so its specific guard is the one that fires. + function Get-FieldContainer { + param([string]$Field) + if ($bodyFields.Contains($Field)) { 'Body' } elseif ($queryFields.Contains($Field)) { 'Query' } else { 'Body' } + } + function Format-RequiredRequest { + param([string]$Omit) + $bodyPairs = [System.Collections.Generic.List[string]]::new() + $queryPairs = [System.Collections.Generic.List[string]]::new() + foreach ($f in $requiredFields) { + if ($f -ieq $Omit) { continue } + $val = if ($f -ieq 'tenantFilter') { "'contoso.onmicrosoft.com'" } else { "'TODO'" } + if ((Get-FieldContainer $f) -eq 'Body') { $bodyPairs.Add("$f = $val") } else { $queryPairs.Add("$f = $val") } + } + $b = '[pscustomobject]@{' + $(if ($bodyPairs.Count) { ' ' + ($bodyPairs -join '; ') + ' ' } else { '' }) + '}' + $q = '[pscustomobject]@{' + $(if ($queryPairs.Count) { ' ' + ($queryPairs -join '; ') + ' ' } else { '' }) + '}' + "Body = $b ; Query = $q" + } + + foreach ($field in $requiredFields) { + [void]$sb.Append($nl) + [void]$sb.Append(" It 'returns BadRequest when $field is missing' {$nl") + [void]$sb.Append(" # Baseline has every other required field populated; only $field is dropped.$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{ $(Format-RequiredRequest -Omit $field) }$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest)$nl") + [void]$sb.Append(" `$response.Body.Results | Should -Match '$field is required'$nl") + [void]$sb.Append(" }$nl") + } +} else { + # Non-endpoint function: emit a single placeholder invocation case. + $callParams = ($paramNames | ForEach-Object { "-$_ `$null" }) -join ' ' + [void]$sb.Append(" It 'does the expected thing' {$nl") + [void]$sb.Append(" # TODO: call with realistic arguments and assert behaviour$nl") + [void]$sb.Append(" # $FunctionName $callParams$nl") + [void]$sb.Append(" `$true | Should -BeTrue # TODO: replace$nl") + [void]$sb.Append(" }$nl") +} + +[void]$sb.Append("}$nl") + +# --- 8. Write it out --- +if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } +Set-Content -Path $OutFile -Value $sb.ToString() -Encoding utf8 -NoNewline + +Write-Host "Scaffolded: $OutFile" -ForegroundColor Green +Write-Host " Function : $FunctionFile" +Write-Host " Area : $Area | Endpoint: $isEndpoint" +Write-Host " Helpers : $(if ($helpers.Count) { $helpers -join ', ' } else { '(none detected)' })" +if ($isEndpoint) { + Write-Host " Body : $(if ($bodyFields.Count) { $bodyFields -join ', ' } else { '(none)' })" + Write-Host " Query : $(if ($queryFields.Count) { $queryFields -join ', ' } else { '(none)' })" + Write-Host " Required : $(if ($requiredFields.Count) { $requiredFields -join ', ' } else { '(none)' })" +} +Write-Host "Next: fill in the # TODOs, then run:" -ForegroundColor Cyan +Write-Host " pwsh $($MyInvocation.MyCommand.Path -replace 'New-CippTest','Invoke-CippTests') -Path `"$([System.IO.Path]::GetRelativePath($RepoRoot, $OutFile))`"" From 2df58194ecc51797f1e4751973e128239908a335 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:46:44 +0200 Subject: [PATCH 150/150] MSP App improvements --- .../Public/Get-CIPPMSPAppInstallCommand.ps1 | 113 ++++++++++++++++++ .../Public/New-CIPPIntuneAppDeployment.ps1 | 23 ++++ .../Applications/Invoke-AddMSPApp.ps1 | 58 ++------- 3 files changed, 144 insertions(+), 50 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 new file mode 100644 index 0000000000000..86da343f15a58 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 @@ -0,0 +1,113 @@ +function Get-CIPPMSPAppInstallCommand { + <# + .SYNOPSIS + Builds the install/uninstall command lines for an MSP RMM app for a single tenant. + .DESCRIPTION + Shared by Invoke-AddMSPApp (manual/queue deploy) and New-CIPPIntuneAppDeployment + (the 'Deploy Intune Application Template' standard). Each parameter value may be: + - a flat string, optionally containing %CIPP variables% that resolve per-tenant + (Application Template shape), or + - an object keyed by tenant customerId (legacy per-tenant deploy shape). + Values are resolved for the tenant, run through Get-CIPPTextReplacement so any + %variables% are substituted with the tenant's value, then escaped with + ConvertTo-CIPPSafePwshArg before being placed on the command line. + .PARAMETER RmmName + The MSP tool identifier (datto, syncro, Huntress, automate, cwcommand, ninja, NCentral). + .PARAMETER Params + The params object from the app config / request body. + .PARAMETER Tenant + The tenant object, requires customerId and defaultDomainName. + .PARAMETER PackageName + Package name for ninja/NCentral installs (not stored under params). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$RmmName, + + $Params, + + [Parameter(Mandatory = $true)] + $Tenant, + + [string]$PackageName + ) + + $InstallParams = [PSCustomObject]$Params + + # Resolve a raw parameter value for this tenant: pick the per-tenant keyed value when the + # value is an object (legacy shape), otherwise use it as-is (template shape), then replace + # any %CIPP variables% using the tenant context. Returns the raw (unescaped) string. + function Resolve-MSPValue { + param($Value) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { + $Resolved = $Value + } elseif ($Value -is [System.Collections.IDictionary]) { + $Resolved = [string]$Value[$Tenant.customerId] + } elseif ($Value -is [pscustomobject]) { + $Resolved = [string]$Value.$($Tenant.customerId) + } else { + $Resolved = [string]$Value + } + if ($Resolved -match '%') { + $Resolved = Get-CIPPTextReplacement -TenantFilter $Tenant.defaultDomainName -Text $Resolved + } + return $Resolved + } + + $DetectionScriptContent = $null + + switch ($RmmName) { + 'datto' { + $DattoUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoURL) + $DattoGuid = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoGUID) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'ninja' { + $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'Huntress' { + $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.Orgkey) + $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.AccountKey) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' + } + 'syncro' { + $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'NCentral' { + $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'automate' { + $ServerRaw = Resolve-MSPValue $InstallParams.Server + $AutomateServer = ConvertTo-CIPPSafePwshArg -Value $ServerRaw + $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.InstallerToken) + $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.LocationID) + $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" + $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" + $DetectionScriptContent = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $ServerRaw + } + 'cwcommand' { + $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + default { + throw "Unknown MSP app type '$RmmName'" + } + } + + return [PSCustomObject]@{ + InstallCommandLine = $installCommandLine + UninstallCommandLine = $uninstallCommandLine + DetectionScriptContent = $DetectionScriptContent + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 index 2b7f3dfac5ff9..5c6d4492ca783 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 @@ -78,6 +78,29 @@ function New-CIPPIntuneAppDeployment { } } + # Build IntuneBody from raw config if not pre-built (template/standard path). MSP apps store + # only the vendor + params in the template, so build the install command here using the shared + # helper, which resolves %CIPP variables% in the params per-tenant. + if (-not $IntuneBody -and $AppType -eq 'MSPApp') { + $MSPAppName = $AppConfig.MSPAppName ?? $AppConfig.rmmname.value ?? $AppConfig.rmmname + if ([string]::IsNullOrWhiteSpace($MSPAppName)) { + throw 'MSP app vendor (rmmname) is required for MSP app deployments but was not found in the template config.' + } + # Ensure the file-loading block below can locate the packaged app files. + $AppConfig | Add-Member -NotePropertyName 'MSPAppName' -NotePropertyValue $MSPAppName -Force + + $IntuneBody = Get-Content (Join-Path $env:CIPPRootPath "AddMSPApp\$MSPAppName.app.json") | ConvertFrom-Json + $IntuneBody.displayName = $AppConfig.Applicationname ?? $AppConfig.displayName + + $TenantObj = Get-Tenants -TenantFilter $TenantFilter + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $MSPAppName -Params $AppConfig.params -Tenant $TenantObj -PackageName $AppConfig.PackageName + $IntuneBody.installCommandLine = $CommandResult.InstallCommandLine + $IntuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $IntuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) + } + } + # Load files based on app type (only for types that need them) $Intunexml = $null $Infile = $null diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 index 2c703956fad93..cf34067491c1c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 @@ -34,57 +34,15 @@ function Invoke-AddMSPApp { $SuccessCount = 0 $ErrorCount = 0 $Results = foreach ($Tenant in $Tenants) { - $InstallParams = [PSCustomObject]$RMMApp.params - switch ($RmmName) { - 'datto' { - Write-Host 'Processing Datto installation' - $DattoUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoURL) - $DattoGuid = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoGUID."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'ninja' { - Write-Host 'Processing Ninja installation' - $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'Huntress' { - $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Orgkey."$($Tenant.customerId)") - $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.AccountKey) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' - } - 'syncro' { - $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'NCentral' { - $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'automate' { - $AutomateServer = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Server) - $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.InstallerToken."$($Tenant.customerId)") - $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.LocationID."$($Tenant.customerId)") - $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" - $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" - $DetectionScript = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $InstallParams.Server - $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($DetectionScript)) - } - 'cwcommand' { - $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - default { - throw "Unknown MSP app type '$RmmName'" - } + # Build the install/uninstall command lines for this tenant. Get-CIPPMSPAppInstallCommand + # resolves each param whether it is a per-tenant keyed value (interactive deploy) or a + # flat value / %CIPP variable% (Application Template deploy). + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $RmmName -Params $RMMApp.params -Tenant $Tenant -PackageName $RMMApp.PackageName + $intuneBody.installCommandLine = $CommandResult.InstallCommandLine + $intuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) } - $intuneBody.installCommandLine = $installCommandLine - $intuneBody.UninstallCommandLine = $uninstallCommandLine try {