(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$', '${IP}') -replace '[\[\]]', '')
+ $Text = $Address.Trim()
+ $Endpoint = $null
+ if (-not [System.Net.IPEndPoint]::TryParse($Text, [ref]$Endpoint)) { return $Text }
+ $IP = $Endpoint.Address
+ if ($IP.IsIPv4MappedToIPv6) { $IP = $IP.MapToIPv4() }
+ elseif ($IP.AddressFamily -eq 'InterNetwork' -and $Text -notmatch '^\d{1,3}(\.\d{1,3}){3}(:\d+)?$') {
+ # .NET also reads "12345" or "1.2" as IPv4; only a dotted quad is one here
+ return $Text
+ }
+ if ($IP.AddressFamily -ne 'InterNetworkV6') { return $IP.ToString() }
+ $Bytes = $IP.GetAddressBytes()
+ if ($Network) { [Array]::Clear($Bytes, 8, 8) }
+ $Canonical = [System.Net.IPAddress]::new($Bytes).ToString()
+ if ($Network) { "$Canonical/64" } else { $Canonical }
}
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1
index 8cca4f2f12758..419f8afd3ee42 100644
--- a/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1
@@ -10,8 +10,11 @@ function Get-CIPPBecAttackerActivity {
Every record is tied to an address, even when the audit log left it out or recorded a
Microsoft front-end address: its own client address, else the sign-in behind its token
(AppAccessContext.UniqueTokenId), else its Entra session (AppAccessContext.AADSessionId),
- else another record of the same mailbox session. Of the candidates, the one with the worst
- verdict wins - a record tied to an attacker token is the attacker's.
+ else another record of the same mailbox session. The most direct link with a verdict wins -
+ the record's own address over its token, its token over a shared session - and a Microsoft
+ front end (Service) never counts as the actor. Only among the addresses of one session does
+ the worst verdict win: a session also carries the user's own addresses, so it must not
+ outrank where the request itself came from.
- Mail: from the mailbox records the MailActivity search already read (no second search):
one row per message opened (internet message id + folder, named from the message trace),
per folder synced by a desktop client (the whole folder counts as taken), per item deleted,
@@ -98,9 +101,9 @@ function Get-CIPPBecAttackerActivity {
if (-not $MailboxSessions.ContainsKey([string]$AD.SessionId)) { $MailboxSessions[[string]$AD.SessionId] = [System.Collections.Generic.HashSet[string]]::new() }
$null = $MailboxSessions[[string]$AD.SessionId].Add($IP)
}
- $Worst = {
+ $Nearest = {
param($Candidates)
- @($Candidates | Where-Object { $_.IP } | Sort-Object -Property @{ Expression = { $Rank[[string]$VerdictOf[$_.IP]] ?? 9 } }, @{ Expression = { $_.Order } }) | Select-Object -First 1
+ @($Candidates | Where-Object { $_.IP } | Sort-Object -Property @{ Expression = { $_.Order } }, @{ Expression = { $Rank[[string]$VerdictOf[$_.IP]] ?? 9 } }) | Select-Object -First 1
}
$Resolve = {
param($AD)
@@ -117,7 +120,7 @@ function Get-CIPPBecAttackerActivity {
}
# a record whose own address is a Microsoft front end says nothing about the actor: prefer what it is tied to
$Useful = @($Candidates | Where-Object { $VerdictOf[$_.IP] -and $VerdictOf[$_.IP] -ne 'Service' })
- $Pick = if ($Useful.Count -gt 0) { & $Worst $Useful } else { $Candidates | Select-Object -First 1 }
+ $Pick = if ($Useful.Count -gt 0) { & $Nearest $Useful } else { $Candidates | Select-Object -First 1 }
if (-not $Pick) { return [pscustomobject]@{ IP = $null; Source = 'none'; Verdict = $null } }
[pscustomobject]@{ IP = $Pick.IP; Source = $Pick.Source; Verdict = $(if ($VerdictOf[$Pick.IP]) { $VerdictOf[$Pick.IP] } else { 'Unknown' }) }
}
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1
index b80bc98607e3f..57fb32d789a93 100644
--- a/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1
@@ -4,11 +4,14 @@ function Get-CIPPBecCorrelatedUserPeers {
Reads the sign-ins of accounts the investigator chose, to correlate the case's addresses with them.
.DESCRIPTION
For each chosen account, one Graph batch reads its interactive sign-ins (paged to the end) and
- the first page of its non-interactive ones since the baseline start. Only the case's own
+ the first page of its non-interactive ones before and during the window (one newest-first page
+ would show only the window for an active account). Only the case's own
addresses matter: an address a colleague used before the window is evidence it is an office
or shared exit; one a colleague signed in from only during the window points at the attacker
- reaching further. Returns a hashtable keyed by IP in the same shape as Get-CIPPBecIPPeers
- ({ IP, OtherUsers, OtherUsersBefore, OtherUsersInWindowOnly, Users, Sampled, Error }), which
+ reaching further. An IPv6 address matches on its /64 (a colleague on the same LAN has a
+ different address inside it). Returns a hashtable keyed by IP in the same shape as
+ Get-CIPPBecIPPeers ({ IP, Network, OtherUsers, OtherUsersBefore, OtherUsersInWindowOnly, Users,
+ Sampled, Error }), which
Invoke-CIPPBecIPAnalysis merges into the tenant-wide peers.
.PARAMETER TenantFilter
Tenant default domain name.
@@ -34,11 +37,20 @@ function Get-CIPPBecCorrelatedUserPeers {
$Result = @{}
$Users = @($UserIds | Where-Object { $_ } | Select-Object -Unique)
- $Wanted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
- foreach ($IP in @($IPs | Where-Object { $_ })) { $null = $Wanted.Add([string]$IP) }
+ # the case's addresses by match key: the host for IPv4, the /64 for IPv6
+ $Wanted = @{}
+ foreach ($IP in @($IPs | Where-Object { $_ } | Select-Object -Unique)) {
+ $Key = ConvertTo-CIPPBecHostAddress -Address ([string]$IP) -Network
+ if (-not $Key) { continue }
+ if (-not $Wanted.ContainsKey($Key)) { $Wanted[$Key] = [System.Collections.Generic.List[string]]::new() }
+ $Wanted[$Key].Add([string]$IP)
+ }
if ($Users.Count -eq 0 -or $Wanted.Count -eq 0) { return $Result }
$From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $Window = $WindowStart.ToUniversalTime()
+ $WindowText = $Window.ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $NonInteractive = "signInEventTypes/any(t: t eq 'nonInteractiveUser')"
$Select = 'ipAddress,userPrincipalName,userId,createdDateTime'
$Requests = [System.Collections.Generic.List[object]]::new()
$NoPaginate = [System.Collections.Generic.List[string]]::new()
@@ -46,24 +58,27 @@ function Get-CIPPBecCorrelatedUserPeers {
$SafeId = ConvertTo-CIPPODataFilterValue -Value ([string]$Users[$i]) -Type Guid
$Filter = "userId eq '$SafeId' and createdDateTime ge $From"
$Requests.Add(@{ id = "i$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter&`$top=999&`$select=$Select" })
- $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=999&`$select=$Select" })
+ $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=userId eq '$SafeId' and createdDateTime ge $WindowText and $NonInteractive&`$top=999&`$select=$Select" })
+ $Requests.Add(@{ id = "b$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and createdDateTime lt $WindowText and $NonInteractive&`$top=999&`$select=$Select" })
$NoPaginate.Add("n$i")
+ $NoPaginate.Add("b$i")
}
$Responses = @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true -Version 'beta' -NoPaginateIds @($NoPaginate))
- $Window = $WindowStart.ToUniversalTime()
$Before = @{}
$During = @{}
$Failed = @($Responses | Where-Object { [int]$_.status -ge 400 })
foreach ($SignIn in @($Responses | Where-Object { [int]$_.status -lt 400 } | ForEach-Object { $_.body.value })) {
if (-not $SignIn) { continue }
- $IP = ConvertTo-CIPPBecHostAddress -Address ([string]$SignIn.ipAddress)
- if (-not $IP -or -not $Wanted.Contains($IP)) { continue }
+ $Key = ConvertTo-CIPPBecHostAddress -Address ([string]$SignIn.ipAddress) -Network
+ if (-not $Key -or -not $Wanted.ContainsKey($Key)) { continue }
$Who = if ($SignIn.userPrincipalName) { [string]$SignIn.userPrincipalName } else { [string]$SignIn.userId }
$When = try { ([datetime]$SignIn.createdDateTime).ToUniversalTime() } catch { $null }
$Bucket = if ($When -and $When -lt $Window) { $Before } else { $During }
- if (-not $Bucket.ContainsKey($IP)) { $Bucket[$IP] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) }
- $null = $Bucket[$IP].Add($Who)
+ foreach ($IP in $Wanted[$Key]) {
+ if (-not $Bucket.ContainsKey($IP)) { $Bucket[$IP] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) }
+ $null = $Bucket[$IP].Add($Who)
+ }
}
foreach ($IP in @(@($Before.Keys) + @($During.Keys) | Select-Object -Unique)) {
$Earlier = if ($Before.ContainsKey($IP)) { @($Before[$IP]) } else { @() }
@@ -71,6 +86,7 @@ function Get-CIPPBecCorrelatedUserPeers {
$All = @(@($Earlier) + @($Later) | Select-Object -Unique)
$Result[$IP] = [pscustomobject]@{
IP = $IP
+ Network = $(if ($IP -match ':') { ConvertTo-CIPPBecHostAddress -Address $IP -Network } else { $null })
OtherUsers = $All.Count
OtherUsersBefore = $Earlier.Count
OtherUsersInWindowOnly = $Later.Count
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1
index 1403c741afa15..fc26e5bf7dfdf 100644
--- a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1
@@ -4,15 +4,19 @@ function Get-CIPPBecIPPeers {
Finds the other accounts in the tenant that signed in from each address.
.DESCRIPTION
For each IP, one Graph batch reads the tenant's interactive sign-ins from it (paged to the end)
- and the first page of non-interactive ones (an office egress address can carry tens of
- thousands of token refreshes; the first 999 already show whether colleagues use it). It
- answers two opposite questions:
+ and the first page of non-interactive ones both before and during the window (an office egress
+ address can carry tens of thousands of token refreshes; the first 999 of each already show who
+ uses it - read as one newest-first page, a busy address would show only the window and hide
+ the colleagues before it). It answers two opposite questions:
- Many colleagues on the address before the window: an office or VPN exit - evidence the user's.
- Other accounts on it only during the window, with no history: the attacker reaching further.
- Returns a hashtable keyed by IP: { IP, OtherUsers, OtherUsersBefore, OtherUsersInWindowOnly,
- Users[], Accounts[], Sampled, Error }. Accounts carries, per other account, its successful and
- failed sign-ins and first/last seen (the blast radius of an attacker address). The investigated
- user is excluded from every count.
+ An IPv6 address is looked up by its /64 (every device on a LAN has its own address inside the
+ LAN's /64, so colleagues never share the exact address); addresses of one /64 share a lookup.
+ Returns a hashtable keyed by IP: { IP, Network, OtherUsers, OtherUsersBefore,
+ OtherUsersInWindowOnly, Users[], Accounts[], Sampled, Error }. Network is the /64 looked up
+ (null for IPv4). Accounts carries, per other account, its successful and failed sign-ins and
+ first/last seen (the blast radius of an attacker address). The investigated user is excluded
+ from every count.
.PARAMETER TenantFilter
Tenant default domain name.
.PARAMETER UserId
@@ -39,29 +43,59 @@ function Get-CIPPBecIPPeers {
$Targets = @($IPs | Where-Object { $_ } | Select-Object -Unique)
if ($Targets.Count -eq 0) { return $Result }
+ # one lookup per IPv4 address or IPv6 /64
+ $Lookups = [ordered]@{}
+ foreach ($Target in $Targets) {
+ $IP = [string]$Target
+ $Net = ConvertTo-CIPPBecHostAddress -Address $IP -Network
+ $Prefix = $null
+ if ($Net -like '*/64') {
+ $Bytes = ([System.Net.IPAddress]::Parse(($Net -replace '/64$'))).GetAddressBytes()
+ $Prefix = (@(0..3 | ForEach-Object { '{0:x}' -f (([int]$Bytes[2 * $_] -shl 8) -bor [int]$Bytes[2 * $_ + 1]) }) -join ':') + ':'
+ # ponytail: sign-ins store the compressed form, so a /64 with a run of zero groups ("2001:db8:0:0:")
+ # can't be matched by prefix text - those fall back to the exact address
+ if ($Prefix -match '(^|:)0:0:') { $Net = $null; $Prefix = $null }
+ } else { $Net = $null }
+ $Key = if ($Net) { $Net } else { $IP }
+ if (-not $Lookups.Contains($Key)) { $Lookups[$Key] = [pscustomobject]@{ Network = $Net; Prefix = $Prefix; Address = $IP; IPs = [System.Collections.Generic.List[string]]::new() } }
+ $Lookups[$Key].IPs.Add($IP)
+ }
+
$From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
- $Select = 'userId,userPrincipalName,createdDateTime,status'
+ $Window = $WindowStart.ToUniversalTime()
+ $WindowText = $Window.ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $Select = 'userId,userPrincipalName,createdDateTime,status,ipAddress'
+ $NonInteractive = "signInEventTypes/any(t: t eq 'nonInteractiveUser')"
$Requests = [System.Collections.Generic.List[object]]::new()
$NoPaginate = [System.Collections.Generic.List[string]]::new()
- for ($i = 0; $i -lt $Targets.Count; $i++) {
- $SafeIp = ConvertTo-CIPPODataFilterValue -Value ([string]$Targets[$i]) -Type String
- $Filter = "ipAddress eq '$SafeIp' and createdDateTime ge $From"
- $Requests.Add(@{ id = "i$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter&`$top=999&`$select=$Select" })
- $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=999&`$select=$Select" })
+ $Groups = @($Lookups.Values)
+ for ($i = 0; $i -lt $Groups.Count; $i++) {
+ $Group = $Groups[$i]
+ $Match = if ($Group.Prefix) {
+ "startswith(ipAddress,'$(ConvertTo-CIPPODataFilterValue -Value $Group.Prefix -Type String)')"
+ } else {
+ "ipAddress eq '$(ConvertTo-CIPPODataFilterValue -Value $Group.Address -Type String)'"
+ }
+ $Requests.Add(@{ id = "i$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Match and createdDateTime ge $From&`$top=999&`$select=$Select" })
+ $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Match and createdDateTime ge $WindowText and $NonInteractive&`$top=999&`$select=$Select" })
$NoPaginate.Add("n$i")
+ if ($Window -gt $StartDate.ToUniversalTime()) {
+ $Requests.Add(@{ id = "b$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Match and createdDateTime ge $From and createdDateTime lt $WindowText and $NonInteractive&`$top=999&`$select=$Select" })
+ $NoPaginate.Add("b$i")
+ }
}
$Responses = @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true -Version 'beta' -NoPaginateIds @($NoPaginate))
- $Window = $WindowStart.ToUniversalTime()
- for ($i = 0; $i -lt $Targets.Count; $i++) {
- $IP = [string]$Targets[$i]
- $Parts = @($Responses | Where-Object { $_.id -in @("i$i", "n$i") })
+ for ($i = 0; $i -lt $Groups.Count; $i++) {
+ $Group = $Groups[$i]
+ $Parts = @($Responses | Where-Object { $_.id -in @("i$i", "n$i", "b$i") })
$Failed = @($Parts | Where-Object { [int]$_.status -ge 400 })
$Before = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$During = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$Accounts = @{}
foreach ($SignIn in @($Parts | Where-Object { [int]$_.status -lt 400 } | ForEach-Object { $_.body.value })) {
if (-not $SignIn -or -not $SignIn.userId -or $SignIn.userId -eq $UserId) { continue }
+ if ($Group.Network -and $SignIn.ipAddress -and (ConvertTo-CIPPBecHostAddress -Address ([string]$SignIn.ipAddress) -Network) -ne $Group.Network) { continue }
$Who = if ($SignIn.userPrincipalName) { [string]$SignIn.userPrincipalName } else { [string]$SignIn.userId }
$When = try { ([datetime]$SignIn.createdDateTime).ToUniversalTime() } catch { $null }
if ($When -and $When -lt $Window) { $null = $Before.Add($Who) } else { $null = $During.Add($Who) }
@@ -76,16 +110,19 @@ function Get-CIPPBecIPPeers {
}
}
$All = @(@($Before) + @($During) | Select-Object -Unique)
- $Result[$IP] = [pscustomobject]@{
- IP = $IP
- OtherUsers = $All.Count
- OtherUsersBefore = $Before.Count
- OtherUsersInWindowOnly = @($During | Where-Object { -not $Before.Contains($_) }).Count
- Users = @($All | Sort-Object)
- # ponytail: capped at 100 per address (an office exit can carry hundreds); the counts above stay exact
- Accounts = @($Accounts.Values | Sort-Object -Property LastSeen -Descending | Select-Object -First 100)
- Sampled = [bool](@($Parts | Where-Object { $_.id -eq "n$i" -and $_.body.'@odata.nextLink' }).Count)
- Error = if ($Failed.Count -gt 0) { [string]$Failed[0].body.error.message } else { $null }
+ foreach ($IP in $Group.IPs) {
+ $Result[$IP] = [pscustomobject]@{
+ IP = $IP
+ Network = $Group.Network
+ OtherUsers = $All.Count
+ OtherUsersBefore = $Before.Count
+ OtherUsersInWindowOnly = @($During | Where-Object { -not $Before.Contains($_) }).Count
+ Users = @($All | Sort-Object)
+ # ponytail: capped at 100 per address (an office exit can carry hundreds); the counts above stay exact
+ Accounts = @($Accounts.Values | Sort-Object -Property LastSeen -Descending | Select-Object -First 100)
+ Sampled = [bool](@($Parts | Where-Object { $_.id -in @("n$i", "b$i") -and $_.body.'@odata.nextLink' }).Count)
+ Error = if ($Failed.Count -gt 0) { [string]$Failed[0].body.error.message } else { $null }
+ }
}
}
return $Result
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1
index 9a953423fa579..5643599400622 100644
--- a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1
@@ -7,7 +7,9 @@ function Get-CIPPBecIPVerdicts {
or on an audited action gets a verdict, a score and the reasons behind it:
- Compromised / Safe: an investigator override for this case, else CIPP's IP allow/block list
(Blocked / Trusted). These decide outright.
- - Service: a Microsoft network address the user never signed in from (Exchange and other
+ - Service: an address in Microsoft 365's published service ranges (ServiceRanges - Microsoft
+ acting for the user, whether or not it shows in the user's sign-ins), a Microsoft network
+ address the user never signed in from (Exchange and other
services act from their own addresses), one whose sign-ins are all by a service
application (CIPP's own, or Microsoft's Partner Customer Delegated Administration - see
ipVerdict.serviceAppIds), or one seen only on CIPP or partner actions. An
@@ -23,8 +25,13 @@ function Get-CIPPBecIPVerdicts {
location, the address of a technician who ran or reviewed the investigation (most likely the
partner's own, so a strong start towards trusted - but still judged, since a technician's
address can be shared or wrong), or an address with only failed sign-ins (spray noise, which is also capped at
- Suspicious). A final pass lifts addresses that share an Entra or mailbox session with a
- likely-attacker address, because one session moving between addresses is one actor.
+ Suspicious). An IPv6 address counts as known to the user when its /64 was used before the
+ window: devices rotate through privacy addresses inside their /64.
+ A final pass lifts addresses that share an Entra or mailbox session with a likely-attacker
+ address, because one session moving between addresses is one actor - but never an address
+ already judged the user's or one the user used before the window: a stolen session is replayed
+ from new addresses, while the user's own device legitimately carries it between its known
+ IPv4 and IPv6 addresses.
.PARAMETER SignIns
The window's interactive sign-ins (IPAddress, Status, Country, City, ASN, RiskLevelDuringSignIn,
UserAgent, DeviceCompliant, SessionId, CreatedDateTime).
@@ -50,6 +57,8 @@ function Get-CIPPBecIPVerdicts {
Addresses of the technicians who ran or reviewed the case ({ IP, By }).
.PARAMETER CippAppId
The CIPP application id; sign-ins by it (and by ipVerdict.serviceAppIds) are service sign-ins.
+ .PARAMETER ServiceRanges
+ Microsoft 365's published service ranges (Get-CIPPMicrosoft365IPRanges), as CIDRs.
.FUNCTIONALITY
Internal
#>
@@ -66,7 +75,8 @@ function Get-CIPPBecIPVerdicts {
[string]$UsageLocation,
$Heuristics,
[object[]]$TechnicianIPs = @(),
- [string]$CippAppId = $env:ApplicationID
+ [string]$CippAppId = $env:ApplicationID,
+ [string[]]$ServiceRanges = @()
)
$Cfg = $Heuristics.ipVerdict
@@ -89,6 +99,31 @@ function Get-CIPPBecIPVerdicts {
$HostOf = { param($Value) ConvertTo-CIPPBecHostAddress -Address ([string]$Value) }
$Truthy = { param($Value) $Value -eq $true -or [string]$Value -eq 'True' }
+ # the service ranges are parsed once and matched by bytes (Test-IpInRange costs ~0.4 ms a call,
+ # and ~90 ranges are checked for every address)
+ $ServiceNets = @(foreach ($Range in @($ServiceRanges | Where-Object { $_ })) {
+ $Net, $Bits = ([string]$Range) -split '/', 2
+ $Parsed = $null
+ if (-not [System.Net.IPAddress]::TryParse($Net, [ref]$Parsed)) { continue }
+ $NetBytes = $Parsed.GetAddressBytes()
+ $Length = if ($Bits) { [int]$Bits } else { $NetBytes.Length * 8 }
+ [pscustomobject]@{ Range = [string]$Range; Bytes = $NetBytes; Whole = [int][Math]::Truncate($Length / 8); Mask = (0xFF -shl (8 - $Length % 8)) -band 0xFF; Partial = ($Length % 8) -ne 0 }
+ })
+ $ServiceRangeOf = {
+ param($IP)
+ $Parsed = $null
+ if ($ServiceNets.Count -eq 0 -or -not [System.Net.IPAddress]::TryParse([string]$IP, [ref]$Parsed)) { return $null }
+ $Bytes = $Parsed.GetAddressBytes()
+ foreach ($Net in $ServiceNets) {
+ if ($Net.Bytes.Length -ne $Bytes.Length) { continue }
+ $Match = $true
+ for ($i = 0; $i -lt $Net.Whole; $i++) { if ($Bytes[$i] -ne $Net.Bytes[$i]) { $Match = $false; break } }
+ if ($Match -and $Net.Partial -and ($Bytes[$Net.Whole] -band $Net.Mask) -ne ($Net.Bytes[$Net.Whole] -band $Net.Mask)) { $Match = $false }
+ if ($Match) { return $Net.Range }
+ }
+ return $null
+ }
+
# --- gather everything known about each address ---
$IPs = [ordered]@{}
$Touch = {
@@ -145,6 +180,20 @@ function Get-CIPPBecIPVerdicts {
$BaselineOk = $Baseline -and [int]$Baseline.Successful -ge $MinBaseline
$BaselineIPs = @{}
foreach ($Row in @($Baseline.IPs | Where-Object { $_ })) { $BaselineIPs[[string]$Row.IP] = $Row }
+ # IPv6 devices rotate through privacy addresses inside one /64 (daily or faster), so a new IPv6
+ # address in a /64 the user used before is the user's network, as a known IPv4 address is
+ $BaselineNetworks = @{}
+ foreach ($Row in @($Baseline.IPs | Where-Object { $_ })) {
+ $Net = ConvertTo-CIPPBecHostAddress -Address ([string]$Row.IP) -Network
+ if ($Net -notlike '*/64') { continue }
+ if (-not $BaselineNetworks.ContainsKey($Net)) { $BaselineNetworks[$Net] = [pscustomobject]@{ IP = $Net; SignIns = 0; Share = 0.0; Days = 0 } }
+ $Agg = $BaselineNetworks[$Net]
+ $Agg.SignIns = $Agg.SignIns + [int]$Row.SignIns
+ $Agg.Share = $Agg.Share + [double]$Row.Share
+ # ponytail: the rows carry a day count, not the days, so the busiest address's count stands in
+ # (undercounts a /64 used on different days by different addresses; the summed share carries it)
+ $Agg.Days = [Math]::Max($Agg.Days, [int]$Row.Days)
+ }
$BaselineAsns = @{}
foreach ($Row in @($Baseline.ASNs | Where-Object { $_ })) { $BaselineAsns[[string]$Row.ASN] = $Row }
$BaselineCountries = @{}
@@ -202,11 +251,16 @@ function Get-CIPPBecIPVerdicts {
if ($Entry.Scripted) { & $Add 'ScriptedClient' (& $Weight 'scriptedClient' 3) 'A sign-in used a scripting or automation user agent' }
$Known = $BaselineIPs[$IP]
+ $KnownWhat = 'address'
+ if (-not $Known) {
+ $Net = ConvertTo-CIPPBecHostAddress -Address $IP -Network
+ if ($Net -like '*/64' -and $BaselineNetworks.ContainsKey($Net)) { $Known = $BaselineNetworks[$Net]; $KnownWhat = "IPv6 network ($Net)" }
+ }
if ($Known) {
if ([double]$Known.Share -ge $RegularShare -or [int]$Known.Days -ge $RegularDays) {
- & $Add 'BaselineRegular' (& $Weight 'baselineRegular' -4) "The user's regular address before the window ($([math]::Round([double]$Known.Share * 100, 1))% of sign-ins, $($Known.Days) day(s))"
+ & $Add 'BaselineRegular' (& $Weight 'baselineRegular' -4) "The user's regular $KnownWhat before the window ($([math]::Round([double]$Known.Share * 100, 1))% of sign-ins, $($Known.Days) day(s))"
} else {
- & $Add 'BaselineSeen' (& $Weight 'baselineSeen' -2) "Used by the user before the window ($($Known.SignIns) sign-in(s))"
+ & $Add 'BaselineSeen' (& $Weight 'baselineSeen' -2) "$(if ($KnownWhat -eq 'address') { 'Used' } else { "Its $KnownWhat was used" }) by the user before the window ($($Known.SignIns) sign-in(s))"
}
} elseif ($BaselineOk) {
& $Add 'NewToUser' (& $Weight 'newToUser' 2) 'Never used by the user before the window'
@@ -223,10 +277,11 @@ function Get-CIPPBecIPVerdicts {
}
if ($Entry.Compliant) { & $Add 'CompliantDevice' (& $Weight 'compliantDevice' -2) 'Signed in from a compliant device' }
if ($Peer) {
+ $PeerOn = if ($Peer.Network) { "its IPv6 network ($($Peer.Network))" } else { 'it' }
if ([int]$Peer.OtherUsersBefore -ge 2 -and -not ($Hosting -or $Proxy)) {
- & $Add 'Colleagues' (& $Weight 'colleagues' -2) "$($Peer.OtherUsersBefore) other account(s) used it before the window (office or VPN exit)"
+ & $Add 'Colleagues' (& $Weight 'colleagues' -2) "$($Peer.OtherUsersBefore) other account(s) used $PeerOn before the window (office or VPN exit)"
} elseif ([int]$Peer.OtherUsersInWindowOnly -ge 2 -and [int]$Peer.OtherUsersBefore -eq 0) {
- & $Add 'WiderAttack' (& $Weight 'widerAttack' 2) "$($Peer.OtherUsersInWindowOnly) other account(s) signed in from it only during the window"
+ & $Add 'WiderAttack' (& $Weight 'widerAttack' 2) "$($Peer.OtherUsersInWindowOnly) other account(s) signed in from $PeerOn only during the window"
}
}
foreach ($Hint in @($Hints | Where-Object { Test-IpInRange -IPAddress $IP -Range $_.Range })) {
@@ -246,6 +301,7 @@ function Get-CIPPBecIPVerdicts {
$Rows.Add([pscustomobject]@{
IP = $IP; Entry = $Entry; Reasons = $Reasons; Score = $Score; OnlyFailed = $OnlyFailed
Country = $Country; City = $City; ASName = $AsName; Hosting = $Hosting; Proxy = $Proxy; Peer = $Peer
+ Known = $Known; ServiceRange = & $ServiceRangeOf $IP
})
}
@@ -259,6 +315,7 @@ function Get-CIPPBecIPVerdicts {
if ($Listed) {
return [pscustomobject]@{ Verdict = $(if ($Listed.State -eq 'Blocked') { 'Compromised' } else { 'Safe' }); Source = "$($Listed.Source) ($($Listed.Range))" }
}
+ if ($Row.ServiceRange) { return [pscustomobject]@{ Verdict = 'Service'; Source = "Microsoft 365 service address ($($Row.ServiceRange))" } }
$SignInCount = $Row.Entry.SignIns + $Row.Entry.NonInteractive
if ($SignInCount -eq 0 -and $Row.ASName -match $ServiceAsn) { return [pscustomobject]@{ Verdict = 'Service'; Source = "Microsoft service address ($($Row.ASName))" } }
$OnlyServiceActors = @($Row.Entry.ActorKinds | Where-Object { $_ -notin $ServiceActors }).Count -eq 0
@@ -275,13 +332,14 @@ function Get-CIPPBecIPVerdicts {
foreach ($Row in $Rows) { $Row | Add-Member -NotePropertyName 'Decision' -NotePropertyValue (& $Classify $Row) -Force }
# One Entra or mailbox session moving between addresses is one actor: lift the rest of a session
- # that includes a likely-attacker address.
+ # that includes a likely-attacker address - except addresses judged the user's or used by the user
+ # before the window (a dual-stack device carries one session across its IPv4 and IPv6 addresses).
$AttackerSessions = [System.Collections.Generic.HashSet[string]]::new()
foreach ($Row in @($Rows | Where-Object { $_.Decision.Verdict -in @('Compromised', 'LikelyAttacker') })) {
foreach ($Session in $Row.Entry.Sessions) { $null = $AttackerSessions.Add($Session) }
}
if ($AttackerSessions.Count -gt 0) {
- foreach ($Row in @($Rows | Where-Object { $_.Decision.Verdict -in @('Suspicious', 'Unknown', 'LikelyUser') })) {
+ foreach ($Row in @($Rows | Where-Object { $_.Decision.Verdict -in @('Suspicious', 'Unknown') -and -not $_.Known })) {
$Shared = @($Row.Entry.Sessions | Where-Object { $AttackerSessions.Contains($_) })
if ($Shared.Count -eq 0) { continue }
$Points = & $Weight 'sharedSession' 3
@@ -293,7 +351,7 @@ function Get-CIPPBecIPVerdicts {
$Order = @{ Compromised = 0; LikelyAttacker = 1; Suspicious = 2; Unknown = 3; LikelyUser = 4; Safe = 5; Service = 6 }
@($Rows | ForEach-Object {
- $Known = $BaselineIPs[$_.IP]
+ $Known = $_.Known
[pscustomobject]@{
IP = $_.IP
Verdict = $_.Decision.Verdict
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1
index 8cbbdb47a4e8f..eb8ab900ff4d9 100644
--- a/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1
@@ -10,6 +10,11 @@ function Get-CIPPBecScore {
agree. The Full-scope signals (delegations, grants, transport rules, add-ins, received mail,
Defender, directory audits, registered devices, non-interactive sign-ins, mail activity, risk
state) add their weights only when their data is present in the payload.
+ The attacker-address signals all rest on the same IP verdicts, so one wrong verdict would count
+ five times over. AttackerIPs always counts; the ones derived from it (mail, files, forms,
+ delegated mailboxes, other accounts reached) count only when an attacker address is backed by
+ evidence of its own - an investigator or CIPP-list verdict (Compromised), an attacker action
+ from it, or a medium/high Entra sign-in risk. Otherwise they are listed, unapplied.
.PARAMETER Results
The BEC results object.
.PARAMETER Heuristics
@@ -119,6 +124,12 @@ function Get-CIPPBecScore {
DelegatedMailboxAttackerAccess = "Another mailbox reached through this account's delegated access from an attacker address"
}
+ # an attacker address backed by more than network heuristics (see DESCRIPTION)
+ $Corroborated = @($Results.IPVerdicts | Where-Object {
+ $_.Verdict -eq 'Compromised' -or ($_.Verdict -eq 'LikelyAttacker' -and @($_.Reasons | Where-Object { $_.Code -in @('FlaggedAction', 'RiskySignIn') }).Count -gt 0)
+ }).Count -gt 0
+ $Derived = @('AttackerMailAccess', 'AttackerFileAccess', 'AttackerForms', 'OtherAccountsReached', 'DelegatedMailboxAttackerAccess')
+
$Breakdown = [System.Collections.Generic.List[object]]::new()
$Total = 0
foreach ($Name in $Stats.Keys) {
@@ -127,13 +138,14 @@ function Get-CIPPBecScore {
'NewUsers' { $Value -gt $NewUsersThreshold }
# a change to this mailbox outweighs unrelated tenant churn; only one of the two applies
'PermissionChanges' { $Value -gt 0 -and [int]$Stats['PermissionChangesTargetingUser'] -eq 0 }
+ { $_ -in $Derived } { $Value -gt 0 -and $Corroborated }
default { $Value -gt 0 }
}
$Wt = [int]($W.$Name ?? 0)
if ($Applied) { $Total = $Total + $Wt }
$Breakdown.Add([pscustomobject]@{
Signal = $Name
- Description = $Descriptions[$Name]
+ Description = $(if ($Name -in $Derived -and $Value -gt 0 -and -not $Corroborated) { "$($Descriptions[$Name]) - not counted: no attacker address is backed by an attacker action, a risky sign-in or a confirmed verdict" } else { $Descriptions[$Name] })
Weight = $Wt
Count = $Value
Applied = [bool]$Applied
diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPMicrosoft365IPRanges.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPMicrosoft365IPRanges.ps1
new file mode 100644
index 0000000000000..e94d72ee82345
--- /dev/null
+++ b/Modules/CIPPCore/Public/BEC/Get-CIPPMicrosoft365IPRanges.ps1
@@ -0,0 +1,53 @@
+function Get-CIPPMicrosoft365IPRanges {
+ <#
+ .SYNOPSIS
+ Microsoft 365's published service address ranges (IPv4 and IPv6 CIDRs).
+ .DESCRIPTION
+ Reads the worldwide Microsoft 365 endpoint list (endpoints.office.com IP web service) - the
+ front ends of Exchange Online, SharePoint, Teams and the identity service. Traffic from these
+ addresses is Microsoft acting for the user (on-behalf-of token exchanges, proxied clients,
+ mailbox access through the service), never a machine an attacker can rent: rented Azure
+ compute sits on Microsoft's network (AS8075) but outside these ranges.
+ The list is kept for a day in the CacheM365IPRanges table (a worker recycle clears the
+ in-process memo in front of it). When the web service cannot be read, an older cached copy is
+ used; with none at all this throws, so the caller decides how to degrade.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param()
+
+ $Now = [datetime]::UtcNow
+ if ($script:M365IPRanges -and $script:M365IPRanges.Expires -gt $Now) { return $script:M365IPRanges.Ranges }
+
+ $Stale = $false
+ $Table = Get-CIPPTable -TableName 'CacheM365IPRanges'
+ $Cached = try { Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'M365' and RowKey eq 'worldwide'" | Select-Object -First 1 } catch { $null }
+ $CachedRanges = if ($Cached.JSON) { @($Cached.JSON | ConvertFrom-Json) } else { @() }
+ $CachedAt = if ($Cached.Timestamp) { ([datetimeoffset]$Cached.Timestamp).UtcDateTime } else { [datetime]::MinValue }
+
+ $Ranges = if ($CachedRanges.Count -gt 0 -and $CachedAt -gt $Now.AddDays(-1)) {
+ $CachedRanges
+ } else {
+ try {
+ # the web service asks for one stable client id per caller: the instance's app id is exactly that
+ $ClientId = [guid]::Empty
+ if (-not [guid]::TryParse([string]$env:ApplicationID, [ref]$ClientId)) { $ClientId = [guid]::NewGuid() }
+ $Response = Invoke-CIPPRestMethod -Uri "https://endpoints.office.com/endpoints/worldwide?clientrequestid=$ClientId" -Method GET -TimeoutSec 20
+ $Fresh = @($Response.ips | Where-Object { $_ } | Select-Object -Unique)
+ if ($Fresh.Count -eq 0) { throw 'The Microsoft 365 endpoint list returned no address ranges' }
+ try {
+ Add-CIPPAzDataTableEntity @Table -Entity @{ PartitionKey = 'M365'; RowKey = 'worldwide'; JSON = [string](ConvertTo-Json -InputObject $Fresh -Compress) } -Force
+ } catch { Write-Information "Microsoft 365 range cache write failed: $($_.Exception.Message)" }
+ $Fresh
+ } catch {
+ if ($CachedRanges.Count -eq 0) { throw }
+ $Stale = $true
+ Write-Information "Microsoft 365 range list unavailable, using the copy from $($CachedAt.ToString('u')): $($_.Exception.Message)"
+ $CachedRanges
+ }
+ }
+ # a stale fallback is only memoised briefly, so the next case retries the web service
+ $script:M365IPRanges = [pscustomobject]@{ Expires = $(if ($Stale) { $Now.AddMinutes(10) } else { $Now.AddHours(1) }); Ranges = @($Ranges) }
+ return @($Ranges)
+}
diff --git a/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1 b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1
index 610eb6e9403dc..b1e2a942209cd 100644
--- a/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1
+++ b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1
@@ -91,7 +91,11 @@ function Invoke-CIPPBecIPAnalysis {
try { $Geo = Get-CIPPGeoIPLocationBatch -IPs $AllIPs } catch { Write-Information "BEC IP analysis: geo lookup failed: $($_.Exception.Message)" }
}
+ # without the list, Microsoft front ends fall back to the network-name rule (only when never signed in from)
+ $ServiceRanges = try { @(Get-CIPPMicrosoft365IPRanges) } catch { Write-Information "BEC IP analysis: Microsoft 365 range list failed: $($_.Exception.Message)"; @() }
+
$VerdictParams = @{
+ ServiceRanges = $ServiceRanges
SignIns = $SignIns
NonInteractiveSignIns = $NonInteractive
Events = $Events
diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1
index e62f16d04aaae..57b3c2297e074 100644
--- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1
+++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1
@@ -47,10 +47,14 @@ function Get-CIPPBaseline {
# name field = displayName) are what CA and Intune templates use, but the wider
# template families store rows under partitions that do NOT match their executor
# ('TransportTemplate', 'ExConnectorTemplate', ...) and name them 'name'/'Name'.
+ # Only picker identities are template references: free-text identities (the
+ # Autopilot/Device Prep/Apple enrollment profile names) are the value itself, and
+ # wrapping them renders '[object Object]' in the editor's text field.
$IdentityDefinitions = @{}
if ($ResolveIdentityLabels) {
foreach ($Definition in @(Get-CIPPBaselineDefinition)) {
- if ($Definition.instanceIdentity) {
+ $IdentityType = "$($Definition.variables.$($Definition.instanceIdentity).type)"
+ if ($Definition.instanceIdentity -and $IdentityType -in @('autoComplete', 'select')) {
$IdentityDefinitions[$Definition.name] = @{
Variable = $Definition.instanceIdentity
Partition = "$($Definition.identity.partition ?? $Definition.remediate.executor)"
diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEWSAllowedAppIdsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEWSAllowedAppIdsState.ps1
new file mode 100644
index 0000000000000..95659c5a3da31
--- /dev/null
+++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEWSAllowedAppIdsState.ps1
@@ -0,0 +1,41 @@
+function Get-CIPPBaselineEWSAllowedAppIdsState {
+ <#
+ .SYNOPSIS
+ Prepare hook for EWSAllowedAppIds: is EWS enabled with every required app allowed.
+ .DESCRIPTION
+ Reads the allow list live (it is not part of the cached organization config) and
+ grades EwsEnabled, the required app IDs missing from the list, and known-malicious
+ app IDs on it. Extra IDs on the list are never drift.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ $Item,
+ $TenantFilter
+ )
+
+ $StateParams = @{
+ TenantFilter = $TenantFilter
+ Presets = $Item.Variables.presets
+ CustomAppIds = $Item.Variables.customAppIds
+ IncludeEwsPermissionApps = [bool]$Item.Variables.includeEwsPermissionApps
+ IncludeHybridApp = $Item.Variables.includeHybridApp -ne $false
+ RemoveMaliciousApps = [bool]$Item.Variables.removeMaliciousApps
+ LogApi = 'Baselines'
+ }
+ $State = Get-CIPPEwsAllowedAppIdState @StateParams
+
+ $Current = [PSCustomObject]@{
+ ewsEnabled = $State.EwsEnabled -eq $true
+ missingAppIds = @($State.MissingAppIds)
+ maliciousAppIdsPresent = @($State.MaliciousAppIdsPresent)
+ }
+ # Carried for the executor, which re-reads the list live before writing.
+ $Current | Add-Member -NotePropertyName 'stateParams' -NotePropertyValue $StateParams
+
+ @{
+ Expected = [PSCustomObject]@{ ewsEnabled = $true; missingAppIds = @(); maliciousAppIdsPresent = @() }
+ Current = $Current
+ }
+}
diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEWSAllowedAppIds.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEWSAllowedAppIds.ps1
new file mode 100644
index 0000000000000..8ef33a1c16dd8
--- /dev/null
+++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEWSAllowedAppIds.ps1
@@ -0,0 +1,33 @@
+function Invoke-CIPPBaselineEWSAllowedAppIds {
+ <#
+ .SYNOPSIS
+ EWSAllowedAppIds executor: enables EWS and merges the required app IDs into the list.
+ .DESCRIPTION
+ Set-OrganizationConfig replaces the whole list, so the list is re-read live and the
+ write is always current + required. Known-malicious IDs already on the list are
+ removed only when removeMaliciousApps is on; otherwise they stay and are reported.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ $Remediate,
+ $TenantFilter,
+ $Current
+ )
+
+ $StateParams = @{}
+ $Carried = $Current.stateParams
+ $Pairs = if ($Carried -is [System.Collections.IDictionary]) { $Carried.GetEnumerator() } else { $Carried.PSObject.Properties }
+ foreach ($Pair in $Pairs) { $StateParams[$Pair.Name] = $Pair.Value }
+ $State = Get-CIPPEwsAllowedAppIdState @StateParams
+
+ if ($State.NeedsWrite) {
+ $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-OrganizationConfig' -cmdParams @{ EwsEnabled = $true; EwsAllowedAppIDs = ($State.DesiredAppIds -join ',') } -UseSystemMailbox $true
+ Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "EWS allowed applications: enabled EWS, added $($State.MissingAppIds.Count) app ID(s), list now $($State.DesiredAppIds.Count)." -Sev 'Info'
+ }
+ $Remaining = @($State.MaliciousAppIdsPresent | Where-Object { $State.DesiredAppIds -contains $_ })
+ if ($Remaining.Count -gt 0) {
+ throw "Known-malicious app ID(s) $($Remaining -join ', ') are on the EWS allow list. Enable 'Remove known-malicious apps from the list' or remove them manually."
+ }
+}
diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1
index 27fca4064c0f9..48bc7a1ead6f2 100644
--- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1
+++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1
@@ -22,8 +22,10 @@ function Invoke-CIPPBaselineMigration {
faithful posture per standard so the operator re-enables deliberately.
- Multi-instance arrays (IntuneTemplate/ConditionalAccessTemplate/Quarantine...)
become one instance each, keyed by a stable hash of the referenced template id
- so re-migration updates instead of duplicating. 'TemplateList-Tags' selections
- map to the IntuneTemplatePackage standard (tag = package, membership stays live).
+ so re-migration updates instead of duplicating. V2 multi-selects on a single
+ template picker (GroupTemplate, TransportRuleTemplate, ...) fan out the same way.
+ 'TemplateList-Tags' selections map to the IntuneTemplatePackage standard
+ (tag = package, membership stays live).
- A hand-authored specials map covers the renamed/restructured standards; any key
that still cannot be mapped lands in the report as a warning and the V3 default
applies - nothing drops silently.
@@ -66,6 +68,9 @@ function Invoke-CIPPBaselineMigration {
EXODirectSend = @{ rename = @{ state = 'rejectDirectSend' }; value = @{ rejectDirectSend = @{ enabled = $false; disabled = $true } } }
FocusedInbox = @{ rename = @{ state = 'enabled' } }
SafeAttachmentPolicy = @{ rename = @{ action = 'SafeAttachmentAction' } }
+ # V2 template pickers saved under a generic key; V3 names the identity variable.
+ ReusableSettingsTemplate = @{ rename = @{ TemplateList = 'reusableSettingsTemplate' } }
+ SafeLinksTemplatePolicy = @{ rename = @{ TemplateIds = 'safeLinksTemplate' } }
# V2 stored the Graph string enum; the V3 definition speaks SPO numerics.
sharingCapability = @{ rename = @{ Level = 'sharingCapability' }; value = @{ sharingCapability = @{ disabled = 0; externalUserSharingOnly = 1; externalUserAndGuestSharing = 2; existingExternalUserSharingOnly = 3 } } }
SpoofWarn = @{ rename = @{ state = 'externalWarningEnabled' }; value = @{ externalWarningEnabled = @{ enabled = $true; disabled = $false } } }
@@ -213,6 +218,24 @@ function Invoke-CIPPBaselineMigration {
}
}
+ # V2 template pickers (GroupTemplate, TransportRuleTemplate, the Purview templates...)
+ # saved a multi-select on ONE entry; the V3 definition is multi-instance with one
+ # template per instance. Pull the selection out here and fan it out below - copied
+ # verbatim it lands as a list in a single-value field (all GUIDs in one instance).
+ $IdentityKey = "$($Definition.instanceIdentity)"
+ $IdentityValues = $null
+ if (-not $InstanceSeed -and $Definition.multiple -eq $true -and $IdentityKey) {
+ $IdentitySetting = @($Settings.Keys) | Where-Object { $_ -ieq $IdentityKey } | Select-Object -First 1
+ if ($IdentitySetting -and $Settings[$IdentitySetting] -is [array]) {
+ $IdentityValues = @(@($Settings[$IdentitySetting]) | ForEach-Object { "$(& $Unwrap $_)" } | Where-Object { $_ } | Select-Object -Unique)
+ $null = $Settings.Remove($IdentitySetting)
+ if ($IdentityValues.Count -eq 0) {
+ $Warnings.Add("$TargetName entry selects no template - skipped")
+ return [PSCustomObject]@{ Configs = @(); Warnings = $Warnings }
+ }
+ }
+ }
+
$DefVars = @($Definition.variables.PSObject.Properties)
$Variables = [ordered]@{}
foreach ($Key in @($Settings.Keys)) {
@@ -235,6 +258,24 @@ function Invoke-CIPPBaselineMigration {
$Variables[$DefVar.Name] = & $Coerce $Value $DefVar.Value.type
}
+ if ($IdentityValues) {
+ $FanOut = foreach ($IdentityValue in $IdentityValues) {
+ $InstanceVariables = [ordered]@{}
+ foreach ($Key in $Variables.Keys) { $InstanceVariables[$Key] = $Variables[$Key] }
+ $InstanceVariables[$IdentityKey] = $IdentityValue
+ [PSCustomObject]@{
+ standard = $TargetName
+ instance = ('{0}#m{1}' -f $TargetName, (& $InstanceId $IdentityValue))
+ variables = [PSCustomObject]$InstanceVariables
+ remediateEnabled = $EffectiveRemediate
+ alertEnabled = $Alert
+ alertOnRemediate = $false
+ faithfulRemediate = $Remediate
+ }
+ }
+ return [PSCustomObject]@{ Configs = @($FanOut); Warnings = $Warnings }
+ }
+
$Instance = if ($InstanceSeed) {
'{0}#m{1}' -f $TargetName, (& $InstanceId $InstanceSeed)
} elseif ($Definition.multiple -eq $true) {
@@ -369,7 +410,7 @@ function Invoke-CIPPBaselineMigration {
$SourceMarker = "StandardsTemplateV2:$V2Guid"
# mapper= is the migration logic version: bump it when the MAPPING changes (not the
# source data) so unchanged V2 templates still re-commit once with the improved output.
- $Sha = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($Row.JSON)|reportOnly=$ReportOnly|detect=$AddDetectStandards|mapper=3"))).ToLower()
+ $Sha = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($Row.JSON)|reportOnly=$ReportOnly|detect=$AddDetectStandards|mapper=4"))).ToLower()
$SafeSource = ConvertTo-CIPPODataFilterValue -Value $SourceMarker
$Existing = Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout' and Source eq '$SafeSource'" | Select-Object -First 1
if ($Existing -and "$($Existing.SHA)" -eq $Sha) {
diff --git a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1
index 774fefbf93815..08e8d73a9272c 100644
--- a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1
+++ b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1
@@ -35,6 +35,9 @@ function New-CIPPBaseline {
if (-not $Baseline.stages -or @($Baseline.stages).Count -lt 1) {
throw 'A baseline requires at least one stage.'
}
+ if (@($Baseline.stages).Count -gt 20) {
+ throw 'A baseline can have at most 20 stages.'
+ }
$GUID = $Baseline.GUID ? $Baseline.GUID : (New-Guid).GUID
$Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds())
diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1
index c2e91a4e8137c..3a73cc7ed88e6 100644
--- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1
+++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1
@@ -47,6 +47,7 @@ function Start-CIPPStatsTimer {
$uniqueStandardsApplied = Get-CIPPStatsUniqueStandardsApplied
$driftStandardsCount = Get-CIPPStatsDriftStandardsCount
$mobileEnrollment = Get-CIPPStatsMobileEnrollment
+ $baselineStats = Get-CIPPStatsBaselines
# Feature flags
$FeatureFlags = @{}
@@ -105,6 +106,10 @@ function Start-CIPPStatsTimer {
SuperAdminNG = $FeatureFlags.SuperAdminNG
MCPServer = $FeatureFlags.MCPServer
SSOMigrationStatus = $MigrationStatus
+ FeatureFlags = ($FeatureFlags | ConvertTo-Json -Compress)
+ BaselineCount = $baselineStats.BaselineCount
+ BaselineTenantCount = $baselineStats.BaselineTenantCount
+ BaselineStandardsCount = $baselineStats.BaselineStandardsCount
} | ConvertTo-Json
try {
diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1
index 66ce86319ba20..0e841cbdcf6f9 100644
--- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1
+++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1
@@ -9,12 +9,13 @@ function Start-LogRetentionCleanup {
param()
try {
- # Check rerun protection - only run once every 24 hours (86400 seconds)
+ # Check rerun protection - 23 hours, so the daily timer is never blocked by firing a few
+ # seconds short of a full 24 hours after the previous run
$RerunParams = @{
TenantFilter = 'AllTenants'
Type = 'LogCleanup'
API = 'LogRetentionCleanup'
- Interval = 86400
+ Interval = 82800
}
$Rerun = Test-CIPPRerun @RerunParams
if ($Rerun) {
@@ -46,8 +47,9 @@ function Start-LogRetentionCleanup {
Write-Host "Starting log cleanup with retention of $RetentionDays days"
- # Calculate cutoff date
- $CutoffDate = (Get-Date).AddDays(-$RetentionDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ # CippLogs is partitioned by day (yyyyMMdd), so the cutoff is a PartitionKey range the
+ # table service can seek to, instead of a Timestamp filter that scans every row
+ $CutoffPartition = (Get-Date).ToUniversalTime().AddDays(-$RetentionDays).ToString('yyyyMMdd')
$TotalDeletedCount = 0
$BatchSize = 5000
@@ -55,7 +57,7 @@ function Start-LogRetentionCleanup {
# Clean up CIPP Logs
if ($PSCmdlet.ShouldProcess('CippLogs', 'Cleaning up old logs')) {
$CippLogsTable = Get-CippTable -tablename 'CippLogs'
- $CutoffFilter = "Timestamp lt datetime'$CutoffDate'"
+ $CutoffFilter = "PartitionKey lt '$CutoffPartition'"
# Process deletions in batches of 10k to avoid timeout
$HasMoreRecords = $true
diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ReportAttachmentRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ReportAttachmentRetentionCleanup.ps1
new file mode 100644
index 0000000000000..3307e6834182a
--- /dev/null
+++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ReportAttachmentRetentionCleanup.ps1
@@ -0,0 +1,43 @@
+function Start-ReportAttachmentRetentionCleanup {
+ <#
+ .SYNOPSIS
+ Start the Report Attachment Retention Cleanup Timer
+ .DESCRIPTION
+ Deletes report attachments that were too large to email and were uploaded to blob storage instead,
+ once they are older than the report attachment retention period
+ #>
+ [CmdletBinding(SupportsShouldProcess = $true)]
+ param(
+ [string]$ConnectionString = $env:AzureWebJobsStorage
+ )
+
+ try {
+ $ConfigTable = Get-CippTable -tablename Config
+ $RetentionSettings = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'ReportAttachmentRetention' and RowKey eq 'Settings'"
+ $RetentionDays = if ($RetentionSettings.RetentionDays) { [math]::Max(7, [int]$RetentionSettings.RetentionDays) } else { 360 }
+ $CutoffDate = (Get-Date).AddDays(-$RetentionDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ $AttachmentTable = Get-CippTable -tablename 'ReportAttachmentBlobs'
+ $OldAttachments = @(Get-CIPPAzDataTableEntity @AttachmentTable -Filter "PartitionKey eq 'ReportAttachment' and Timestamp lt datetime'$CutoffDate'")
+ if ($OldAttachments.Count -eq 0 -or -not $PSCmdlet.ShouldProcess('ReportAttachmentBlobs', 'Delete expired report attachments')) { return }
+
+ $Deleted = @(foreach ($Attachment in $OldAttachments) {
+ try {
+ $null = New-CIPPAzStorageRequest -Service 'blob' -Resource $Attachment.BlobPath -Method 'DELETE' -ConnectionString $ConnectionString
+ $Attachment
+ } catch {
+ # A blob already gone (404) is as good as deleted; anything else stays for the next run
+ if ($_.Exception.Message -match '404|BlobNotFound') { $Attachment }
+ else { Write-LogMessage -API 'ReportAttachmentRetentionCleanup' -message "Failed to delete report attachment $($Attachment.BlobPath): $($_.Exception.Message)" -sev 'Warning' }
+ }
+ })
+ if ($Deleted.Count -gt 0) {
+ Remove-CIPPAzDataTableEntity @AttachmentTable -Entity $Deleted -Force
+ }
+ Write-LogMessage -API 'ReportAttachmentRetentionCleanup' -message "Deleted $($Deleted.Count) expired report attachments (retention: $RetentionDays days)" -Sev 'Info'
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -API 'ReportAttachmentRetentionCleanup' -message "Failed to run report attachment cleanup: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ throw
+ }
+}
diff --git a/Modules/CIPPCore/Public/Get-CIPPEwsAllowedAppIdState.ps1 b/Modules/CIPPCore/Public/Get-CIPPEwsAllowedAppIdState.ps1
new file mode 100644
index 0000000000000..aab61258329ad
--- /dev/null
+++ b/Modules/CIPPCore/Public/Get-CIPPEwsAllowedAppIdState.ps1
@@ -0,0 +1,132 @@
+function Get-CIPPEwsAllowedAppIdState {
+ <#
+ .SYNOPSIS
+ Reads a tenant's EWS app-ID allow list and works out the additive target list.
+ .DESCRIPTION
+ Shared by the EWSAllowedAppIds standard and its baseline hook. Set-OrganizationConfig
+ replaces the whole EwsAllowedAppIDs list on every write, so the target is always the
+ current list plus the required IDs. Known-malicious app IDs are never added, and are
+ only removed from the list when -RemoveMaliciousApps is set.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]$TenantFilter,
+ $Presets,
+ $CustomAppIds,
+ [bool]$IncludeEwsPermissionApps = $false,
+ [bool]$IncludeHybridApp = $true,
+ [bool]$RemoveMaliciousApps = $false,
+ $LogApi = 'Standards'
+ )
+
+ # Preset key -> app IDs. The frontend option values are these keys.
+ $PresetMap = @{
+ MicrosoftOffice = @('d3590ed6-52b3-4102-aeff-aad2292ab01c')
+ PowerQuery = @('a672d62c-fc7b-4e81-a576-e60dc46e951d')
+ PowerBIDataRefresh = @('b52893c8-bc2e-47fc-918b-77022b299bbc')
+ AppleMail = @('f8d98a96-0999-43f5-8af3-69971c7bb423')
+ AvePointCloud = @('8347dcbb-c18a-4a06-ad9d-c4ade6daba43', 'c21d796d-56a5-4305-a7df-047371fa9fd7', 'b14c93d6-47fe-4ace-afcd-006fc1fbfabb', '34be87c0-bd08-47ab-8ecf-38f6340592f3', '83043760-a2ba-4185-8611-0e6d94a0905b', 'ec377498-817e-4ec8-89be-f3917a7a8bdd')
+ AvePointFlyServer = @('1051e7ac-3119-477f-9c35-420b22bfbf13', '6d5ebe16-e826-4621-b839-d14134299a73', '44d3d6d8-eee8-416e-8c86-09cdabc778e9', 'abab2369-6eb4-4c00-bd60-9438dc9d6514')
+ }
+ $DefaultPresets = @('MicrosoftOffice', 'PowerQuery', 'PowerBIDataRefresh', 'AppleMail')
+
+ # Picker values arrive as {label, value} wrappers or plain strings.
+ $Unwrap = { param($Value) @($Value | ForEach-Object { $_.value ?? $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } | ForEach-Object { "$_".Trim() }) }
+
+ $Config = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-OrganizationConfig' -cmdParams @{ RetrieveEwsOperationAccessPolicy = $true }
+ # The list can come back as one comma-separated string or as an array.
+ $CurrentAppIds = [System.Collections.Generic.List[string]]::new()
+ $CurrentSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($Id in (@($Config.EwsAllowedAppIDs) -join ',') -split '[,;\s]+') {
+ if ([string]::IsNullOrWhiteSpace($Id)) { continue }
+ $Normalized = $Id.Trim().ToLowerInvariant()
+ if ($CurrentSet.Add($Normalized)) { $CurrentAppIds.Add($Normalized) }
+ }
+
+ $Candidates = [System.Collections.Generic.List[object]]::new()
+
+ $PresetKeys = & $Unwrap $Presets
+ if ($PresetKeys.Count -eq 0) { $PresetKeys = $DefaultPresets }
+ foreach ($Key in $PresetKeys) {
+ if (-not $PresetMap.ContainsKey($Key)) {
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message "EWS allowed apps: unknown preset '$Key' skipped." -sev Warning
+ continue
+ }
+ foreach ($Id in $PresetMap[$Key]) { $Candidates.Add(@{ Id = $Id; Source = "preset $Key" }) }
+ }
+
+ # Custom entries may hold tenant variables that expand to one or more IDs.
+ foreach ($Entry in (& $Unwrap $CustomAppIds)) {
+ $Expanded = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $Entry
+ foreach ($Part in ("$Expanded" -split '[,;\s]+' | Where-Object { $_ })) {
+ $Guid = [guid]::Empty
+ if ([guid]::TryParse($Part, [ref]$Guid)) {
+ $Candidates.Add(@{ Id = $Guid.ToString(); Source = 'custom' })
+ } else {
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message "EWS allowed apps: custom entry '$Part' (from '$Entry') is not a valid app ID and was skipped." -sev Warning
+ }
+ }
+ }
+
+ if ($IncludeEwsPermissionApps -or $IncludeHybridApp) {
+ try {
+ $PermissionApps = @(Get-CIPPEwsPermissionApps -TenantFilter $TenantFilter | Where-Object { $_.appId })
+ if ($IncludeEwsPermissionApps) {
+ foreach ($App in $PermissionApps) { $Candidates.Add(@{ Id = $App.appId; Source = "EWS permission holder '$($App.displayName)'" }) }
+ }
+ if ($IncludeHybridApp) {
+ $HybridApps = @($PermissionApps | Where-Object { $_.isExchangeHybridApp })
+ foreach ($App in $HybridApps) { $Candidates.Add(@{ Id = $App.appId; Source = "Exchange hybrid app '$($App.displayName)'" }) }
+ }
+ } catch {
+ # Discovery only adds IDs; a failure must not block the rest of the list.
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message "EWS allowed apps: could not discover apps holding EWS permissions: $($_.Exception.Message)" -sev Warning
+ }
+ }
+
+ $Malicious = (Get-CIPPBecRogueAppFeed).Apps
+ $IsMalicious = { param($Id) $Malicious -and $Malicious.ContainsKey($Id.ToLowerInvariant()) }
+
+ $RequiredAppIds = [System.Collections.Generic.List[string]]::new()
+ $RequiredSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($Candidate in $Candidates) {
+ $Id = $Candidate.Id.ToLowerInvariant()
+ if (& $IsMalicious $Id) {
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message "EWS allowed apps: $Id ($($Malicious[$Id].Name)) from $($Candidate.Source) is a known-malicious app and was not added." -sev Warning
+ continue
+ }
+ if ($RequiredSet.Add($Id)) { $RequiredAppIds.Add($Id) }
+ }
+
+ $MissingAppIds = @($RequiredAppIds | Where-Object { -not $CurrentSet.Contains($_) })
+ $MaliciousPresent = @($CurrentAppIds | Where-Object { & $IsMalicious $_ })
+ foreach ($Id in $MaliciousPresent) {
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message "EWS allowed apps: known-malicious app $Id ($($Malicious[$Id].Name)) is on the tenant's EWS allow list." -sev Alert
+ }
+
+ $DesiredAppIds = [System.Collections.Generic.List[string]]::new()
+ foreach ($Id in $CurrentAppIds) {
+ if ($RemoveMaliciousApps -and $MaliciousPresent -contains $Id) { continue }
+ $DesiredAppIds.Add($Id)
+ }
+ foreach ($Id in $MissingAppIds) { $DesiredAppIds.Add($Id) }
+
+ $NeedsWrite = $Config.EwsEnabled -ne $true -or $MissingAppIds.Count -gt 0 -or ($RemoveMaliciousApps -and $MaliciousPresent.Count -gt 0)
+ if ($NeedsWrite -and $DesiredAppIds.Count -eq 0) {
+ # EwsEnabled with an empty list blocks all EWS, so never write one.
+ Write-LogMessage -API $LogApi -tenant $TenantFilter -message 'EWS allowed apps: the resulting allow list would be empty, so nothing was changed.' -sev Warning
+ $NeedsWrite = $false
+ }
+
+ [PSCustomObject]@{
+ EwsEnabled = $Config.EwsEnabled
+ CurrentAppIds = @($CurrentAppIds)
+ RequiredAppIds = @($RequiredAppIds)
+ MissingAppIds = @($MissingAppIds)
+ MaliciousAppIdsPresent = @($MaliciousPresent)
+ DesiredAppIds = @($DesiredAppIds)
+ NeedsWrite = [bool]$NeedsWrite
+ }
+}
diff --git a/Modules/CIPPCore/Public/Get-CIPPEwsPermissionApps.ps1 b/Modules/CIPPCore/Public/Get-CIPPEwsPermissionApps.ps1
new file mode 100644
index 0000000000000..2d45dbc689503
--- /dev/null
+++ b/Modules/CIPPCore/Public/Get-CIPPEwsPermissionApps.ps1
@@ -0,0 +1,94 @@
+function Get-CIPPEwsPermissionApps {
+ <#
+ .SYNOPSIS
+ Lists the service principals in a tenant that hold Exchange Online EWS permissions.
+ .DESCRIPTION
+ Returns one object per app holding the Exchange Online full_access_as_app application
+ role, or a delegated grant on Exchange Online that includes EWS.AccessAsUser.All or
+ full_access_as_user. Assignments and grants are read live from Graph (the nightly cache
+ misses assignments, and an incomplete allow list breaks apps); service principal names
+ come from the cache with a live lookup for any holder it lacks.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]$TenantFilter
+ )
+
+ $ExoAppId = '00000002-0000-0ff1-ce00-000000000000'
+ $DelegatedScopes = @('EWS.AccessAsUser.All', 'full_access_as_user')
+
+ $ServicePrincipals = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals' -Fields 'id', 'appId', 'displayName', 'appRoles')
+ if ($ServicePrincipals.Count -eq 0) {
+ $ServicePrincipals = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals?$select=id,appId,displayName,appRoles&$top=999' -tenantid $TenantFilter)
+ }
+ $ExoSp = $ServicePrincipals | Where-Object { $_.appId -eq $ExoAppId } | Select-Object -First 1
+ if (-not $ExoSp.id) { throw 'The Exchange Online service principal was not found.' }
+ # Resolve the role by value; the well-known id is only a fallback.
+ $FullAccessRoleId = (@($ExoSp.appRoles) | Where-Object { $_.value -eq 'full_access_as_app' } | Select-Object -First 1).id ?? 'dc890d15-9560-4a4c-9b7f-a736ec74ec40'
+
+ $Assignments = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ExoSp.id)/appRoleAssignedTo?`$top=999" -tenantid $TenantFilter)
+ $Grants = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/oauth2PermissionGrants?`$filter=resourceId eq '$($ExoSp.id)'" -tenantid $TenantFilter)
+
+ # principalId -> @{ types; permissions }
+ $Holders = [ordered]@{}
+ $AddHolder = {
+ param($SpId, $Type, $Permission)
+ if (-not $Holders.Contains($SpId)) {
+ $Holders[$SpId] = @{
+ Types = [System.Collections.Generic.List[string]]::new()
+ Permissions = [System.Collections.Generic.List[string]]::new()
+ }
+ }
+ if (-not $Holders[$SpId].Types.Contains($Type)) { $Holders[$SpId].Types.Add($Type) }
+ if (-not $Holders[$SpId].Permissions.Contains($Permission)) { $Holders[$SpId].Permissions.Add($Permission) }
+ }
+ foreach ($Assignment in $Assignments) {
+ if ($Assignment.resourceId -eq $ExoSp.id -and $Assignment.appRoleId -eq $FullAccessRoleId -and $Assignment.principalType -eq 'ServicePrincipal') {
+ & $AddHolder "$($Assignment.principalId)" 'Application' 'full_access_as_app'
+ }
+ }
+ foreach ($Grant in $Grants) {
+ if ($Grant.resourceId -ne $ExoSp.id) { continue }
+ foreach ($Scope in ("$($Grant.scope)" -split '\s+')) {
+ $Match = $DelegatedScopes | Where-Object { $_ -eq $Scope } | Select-Object -First 1
+ if ($Match) { & $AddHolder "$($Grant.clientId)" 'Delegated' $Match }
+ }
+ }
+ if ($Holders.Count -eq 0) { return }
+
+ $SpById = @{}
+ foreach ($Sp in $ServicePrincipals) { if ($Sp.id) { $SpById["$($Sp.id)"] = $Sp } }
+ # Holders the cache does not know yet (apps added since the nightly run): one $batch lookup.
+ $LookupRequests = [System.Collections.Generic.List[object]]::new()
+ foreach ($SpId in $Holders.Keys) {
+ if (-not $SpById.ContainsKey($SpId)) {
+ $LookupRequests.Add(@{ id = $SpId; method = 'GET'; url = "servicePrincipals/$SpId`?`$select=id,appId,displayName" })
+ }
+ }
+ if ($LookupRequests.Count -gt 0) {
+ foreach ($Result in @(New-GraphBulkRequest -Requests @($LookupRequests) -tenantid $TenantFilter)) {
+ if ($Result.status -eq 200 -and $Result.body.id) { $SpById["$($Result.body.id)"] = $Result.body }
+ }
+ }
+ $Malicious = (Get-CIPPBecRogueAppFeed).Apps
+
+ foreach ($SpId in $Holders.Keys) {
+ $Sp = $SpById[$SpId]
+ if (-not $Sp) { continue }
+ $AppId = "$($Sp.appId)".ToLowerInvariant()
+ $Holder = $Holders[$SpId]
+ $MaliciousEntry = if ($AppId -and $Malicious) { $Malicious[$AppId] }
+ [PSCustomObject]@{
+ appId = $AppId
+ displayName = $Sp.displayName
+ servicePrincipalId = $SpId
+ permissionType = $Holder.Types -join ', '
+ permissions = @($Holder.Permissions)
+ isExchangeHybridApp = $Holder.Permissions.Contains('full_access_as_app') -and "$($Sp.displayName)" -like 'ExchangeServerApp-*'
+ isKnownMalicious = [bool]$MaliciousEntry
+ maliciousName = $MaliciousEntry.Name
+ }
+ }
+}
diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseRecommendation.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseRecommendation.ps1
index 1a2046ea342c1..e870b17ace5a6 100644
--- a/Modules/CIPPCore/Public/Get-CIPPLicenseRecommendation.ps1
+++ b/Modules/CIPPCore/Public/Get-CIPPLicenseRecommendation.ps1
@@ -81,6 +81,10 @@ function Get-CIPPLicenseRecommendation {
.PARAMETER PlanIdsBySku
Optional. Hashtable of skuId (lower) -> string[] service plan ids. Defaults to ConversionTable.csv.
+ .PARAMETER PlanNamesById
+ Optional. Hashtable of service plan id (lower) -> friendly name, used to name plans an
+ analysis would otherwise drop silently. Defaults to ConversionTable.csv.
+
.FUNCTIONALITY
Internal
#>
@@ -101,7 +105,8 @@ function Get-CIPPLicenseRecommendation {
$AppUsage,
$MailboxUsage,
$CopilotUsage,
- [hashtable]$PlanIdsBySku
+ [hashtable]$PlanIdsBySku,
+ [hashtable]$PlanNamesById
)
if ($TenureMonths -le 0) { $TenureMonths = 6 }
@@ -159,20 +164,30 @@ function Get-CIPPLicenseRecommendation {
$Uplift = if ($Catalog.meta.monthlyCommitmentUplift) { [double]$Catalog.meta.monthlyCommitmentUplift } else { 0.20 }
# ------------------------------------------------------------------ service plans per SKU
- if (-not $PlanIdsBySku) {
- $PlanIdsBySku = @{}
+ if (-not $PlanIdsBySku -or -not $PlanNamesById) {
+ $NeedIds = -not $PlanIdsBySku
+ $NeedNames = -not $PlanNamesById
+ if ($NeedIds) { $PlanIdsBySku = @{} }
+ if ($NeedNames) { $PlanNamesById = @{} }
try {
$TablePath = Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv'
if (Test-Path $TablePath) {
foreach ($Row in ([System.IO.File]::ReadAllText($TablePath) | ConvertFrom-Csv)) {
$Key = ([string]$Row.GUID).ToLowerInvariant()
if (-not $Key -or -not $Row.Service_Plan_Id) { continue }
- if (-not $PlanIdsBySku.ContainsKey($Key)) { $PlanIdsBySku[$Key] = [System.Collections.Generic.List[string]]::new() }
- $PlanIdsBySku[$Key].Add(([string]$Row.Service_Plan_Id).ToLowerInvariant())
+ $PlanKey = ([string]$Row.Service_Plan_Id).ToLowerInvariant()
+ if ($NeedIds) {
+ if (-not $PlanIdsBySku.ContainsKey($Key)) { $PlanIdsBySku[$Key] = [System.Collections.Generic.List[string]]::new() }
+ $PlanIdsBySku[$Key].Add($PlanKey)
+ }
+ if ($NeedNames -and $Row.Service_Plans_Included_Friendly_Names -and -not $PlanNamesById.ContainsKey($PlanKey)) {
+ $PlanNamesById[$PlanKey] = [string]$Row.Service_Plans_Included_Friendly_Names
+ }
}
}
} catch { Write-Information "ConversionTable read failed: $($_.Exception.Message)" }
}
+ $PlanNameOf = { param($Id) $Key = ([string]$Id).ToLowerInvariant(); if ($PlanNamesById.ContainsKey($Key)) { $PlanNamesById[$Key] } else { $Key } }
$PlanSetOf = @{}
$GetPlanSet = {
param($Sku)
@@ -194,6 +209,11 @@ function Get-CIPPLicenseRecommendation {
$Capabilities = @($Catalog.capabilities)
$CapById = @{}
foreach ($Cap in $Capabilities) { $CapById[[string]$Cap.id] = $Cap }
+ # Service plans no capability describes (Windows 365, Defender for Identity, ...): a product
+ # made only of these contributes nothing to a capability union and can't be reported by name,
+ # only by which of its plans a target doesn't carry.
+ $CapMappedPlanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($Cap in $Capabilities) { foreach ($Id in @($Cap.servicePlanIds)) { if ($Id) { $null = $CapMappedPlanIds.Add(([string]$Id).ToLowerInvariant()) } } }
# cap id -> which capability ids a plan set includes (respecting per-user disabled plans)
$CapsOfPlans = {
param($PlanSet, $Disabled)
@@ -397,6 +417,26 @@ function Get-CIPPLicenseRecommendation {
return $Best
}
+ # Friendly names of service plans a target doesn't carry that no capability represents, so a
+ # downgrade or consolidation still reports what silently disappears. Reporting only.
+ $PlanLosses = {
+ param($FromSkuIds, $FromDisabledSets, $TargetSkuId)
+ $TargetPlans = if ($TargetSkuId) { & $GetPlanSet $TargetSkuId } else { [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) }
+ $Names = [System.Collections.Generic.List[string]]::new()
+ for ($i = 0; $i -lt @($FromSkuIds).Count; $i++) {
+ $PlanSet = & $GetPlanSet $FromSkuIds[$i]
+ $Disabled = $FromDisabledSets[$i]
+ foreach ($PlanId in $PlanSet) {
+ if ($Disabled -and $Disabled.Contains($PlanId)) { continue }
+ if ($CapMappedPlanIds.Contains($PlanId)) { continue }
+ if ($TargetPlans.Contains($PlanId)) { continue }
+ $Name = & $PlanNameOf $PlanId
+ if ($Name -and -not $Names.Contains($Name)) { $Names.Add($Name) }
+ }
+ }
+ return , @($Names | Sort-Object -Unique)
+ }
+
# ------------------------------------------------------------------ downgrades
$DowngradeGroups = @{}
$DowngradeUserCount = 0
@@ -448,6 +488,11 @@ function Get-CIPPLicenseRecommendation {
foreach ($CapId in ($Held | Sort-Object)) {
if ($Target -and $Target.caps.Contains($CapId)) { $Kept.Add((& $CapLabel $CapId)) } else { $Lost.Add((& $CapLabel $CapId)) }
}
+ $TargetSkuForLoss = if ($Target) { $Target.skuId } else { $null }
+ foreach ($Name in (& $PlanLosses @($Key) @($Disabled) $TargetSkuForLoss)) {
+ if (-not $Lost.Contains($Name)) { $Lost.Add($Name) }
+ }
+ $Lost = [System.Collections.Generic.List[string]]::new([string[]]@($Lost | Sort-Object -Unique))
$DowngradeGroups[$GroupKey] = [pscustomobject]@{
FromLicense = $Current.name
FromSkuId = $Key
@@ -482,9 +527,15 @@ function Get-CIPPLicenseRecommendation {
$Upn = [string]$User.userPrincipalName
$HeldProducts = [System.Collections.Generic.List[object]]::new()
$UnionCaps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
- $Families = [System.Collections.Generic.List[string]]::new()
- $Sum = 0.0
$Unpriced = $false
+ # Describable = at least one capability comes from this product. An opaque add-on
+ # (Windows 365, Defender for Identity, ...) contributes nothing to a capability union,
+ # so it never joins a Consolidate bundle - the base plan "covering" it is not real.
+ $DescProducts = [System.Collections.Generic.List[object]]::new()
+ $DescDisabled = [System.Collections.Generic.List[object]]::new()
+ $DescUnionCaps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ $DescFamilies = [System.Collections.Generic.List[string]]::new()
+ $DescSum = 0.0
foreach ($Assigned in @($User.assignedLicenses)) {
if (-not $Assigned.skuId) { continue }
$Key = ([string]$Assigned.skuId).ToLowerInvariant()
@@ -492,62 +543,72 @@ function Get-CIPPLicenseRecommendation {
$Product = $ProductBySku[$Key]
if ($null -eq $Product.price) { $Unpriced = $true; continue }
$HeldProducts.Add($Product)
- $Families.Add($Product.family)
- $Sum += $Product.price
$Disabled = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($D in @($Assigned.disabledPlans)) { if ($D) { $null = $Disabled.Add(([string]$D).ToLowerInvariant()) } }
$UnionCaps.UnionWith((& $CapsOfPlans (& $GetPlanSet $Key) $Disabled))
+ if ($Product.caps.Count -gt 0) {
+ $DescProducts.Add($Product)
+ $DescDisabled.Add($Disabled)
+ $DescFamilies.Add($Product.family)
+ $DescSum += $Product.price
+ $DescUnionCaps.UnionWith((& $CapsOfPlans (& $GetPlanSet $Key) $Disabled))
+ }
}
if ($HeldProducts.Count -eq 0 -or $Unpriced) { continue }
- $FromNames = @($HeldProducts | Sort-Object -Property name | Select-Object -ExpandProperty name -Unique)
- $FromKey = ($HeldProducts.skuId | Sort-Object) -join '+'
- # Consolidate: several plans -> one cheaper plan covering the same capabilities
- if ($HeldProducts.Count -ge 2 -and -not $OverlapUpns.Contains($Upn)) {
- $Target = & $CheapestCovering $UnionCaps @($Families) $Sum
+ # Consolidate: several describable plans -> one cheaper plan covering the same capabilities
+ if ($DescProducts.Count -ge 2 -and -not $OverlapUpns.Contains($Upn)) {
+ $Target = & $CheapestCovering $DescUnionCaps @($DescFamilies) $DescSum
if ($Target) {
- $GroupKey = "Consolidate|$FromKey|$($Target.skuId)"
+ $DescFromKey = ($DescProducts.skuId | Sort-Object) -join '+'
+ $GroupKey = "Consolidate|$DescFromKey|$($Target.skuId)"
if (-not $UpgradeGroups.ContainsKey($GroupKey)) {
+ $Loses = & $PlanLosses @($DescProducts.skuId) @($DescDisabled) $Target.skuId
$UpgradeGroups[$GroupKey] = [pscustomobject]@{
Type = 'Consolidate'
- FromLicenses = $FromNames
- FromSkuIds = @($HeldProducts.skuId)
+ FromLicenses = @($DescProducts | Sort-Object -Property name | Select-Object -ExpandProperty name -Unique)
+ FromSkuIds = @($DescProducts.skuId)
ToLicense = $Target.name
ToSkuId = $Target.skuId
Seats = 0
- UnitCost = [math]::Round($Sum, 2)
+ UnitCost = [math]::Round($DescSum, 2)
TargetCost = $Target.price
- UnitDelta = [math]::Round($Target.price - $Sum, 2)
+ UnitDelta = [math]::Round($Target.price - $DescSum, 2)
MonthlyDelta = 0.0
- Gains = @(($Target.caps | Where-Object { -not $UnionCaps.Contains($_) } | Sort-Object) | ForEach-Object { & $CapLabel $_ })
+ Gains = @(($Target.caps | Where-Object { -not $DescUnionCaps.Contains($_) } | Sort-Object) | ForEach-Object { & $CapLabel $_ })
+ Loses = @($Loses)
Users = [System.Collections.Generic.List[object]]::new()
}
}
$Group = $UpgradeGroups[$GroupKey]
$Group.Seats = $Group.Seats + 1
- $Group.MonthlyDelta = [math]::Round($Group.MonthlyDelta + ($Target.price - $Sum), 2)
+ $Group.MonthlyDelta = [math]::Round($Group.MonthlyDelta + ($Target.price - $DescSum), 2)
$Group.Users.Add([pscustomobject]@{ userPrincipalName = $Upn; displayName = [string]$User.displayName })
}
}
- # Protect: no device management / sign-in security / device threat protection at all
- if (-not $UnionCaps.Overlaps($ProtectCaps)) {
+ # Protect: no device management / sign-in security / device threat protection at all.
+ # Eligibility stays on the full capability union (an opaque add-on can never supply a
+ # protect capability anyway); cost basis is the describable products only, so an add-on
+ # that contributes nothing isn't netted away against the target's price.
+ if ($DescProducts.Count -gt 0 -and -not $UnionCaps.Overlaps($ProtectCaps)) {
$Required = [System.Collections.Generic.HashSet[string]]::new($UnionCaps, [System.StringComparer]::OrdinalIgnoreCase)
$Required.UnionWith($ProtectCaps)
- $Target = & $CheapestCovering $Required @($Families) ([double]::MaxValue)
+ $Target = & $CheapestCovering $Required @($DescFamilies) ([double]::MaxValue)
if ($Target) {
- $GroupKey = "Protect|$FromKey|$($Target.skuId)"
+ $DescFromKey = ($DescProducts.skuId | Sort-Object) -join '+'
+ $GroupKey = "Protect|$DescFromKey|$($Target.skuId)"
if (-not $UpgradeGroups.ContainsKey($GroupKey)) {
$UpgradeGroups[$GroupKey] = [pscustomobject]@{
Type = 'Protect'
- FromLicenses = $FromNames
- FromSkuIds = @($HeldProducts.skuId)
+ FromLicenses = @($DescProducts | Sort-Object -Property name | Select-Object -ExpandProperty name -Unique)
+ FromSkuIds = @($DescProducts.skuId)
ToLicense = $Target.name
ToSkuId = $Target.skuId
Seats = 0
- UnitCost = [math]::Round($Sum, 2)
+ UnitCost = [math]::Round($DescSum, 2)
TargetCost = $Target.price
- UnitDelta = [math]::Round($Target.price - $Sum, 2)
+ UnitDelta = [math]::Round($Target.price - $DescSum, 2)
MonthlyDelta = 0.0
Gains = @(($Target.caps | Where-Object { -not $UnionCaps.Contains($_) } | Sort-Object) | ForEach-Object { & $CapLabel $_ })
Users = [System.Collections.Generic.List[object]]::new()
@@ -555,7 +616,7 @@ function Get-CIPPLicenseRecommendation {
}
$Group = $UpgradeGroups[$GroupKey]
$Group.Seats = $Group.Seats + 1
- $Group.MonthlyDelta = [math]::Round($Group.MonthlyDelta + ($Target.price - $Sum), 2)
+ $Group.MonthlyDelta = [math]::Round($Group.MonthlyDelta + ($Target.price - $DescSum), 2)
$Group.Users.Add([pscustomobject]@{ userPrincipalName = $Upn; displayName = [string]$User.displayName })
}
}
@@ -742,7 +803,9 @@ function Get-CIPPLicenseRecommendation {
foreach ($U in @($Up.Users)) {
$From = @($Up.FromLicenses) -join ' + '
if ($Up.Type -eq 'Consolidate') {
- & $AddSuggestion 'Combine licenses' $U.userPrincipalName $From $Up.FromSkuIds[0] $Up.ToLicense "Replace $From with $($Up.ToLicense)" 'One bundle covers the same features for less' 1 (-1 * [double]$Up.UnitDelta) $true @() @()
+ $Reason = 'One bundle covers the same features for less'
+ if (@($Up.Loses).Count -gt 0) { $Reason += "; would lose $(@($Up.Loses) -join ', ')" }
+ & $AddSuggestion 'Combine licenses' $U.userPrincipalName $From $Up.FromSkuIds[0] $Up.ToLicense "Replace $From with $($Up.ToLicense)" $Reason 1 (-1 * [double]$Up.UnitDelta) $true @() $Up.Loses
} else {
& $AddSuggestion 'Add protection' $U.userPrincipalName $From $Up.FromSkuIds[0] $Up.ToLicense "Change $From to $($Up.ToLicense)" "No device management, sign-in security or device threat protection; adds $(@($Up.Gains) -join ', ')" 1 (-1 * [double]$Up.UnitDelta) $true @() @()
}
diff --git a/Modules/CIPPCore/Public/Get-CIPPStatsBaselines.ps1 b/Modules/CIPPCore/Public/Get-CIPPStatsBaselines.ps1
new file mode 100644
index 0000000000000..776e72fd12521
--- /dev/null
+++ b/Modules/CIPPCore/Public/Get-CIPPStatsBaselines.ps1
@@ -0,0 +1,34 @@
+function Get-CIPPStatsBaselines {
+ <#
+ .SYNOPSIS
+ Baseline usage counts for the anonymous stats payload
+ #>
+ [CmdletBinding()]
+ param()
+
+ try {
+ $RolloutTable = Get-CippTable -tablename 'BaselineRollouts'
+ $BaselineCount = @(Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout'" -Property RowKey).Count
+
+ # One delta row exists per (standard, scope, stage); scopes are tenants, groups or AllTenants.
+ $DeltaTable = Get-CippTable -tablename 'Baselines'
+ $Deltas = @(Get-CIPPAzDataTableEntity @DeltaTable -Filter "PartitionKey eq 'standardItem'" -Property standardName, scope, scopeId)
+
+ $Scopes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ $Standards = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($Delta in $Deltas) {
+ if ($Delta.scopeId) { [void]$Scopes.Add("$($Delta.scopeId)") }
+ # Multi-instance keys look like 'standard#instance'; count the standard once.
+ if ($Delta.standardName) { [void]$Standards.Add(("$($Delta.standardName)" -split '#')[0]) }
+ }
+
+ [PSCustomObject]@{
+ BaselineCount = $BaselineCount
+ BaselineTenantCount = $Scopes.Count
+ BaselineStandardsCount = $Standards.Count
+ }
+ } catch {
+ Write-LogMessage -API 'CIPPStatsTimer' -tenant $env:TenantID -message "Failed to calculate baseline stats: $($_.Exception.Message)" -sev Warning
+ [PSCustomObject]@{ BaselineCount = $null; BaselineTenantCount = $null; BaselineStandardsCount = $null }
+ }
+}
diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1
index 313019b8bf290..8391eaa00b980 100644
--- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1
+++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1
@@ -12,12 +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 reads what is actually granted on the CIPP-SAM enterprise
- application (service principal) in the partner tenant: appRoleAssignments (application/Role) and
- oauth2PermissionGrants (delegated/Scope). It diffs those grants against the effective set,
+ 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.
- If the grant lookup fails, GrantCheckFailed is set to $true and GrantCheckError contains the error message.
.EXAMPLE
Get-CippSamPermissions
@@ -198,92 +197,44 @@ function Get-CippSamPermissions {
}
}
- # 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.
+ # Diff the manifest-required base against the saved AppPermissions table. The table records what has
+ # been applied to the CIPP-SAM app - the repair/update flow persists it as manifest ∪ extras - so it
+ # stands in for the "current" permission set and no partner-tenant Graph call is needed here.
+ # MissingPermissions = manifest-required perms not yet present in the table (a Permissions repair is needed).
+ # PartnerAppDiff mirrors MissingPermissions in the shape the SAM permissions page expects.
$MissingPermissions = @{}
$PartnerAppDiff = @{}
- $GrantCheckFailed = $false
- $GrantCheckError = ''
if (!$NoDiff.IsPresent) {
- try {
- $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)
- }
+ foreach ($AppId in $AllAppIds) {
+ $ManifestApp = $ManifestPermissions.$AppId
+ $SavedApp = $SavedPermissions.$AppId
- # 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) }
- }
+ $SavedAppIds = @($SavedApp.applicationPermissions.id)
+ $SavedDelIds = @($SavedApp.delegatedPermissions.id)
- foreach ($AppId in $AllAppIds) {
- $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId
- $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ })
- $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ })
-
- # Application (Role) permissions compare by GUID against appRoleAssignments.
- $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | 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)
- $EffDelNames = @($EffDel.value)
-
- $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 ($Name in ($GrantedScopeNames | Sort-Object -Unique)) {
- if ($EffDelNames -notcontains $Name) {
- [PSCustomObject]@{ id = $Name; value = $Name }
- }
- })
-
- if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) {
- $MissingPermissions.$AppId = @{
- applicationPermissions = $MissingApp
- delegatedPermissions = $MissingDel
+ $MissingApp = @(foreach ($Permission in $ManifestApp.applicationPermissions) {
+ if ($Permission.id -and $SavedAppIds -notcontains $Permission.id) {
+ [PSCustomObject]@{ id = $Permission.id; value = $Permission.value }
}
- }
- 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
+ })
+ $MissingDel = @(foreach ($Permission in $ManifestApp.delegatedPermissions) {
+ if ($Permission.id -and $SavedDelIds -notcontains $Permission.id) {
+ [PSCustomObject]@{ id = $Permission.id; value = $Permission.value }
}
+ })
+
+ if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) {
+ $MissingPermissions.$AppId = @{
+ applicationPermissions = $MissingApp
+ delegatedPermissions = $MissingDel
+ }
+ $PartnerAppDiff.$AppId = @{
+ missingApplicationPermissions = $MissingApp
+ missingDelegatedPermissions = $MissingDel
+ extraApplicationPermissions = @()
+ extraDelegatedPermissions = @()
}
}
- } catch {
- Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)"
- $GrantCheckFailed = $true
- $GrantCheckError = $_.Exception.Message
}
}
@@ -331,8 +282,6 @@ function Get-CippSamPermissions {
Type = if ($HasSaved) { 'Table' } else { 'Manifest' }
UpdatedBy = $SavedRow.UpdatedBy ?? 'CIPP'
Timestamp = $Timestamp.ToString('yyyy-MM-ddTHH:mm:ssZ')
- GrantCheckFailed = $GrantCheckFailed
- GrantCheckError = $GrantCheckError
}
$SamAppPermissions = $SamAppPermissions | ConvertTo-Json -Depth 10 -Compress | ConvertFrom-Json
diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1
index 329ade7d60344..e97bfd2120a1d 100644
--- a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1
+++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1
@@ -47,6 +47,8 @@ function Get-CippMcpToolCatalog {
foreach ($MethodEntry in $PathEntry.Value.GetEnumerator()) {
$Method = [string]$MethodEntry.Key
if ($Method -notin @('get', 'post')) { continue }
+ # a GET/POST pair is one tool; the POST also lists the query parameters
+ if ($Method -eq 'get' -and $PathEntry.Value.Contains('post')) { continue }
$Op = $MethodEntry.Value
$Role = $Op['x-cipp-role']
diff --git a/Modules/CIPPCore/Public/New-CIPPReportAttachmentLink.ps1 b/Modules/CIPPCore/Public/New-CIPPReportAttachmentLink.ps1
new file mode 100644
index 0000000000000..d1e2d023d0f03
--- /dev/null
+++ b/Modules/CIPPCore/Public/New-CIPPReportAttachmentLink.ps1
@@ -0,0 +1,49 @@
+function New-CIPPReportAttachmentLink {
+ <#
+ .SYNOPSIS
+ Upload an email attachment to blob storage and return a read-only SAS download URL
+ .DESCRIPTION
+ Used when an attachment is too large for Graph sendMail's 4MB request limit. The blob is recorded
+ in the ReportAttachmentBlobs table so Start-ReportAttachmentRetentionCleanup can delete it once the
+ report attachment retention period passes; the SAS link expires at the same time.
+ #>
+ [CmdletBinding(SupportsShouldProcess = $true)]
+ param(
+ [Parameter(Mandatory = $true)][string]$Name,
+ [Parameter(Mandatory = $true)][string]$ContentBytes,
+ [string]$ContentType = 'application/octet-stream',
+ [string]$ConnectionString = $env:AzureWebJobsStorage
+ )
+
+ $ContainerName = 'report-attachments'
+ $ConfigTable = Get-CIPPTable -TableName Config
+ $RetentionSettings = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'ReportAttachmentRetention' and RowKey eq 'Settings'"
+ $RetentionDays = if ($RetentionSettings.RetentionDays) { [int]$RetentionSettings.RetentionDays } else { 360 }
+
+ $Containers = try { New-CIPPAzStorageRequest -Service 'blob' -Component 'list' -ConnectionString $ConnectionString } catch { @() }
+ if (-not ($Containers | Where-Object { $_.Name -eq $ContainerName })) {
+ $null = New-CIPPAzStorageRequest -Service 'blob' -Resource $ContainerName -Method 'PUT' -QueryParams @{ restype = 'container' } -ConnectionString $ConnectionString
+ }
+
+ $SafeName = $Name -replace '[^a-zA-Z0-9_.\-]', '_'
+ $BlobId = [string][guid]::NewGuid()
+ $BlobPath = "$ContainerName/$BlobId/$SafeName"
+ $null = New-CIPPAzStorageRequest -Service 'blob' -Resource $BlobPath -Method 'PUT' -ContentType $ContentType -Body ([Convert]::FromBase64String($ContentBytes)) -ConnectionString $ConnectionString
+
+ $AttachmentTable = Get-CIPPTable -TableName 'ReportAttachmentBlobs'
+ Add-CIPPAzDataTableEntity @AttachmentTable -Force -Entity @{
+ PartitionKey = 'ReportAttachment'
+ RowKey = $BlobId
+ BlobPath = $BlobPath
+ FileName = $SafeName
+ }
+
+ $Conn = @{}
+ foreach ($Part in ($ConnectionString -split ';')) {
+ if ($Part.Trim() -match '^(.+?)=(.+)$') { $Conn[$matches[1]] = $matches[2] }
+ }
+ $Sas = New-CIPPAzServiceSAS -AccountName $Conn['AccountName'] -AccountKey $Conn['AccountKey'] -Service 'blob' -SignedResource 'b' -ResourcePath $BlobPath `
+ -Permissions 'r' -ExpiryTime ([DateTime]::UtcNow.AddDays($RetentionDays)) -ContentDisposition "attachment; filename=`"$SafeName`"" -ConnectionString $ConnectionString
+
+ return $Sas.ResourceUri + $Sas.Token
+}
diff --git a/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1
deleted file mode 100644
index be860ac91a379..0000000000000
--- a/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1
+++ /dev/null
@@ -1,31 +0,0 @@
-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/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1
index 0c04760a9c258..bad4157e0f263 100644
--- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1
+++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1
@@ -5,9 +5,9 @@ function Update-CippSamPermissions {
.DESCRIPTION
Writes the full applied permission set - the SAM manifest base PLUS any admin-configured extra
permissions - into the AppPermissions table, so the table always reflects everything the
- CIPP-SAM app is expected to have. The grant flow below reads this table, so persisting the
- manifest here is what lets the permission check clear after a repair - that check compares the
- effective set against the grants on the service principal, not against this table.
+ CIPP-SAM app is expected to have. Get-CippSamPermissions diffs the manifest against this table
+ to decide when a Permissions repair is needed, so persisting the manifest here is what lets that
+ check clear after a repair.
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
diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1
index c968826fbe443..d1bb801a698fc 100644
--- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1
+++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1
@@ -67,9 +67,11 @@ function Send-CIPPAlert {
}
# Add file attachments if provided. sendMail rejects a request body over 4MB, so attach in
- # order (the report PDF comes first) and omit whatever no longer fits.
+ # order (the report PDF comes first); whatever no longer fits is uploaded to blob storage and
+ # linked from the body instead, or omitted if the upload fails. The links fit in the 64KB slack.
if ($Attachments -and $Attachments.Count -gt 0) {
$Budget = 4MB - 64KB - [System.Text.Encoding]::UTF8.GetByteCount((ConvertTo-Json -Compress -Depth 10 -InputObject $PowerShellBody))
+ $DownloadLinks = [System.Collections.Generic.List[string]]::new()
$FittingAttachments = @($Attachments | ForEach-Object {
$Size = ([string]$_.ContentBytes).Length + 512
if ($Size -le $Budget) {
@@ -81,12 +83,21 @@ function Send-CIPPAlert {
contentBytes = $_.ContentBytes
}
} else {
- Write-Information "Omitting attachment $($_.Name) from '$Title': too large for sendMail"
+ $Attachment = $_
+ try {
+ $Url = New-CIPPReportAttachmentLink -Name $Attachment.Name -ContentBytes $Attachment.ContentBytes -ContentType $Attachment.ContentType
+ $DownloadLinks.Add("$([System.Net.WebUtility]::HtmlEncode($Attachment.Name))")
+ } catch {
+ Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Omitting attachment $($Attachment.Name) from '$Title': too large for email and the blob upload failed: $($_.Exception.Message)" -sev Warning
+ }
}
})
if ($FittingAttachments.Count -gt 0) {
$PowerShellBody.message.attachments = $FittingAttachments
}
+ if ($DownloadLinks.Count -gt 0) {
+ $PowerShellBody.message.body.content = "$HTMLContentThe following attachment(s) were too large to attach to this email. Download them directly here:
$($DownloadLinks -join '')
"
+ }
}
$JSONBody = ConvertTo-Json -Compress -Depth 10 -InputObject $PowerShellBody
diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1
index bb7d4b7cca646..46ca2c4616a14 100644
--- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1
+++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1
@@ -149,7 +149,17 @@ function Send-CIPPScheduledTaskAlert {
# Build HTML with adaptive table styling
$TableDesign = ''
- $EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($TaskInfo.Name)
+ # Scripted alerts store Name as "{tenant labels}: {subject}". That list must not appear in
+ # per-tenant PSA/email bodies - Tenant is already on the next line. Prefer CustomSubject,
+ # else strip the leading scope for Alert deliveries, else keep the full Name.
+ $DisplayTitle = if (![string]::IsNullOrWhiteSpace($TaskInfo.CustomSubject)) {
+ [string]$TaskInfo.CustomSubject
+ } elseif ($TaskType -eq 'Alert' -and "$($TaskInfo.Name)" -match '^[^:]+:\s*(.+)$') {
+ $Matches[1].Trim()
+ } else {
+ [string]$TaskInfo.Name
+ }
+ $EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($DisplayTitle)
$EncodedTenantName = [System.Web.HttpUtility]::HtmlEncode($TenantFilter)
$AlertHeader = "$EncodedTaskName
Tenant: $EncodedTenantName
"
# Commands that also serve an HTTP caller return a single row carrying the result lines plus
@@ -223,11 +233,12 @@ function Send-CIPPScheduledTaskAlert {
$HTML += $AlertCommentHtml
}
- # Build title — honor CustomSubject if set on the task row, otherwise use default format
+ # Build title — honor CustomSubject if set on the task row, otherwise use DisplayTitle
+ # so multi-tenant alert Names do not leak other client labels into the ticket subject.
$title = if (![string]::IsNullOrWhiteSpace($TaskInfo.CustomSubject)) {
"$($TaskInfo.CustomSubject) - $TenantFilter"
} else {
- "$TaskType - $TenantFilter - $($TaskInfo.Name)"
+ "$TaskType - $TenantFilter - $DisplayTitle"
}
if ($TaskInfo.Reference) {
$title += " - Reference: $($TaskInfo.Reference)"
diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
index 7a41e884f0b6b..b71b3360b019f 100644
--- a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
+++ b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1
@@ -117,51 +117,46 @@ function Test-CIPPAccessPermissions {
}
- if ($GraphPermissions.GrantCheckFailed) {
- $ErrorMessages.Add("Could not verify which permissions are granted on the CIPP-SAM application: $($GraphPermissions.GrantCheckError)") | Out-Null
- $Success = $false
- } else {
- $MissingSamPermissions = $GraphPermissions.MissingPermissions
- if (($MissingSamPermissions.PSObject.Properties.Name | Measure-Object).Count -gt 0) {
+ $MissingSamPermissions = $GraphPermissions.MissingPermissions
+ if (($MissingSamPermissions.PSObject.Properties.Name | Measure-Object).Count -gt 0) {
- $MissingPermissions = foreach ($AppId in $MissingSamPermissions.PSObject.Properties.Name) {
- $ServicePrincipal = $GraphPermissions.UsedServicePrincipals | Where-Object -Property appId -EQ $AppId
+ $MissingPermissions = foreach ($AppId in $MissingSamPermissions.PSObject.Properties.Name) {
+ $ServicePrincipal = $GraphPermissions.UsedServicePrincipals | Where-Object -Property appId -EQ $AppId
- foreach ($Permission in $MissingSamPermissions.$AppId.applicationPermissions) {
- [PSCustomObject]@{
- Application = $ServicePrincipal.displayName
- Type = 'Application'
- PermissionId = $Permission.id
- Permission = $Permission.value
- }
+ foreach ($Permission in $MissingSamPermissions.$AppId.applicationPermissions) {
+ [PSCustomObject]@{
+ Application = $ServicePrincipal.displayName
+ Type = 'Application'
+ PermissionId = $Permission.id
+ Permission = $Permission.value
}
- foreach ($Permission in $MissingSamPermissions.$AppId.delegatedPermissions) {
- [PSCustomObject]@{
- Application = $ServicePrincipal.displayName
- Type = 'Delegated'
- PermissionId = $Permission.id
- Permission = $Permission.value
- }
+ }
+ foreach ($Permission in $MissingSamPermissions.$AppId.delegatedPermissions) {
+ [PSCustomObject]@{
+ Application = $ServicePrincipal.displayName
+ Type = 'Delegated'
+ PermissionId = $Permission.id
+ Permission = $Permission.value
}
}
- $Success = $false
-
- # Until now this only ever surfaced on the permissions page, so the only way to find
- # out that CIPP needs new consent was to go and look. Log it so it reaches the
- # notification pipeline like any other alert. Write-AlertMessage de-duplicates per
- # day, so a check that runs on every page load doesn't repeat itself.
- # Logged against the partner tenant - it's the CIPP application that needs consent,
- # not a customer's tenant.
- $MissingCount = ($MissingPermissions | Measure-Object).Count
- $MissingSummary = ($MissingPermissions | ForEach-Object { $_.Permission } | Sort-Object -Unique) -join ', '
- # Select-Object -First 1 because an empty TenantFilter makes Get-Tenants return every
- # tenant, which would otherwise land every domain in the alert's Tenant field.
- $PartnerTenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1
- $AlertTenant = if ($PartnerTenant.defaultDomainName) { $PartnerTenant.defaultDomainName } else { 'None' }
- Write-AlertMessage -tenant $AlertTenant -tenantId $PartnerTenant.customerId -message "CIPP has $MissingCount new permission(s) to apply: $MissingSummary. Review and apply them under CIPP > Application Settings > Permissions."
- } else {
- $Messages.Add('You have all the required permissions.') | Out-Null
}
+ $Success = $false
+
+ # Until now this only ever surfaced on the permissions page, so the only way to find
+ # out that CIPP needs new consent was to go and look. Log it so it reaches the
+ # notification pipeline like any other alert. Write-AlertMessage de-duplicates per
+ # day, so a check that runs on every page load doesn't repeat itself.
+ # Logged against the partner tenant - it's the CIPP application that needs consent,
+ # not a customer's tenant.
+ $MissingCount = ($MissingPermissions | Measure-Object).Count
+ $MissingSummary = ($MissingPermissions | ForEach-Object { $_.Permission } | Sort-Object -Unique) -join ', '
+ # Select-Object -First 1 because an empty TenantFilter makes Get-Tenants return every
+ # tenant, which would otherwise land every domain in the alert's Tenant field.
+ $PartnerTenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1
+ $AlertTenant = if ($PartnerTenant.defaultDomainName) { $PartnerTenant.defaultDomainName } else { 'None' }
+ Write-AlertMessage -tenant $AlertTenant -tenantId $PartnerTenant.customerId -message "CIPP has $MissingCount new permission(s) to apply: $MissingSummary. Review and apply them under CIPP > Application Settings > Permissions."
+ } else {
+ $Messages.Add('You have all the required permissions.') | Out-Null
}
$ApplicationToken = Get-GraphToken -returnRefresh $true -SkipCache $true -AsApp $true
diff --git a/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1 b/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1
index 0cc80cad7318e..050077fb82185 100644
--- a/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1
+++ b/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1
@@ -16,12 +16,16 @@ function Convert-CippExecStandardsToControls {
The listStandardTemplates Body, used only to resolve Intune/CA template GUIDs to display names.
.PARAMETER Catalog
The parsed Config\standards.json catalog (array of standard definitions).
+ .PARAMETER TemplateNames
+ Template GUID -> display name, read straight from the templates table (CATemplate/IntuneTemplate
+ rows). Takes precedence over names found in -Templates.
#>
[CmdletBinding()]
param(
$Compare,
$Templates = @(),
- $Catalog = @()
+ $Catalog = @(),
+ [hashtable]$TemplateNames = @{}
)
# JS parity: JSON.stringify of an object whose top-level keys are sorted. Nested objects keep their
@@ -72,6 +76,10 @@ function Convert-CippExecStandardsToControls {
}
}
+ foreach ($Guid in $TemplateNames.Keys) {
+ if (-not [string]::IsNullOrWhiteSpace([string]$TemplateNames[$Guid])) { $TemplateMap[([string]$Guid).ToLower()] = [string]$TemplateNames[$Guid] }
+ }
+
# Catalog by full standard name (e.g. 'standards.CopilotSettings').
$CatalogMap = @{}
foreach ($C in @($Catalog)) { if ($C.name) { $CatalogMap[[string]$C.name] = $C } }
diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1
index fc718836ac02b..2a6ca687395cf 100644
--- a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1
+++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1
@@ -5,8 +5,8 @@ function Update-CIPPSAMCertificate {
.DESCRIPTION
Loads Key Vault SAMCertificate current/previous versions as the Entra keep-set.
- On every run, removes proven CIPP debris (older KV thumbprints or CIPP-SAM displayName
- orphans) while preserving unknown credentials. Mints only when missing, near expiry,
+ On every run, removes proven CIPP debris (older KV thumbprints from this instance's own
+ history) while preserving every other credential. Mints only when missing, near expiry,
or -Force. Drift re-registers the same stored public key without minting. Renewal
keeps at most the previous current plus the new cert, then stores the PFX under the
same KV name. Store failure rolls back only the newly added credential.
@@ -265,9 +265,9 @@ function Get-CIPPSAMCredentialClassification {
continue
}
- $IsCippDisplayName = $Credential.displayName -like 'CIPP-SAM Certificate*'
- $InHistorical = $HistoricalThumbprints.Contains($Thumbprint)
- if ($InHistorical -or $IsCippDisplayName) {
+ # Only prune certs this instance itself stored (any KV version outside the keep-set).
+ # Other instances sharing the SAM app use the same displayName prefix; never touch theirs.
+ if ($HistoricalThumbprints.Contains($Thumbprint) -or $KnownThumbprints -contains $Thumbprint) {
$RemovedCount++
Write-Information "Pruning CIPP key credential $($Credential.keyId) thumbprint=$Thumbprint displayName=$($Credential.displayName)"
continue
diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1
index 8b24005bf76ac..9c669b39aa032 100644
--- a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1
+++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1
@@ -33,7 +33,10 @@ function Push-AuditLogSearchCreationV2 {
try {
$Ledger = Get-CippTable -TableName 'AuditLogCoverage'
- $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter'")
+ # Whole partition: re-planned MANUAL-* rows need re-creating too. No split markers - these rows never split.
+ $TenantKey = ([string]$TenantFilter).Replace("'", "''")
+ $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantKey'" `
+ -Property 'RowKey', 'WindowStart', 'WindowEnd', 'State', 'NextAttemptUtc', 'Attempts', 'RetryCount', 'ThrottleCount')
$Now = (Get-Date).ToUniversalTime()
# 1) Seed owed regular + reconciliation windows as Planned.
diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1
index 9a2a1bc8e99b7..0c38d6d4263f9 100644
--- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1
+++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1
@@ -38,6 +38,21 @@ function Set-CIPPDBCacheDefenderCVEs {
$RecordCount = 0
$SkippedCount = 0
+ # Tenant-wide device tables. TVM repeats every device once per (software x CVE), so a
+ # per-CVE copy of each device's id and JSON text costs ~350-400 bytes per CVE x device
+ # pair - hundreds of MB on a large tenant, all retained until the stream ends. Instead
+ # each distinct device is stored once and a CVE bucket holds small integer indexes:
+ # $DeviceKeyIndex dedupe key (id, else name; case-insensitive) -> key index
+ # $FragmentIndex exact id + name text -> fragment index
+ # $DeviceFragments fragment index -> the {deviceId, deviceName} JSON text
+ # Two indexes rather than one because dedupe is case-insensitive but the stored text is
+ # whatever the CVE's first record for that device said, exactly as before. Read by index (a
+ # missing key is $null), not TryGetValue: a [ref] out-parameter costs several times an index
+ # lookup per record.
+ $DeviceKeyIndex = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ $FragmentIndex = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::Ordinal)
+ $DeviceFragments = [System.Collections.Generic.List[string]]::new()
+
Get-DefenderTvmRaw -TenantId $TenantFilter -Stream | ForEach-Object {
$Vuln = $_
$RecordCount++
@@ -60,52 +75,45 @@ function Set-CIPPDBCacheDefenderCVEs {
vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? ''
exploitabilityLevel = $Vuln.exploitabilityLevel ?? ''
- # Device metadata as the JSON text it will be stored as, not as objects.
- DeviceJson = [System.Text.StringBuilder]::new()
- DeviceCount = 0
- # Dedupe devices by id so DeviceCount is a unique-device count and the
- # stored list carries each affected device once, however many software
- # packages reported the same CVE on it.
- SeenDevices = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ # Dedupe key indexes seen on this CVE, so DeviceCount is a unique-device
+ # count and each affected device is stored once however many software
+ # packages reported the CVE on it.
+ SeenDevices = [System.Collections.Generic.HashSet[int]]::new()
+ # Fragment indexes in first-seen order - the order the row lists them in.
+ Devices = [System.Collections.Generic.List[int]]::new()
}
}
- # Extract this device instance and fold it in as serialised text immediately.
- #
- # The aggregation itself is unavoidable: TVM returns one record per
- # (device x software x CVE), so a CVE's records are scattered across the whole
- # stream and its row cannot be written until the stream ends. What IS avoidable is
- # keeping every record as a live object until then. This previously held one
- # hashtable per record in a List per CVE - on a large tenant that is hundreds of
- # thousands of hashtables, each carrying its own dictionary overhead plus six
- # strings, and it is the single largest thing this job retains.
- #
- # Serialising on arrival keeps the same bytes in one allocation instead of eight,
- # and lets the source record become collectable straight away. It also removes the
- # second copy that used to exist at emit time, where a CVE's whole device List and
- # the JSON produced from it were both live at once.
- #
# Minimal per-device payload: only the id and name are consumed downstream.
$DeviceId = ($Vuln.deviceId -join ',') ?? ''
$DeviceName = ($Vuln.deviceName -join ',') ?? ''
- # Dedupe on the device id (falling back to the name) so one device that reports
- # the same CVE across several software packages is stored and counted once.
+ # Dedupe on the device id (falling back to the name).
$DeviceKey = if ($DeviceId) { $DeviceId } else { $DeviceName }
+ if (-not $DeviceKey) { return }
+
+ $KeyIndex = $DeviceKeyIndex[$DeviceKey]
+ if ($null -eq $KeyIndex) {
+ $KeyIndex = $DeviceKeyIndex.Count
+ $DeviceKeyIndex[$DeviceKey] = $KeyIndex
+ }
+
$Bucket = $CveAggregator[$CveId]
- if ($DeviceKey -and $Bucket.SeenDevices.Add($DeviceKey)) {
- # ConvertTo-Json builds the fragment rather than string interpolation, so
- # escaping of device names stays correct.
- $Fragment = @{
- deviceId = $DeviceId
- deviceName = $DeviceName
- } | ConvertTo-Json -Compress
-
- # Appended only after the fragment is fully built, so a record that fails
- # mid-extraction cannot leave a partial payload attached to the wrong CVE.
- if ($Bucket.DeviceCount -gt 0) { [void]$Bucket.DeviceJson.Append(',') }
- [void]$Bucket.DeviceJson.Append($Fragment)
- $Bucket.DeviceCount++
+ if ($Bucket.SeenDevices.Add($KeyIndex)) {
+ $FragmentKey = "$DeviceId`0$DeviceName"
+ $Fragment = $FragmentIndex[$FragmentKey]
+ if ($null -eq $Fragment) {
+ # ConvertTo-Json builds the fragment rather than string interpolation, so
+ # escaping of device names stays correct. Built once per device, not per
+ # CVE x device pair.
+ $DeviceFragments.Add((@{
+ deviceId = $DeviceId
+ deviceName = $DeviceName
+ } | ConvertTo-Json -Compress))
+ $Fragment = $DeviceFragments.Count - 1
+ $FragmentIndex[$FragmentKey] = $Fragment
+ }
+ $Bucket.Devices.Add($Fragment)
}
} catch {
$SkippedCount++
@@ -159,12 +167,10 @@ function Set-CIPPDBCacheDefenderCVEs {
# A single-device CVE stays a bare object and a multi-device CVE becomes an
# array, which is what piping a List through ConvertTo-Json used to produce and
# what Get-CIPPCVEReport and the CVE management endpoint parse.
- $CompactDeviceJson = if ($CveData.DeviceCount -eq 1) {
- $CveData.DeviceJson.ToString()
- } else {
- [void]$CveData.DeviceJson.Insert(0, '[').Append(']')
- $CveData.DeviceJson.ToString()
- }
+ $DeviceCount = $CveData.Devices.Count
+ $Parts = [string[]]::new($DeviceCount)
+ for ($i = 0; $i -lt $DeviceCount; $i++) { $Parts[$i] = $DeviceFragments[$CveData.Devices[$i]] }
+ $CompactDeviceJson = if ($DeviceCount -eq 1) { $Parts[0] } else { '[' + [string]::Join(',', $Parts) + ']' }
@{
PartitionKey = $CveKey
@@ -182,7 +188,7 @@ function Set-CIPPDBCacheDefenderCVEs {
exploitabilityLevel = $CveData.exploitabilityLevel
# Unique affected-device count for this CVE in this tenant.
- deviceCount = $CveData.DeviceCount
+ deviceCount = $DeviceCount
# Minimal per-device detail ({deviceId, deviceName}) as one JSON string.
deviceDetailsJson = $CompactDeviceJson
@@ -190,7 +196,7 @@ function Set-CIPPDBCacheDefenderCVEs {
lastUpdated = $LastUpdated
}
- # The row is built; drop the bucket so its device list is collectable
+ # The row is built; drop the bucket so its index lists are collectable
# before the next CVE is serialised.
$CveAggregator.Remove($CveKey)
}
diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecReportAttachmentRetentionConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecReportAttachmentRetentionConfig.ps1
new file mode 100644
index 0000000000000..0aa7d10c2ef0f
--- /dev/null
+++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecReportAttachmentRetentionConfig.ps1
@@ -0,0 +1,61 @@
+function Invoke-ExecReportAttachmentRetentionConfig {
+ <#
+ .FUNCTIONALITY
+ Entrypoint, AnyTenant
+ .ROLE
+ CIPP.AppSettings.ReadWrite
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+ $Table = Get-CIPPTable -TableName Config
+ $Filter = "PartitionKey eq 'ReportAttachmentRetention' and RowKey eq 'Settings'"
+
+ $results = try {
+ if ($Request.Query.List) {
+ $RetentionSettings = Get-CIPPAzDataTableEntity @Table -Filter $Filter
+ if (!$RetentionSettings) {
+ # Return default values if not set
+ @{
+ RetentionDays = 360
+ }
+ } else {
+ @{
+ RetentionDays = [int]$RetentionSettings.RetentionDays
+ }
+ }
+ } else {
+ $RetentionDays = [int]$Request.Body.RetentionDays
+
+ # Validate minimum value
+ if ($RetentionDays -lt 7) {
+ throw 'Retention days must be at least 7 days'
+ }
+
+ # Validate maximum value
+ if ($RetentionDays -gt 365) {
+ throw 'Retention days must be at most 365 days'
+ }
+
+ $RetentionConfig = @{
+ 'RetentionDays' = $RetentionDays
+ 'PartitionKey' = 'ReportAttachmentRetention'
+ 'RowKey' = 'Settings'
+ }
+
+ Add-CIPPAzDataTableEntity @Table -Entity $RetentionConfig -Force | Out-Null
+ Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Set report attachment retention to $RetentionDays days" -Sev 'Info'
+ "Successfully set report attachment retention to $RetentionDays days"
+ }
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to set report attachment retention configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ "Failed to set configuration: $($ErrorMessage.NormalizedError)"
+ }
+
+ $body = [pscustomobject]@{'Results' = $Results }
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ Body = $body
+ })
+}
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 eb7c3db9c246f..3974edd519321 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
@@ -18,8 +18,8 @@ function Invoke-ExecSAMAppPermissions {
# Persist the full applied set = manifest base ∪ submitted extras, so the AppPermissions
# table always reflects everything the CIPP-SAM app should have (the manifest is always
- # applied and cannot be removed). The grant flow reads this table when applying
- # permissions to the service principal, which is what the permission check diffs against.
+ # applied and cannot be removed). Get-CippSamPermissions diffs the manifest against this
+ # table to decide when a Permissions repair is needed.
$Applied = @{}
$AppIds = @(@($ManifestPermissions.PSObject.Properties.Name) + @($Submitted.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique
foreach ($AppId in $AppIds) {
diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1
index e5eb3bfb09106..f0d98568dfae3 100644
--- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1
+++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1
@@ -156,7 +156,23 @@ function Invoke-ExecGetExecutiveReportPdf {
$CatPath = Join-Path $env:CIPPRootPath 'Config\standards.json'
if (Test-Path $CatPath) { $Catalog = @(Get-Content $CatPath -Raw | ConvertFrom-Json -Depth 20) }
} catch { $Catalog = @() }
- $SecurityControls = @(Convert-CippExecStandardsToControls -Compare @([pscustomobject]$TenantStd) -Catalog $Catalog)
+ # CA/Intune template standards are keyed by template GUID; resolve those to the template's display name.
+ $TemplateNames = @{}
+ if ($TenantStd.Keys -match '^standards\.(ConditionalAccessTemplate|IntuneTemplate)\.') {
+ try {
+ $TemplatesTable = Get-CIPPTable -TableName 'templates'
+ foreach ($Template in @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'CATemplate' or PartitionKey eq 'IntuneTemplate'")) {
+ try {
+ $Content = $Template.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop
+ $DisplayName = [string]($Content.displayName ?? $Content.Displayname)
+ if ([string]::IsNullOrWhiteSpace($DisplayName)) { continue }
+ $TemplateNames["$($Template.RowKey)"] = $DisplayName
+ if ($Template.GUID) { $TemplateNames["$($Template.GUID)"] = $DisplayName }
+ } catch { Write-Information "Executive report: skipped unreadable template $($Template.RowKey): $($_.Exception.Message)" }
+ }
+ } catch { Write-Information "Executive report: template names unavailable - $($_.Exception.Message)" }
+ }
+ $SecurityControls = @(Convert-CippExecStandardsToControls -Compare @([pscustomobject]$TenantStd) -Catalog $Catalog -TemplateNames $TemplateNames)
}
} catch { Write-Information "Executive report: standards unavailable - $($_.Exception.Message)" }
diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableEWS.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableEWS.ps1
index 224e30efd7b8e..74c99a75ea4db 100644
--- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableEWS.ps1
+++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableEWS.ps1
@@ -7,7 +7,7 @@ function Invoke-CIPPStandardDisableEWS {
.SYNOPSIS
(Label) Disable Exchange Web Services
.DESCRIPTION
- (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.
+ (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. Conflicts with the "Configure EWS allowed applications" standard, which sets EwsEnabled to true: do not apply both to a tenant.
(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.
.NOTES
CAT
diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEWSAllowedAppIds.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEWSAllowedAppIds.ps1
new file mode 100644
index 0000000000000..f2e62f40de07e
--- /dev/null
+++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEWSAllowedAppIds.ps1
@@ -0,0 +1,114 @@
+function Invoke-CIPPStandardEWSAllowedAppIds {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .COMPONENT
+ (APIName) EWSAllowedAppIds
+ .SYNOPSIS
+ (Label) Configure EWS allowed applications
+ .DESCRIPTION
+ (Helptext) Adds the selected applications to the Exchange Online EWS app allow list (EwsAllowedAppIDs) and sets EwsEnabled to true. Apps already on the list are kept, and known-malicious apps are never added. Do not use together with the "Disable Exchange Web Services" standard on the same tenant: they set EwsEnabled to opposite values. Once CIPP writes the list, Microsoft stops auto-populating it for that tenant, so include every app the tenant needs (check the EWS usage report in the Microsoft 365 admin center). Changes can take up to 24 hours to apply. Keep "Include the dedicated Exchange hybrid app" on for hybrid organisations so Free/Busy and MailTips keep working.
+ (DocsDescription) When EWS is enabled, Exchange Online only allows EWS access from the application IDs on the organization's EwsAllowedAppIDs list, and an empty list blocks all EWS. Microsoft populates that list from recent usage only while the admin has never set it, so the list CIPP writes must be complete. This standard reads the current list and adds the selected presets, the custom application IDs (tenant variables are supported, invalid IDs are skipped with a warning) and, optionally, every app that holds the Exchange Online full_access_as_app application permission or a delegated EWS permission, plus the dedicated Exchange hybrid app (ExchangeServerApp-*) that hybrid Free/Busy, MailTips, profile photos and archive moves depend on. IDs already on the list are never removed; the only exception is known-malicious apps (CIPP's curated list), which are removed only when that option is enabled and are reported as drift otherwise. Known-malicious apps are never added. This conflicts with the "Disable Exchange Web Services" standard. Changes can take up to 24 hours to take effect. See Microsoft's guidance on the deprecation of EWS in Exchange Online: https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-ews-exchange-online
+ .NOTES
+ CAT
+ Exchange Standards
+ TAG
+ EXECUTIVETEXT
+ Keeps the business applications that still rely on Exchange Web Services working as Microsoft retires unrestricted EWS access, by maintaining the approved-application list for each tenant. Existing approvals are preserved and applications known to be used in attacks are never approved.
+ ADDEDCOMPONENT
+ {"type":"autoComplete","multiple":true,"creatable":false,"required":false,"name":"standards.EWSAllowedAppIds.presets","label":"Known applications to allow (empty = Microsoft Office, Power Query for Excel, Power BI Data Refresh, Apple Mail/Calendar)","options":[{"label":"Microsoft Office","value":"MicrosoftOffice"},{"label":"Microsoft Power Query for Excel","value":"PowerQuery"},{"label":"Power BI Data Refresh","value":"PowerBIDataRefresh"},{"label":"Apple Mail/Calendar (macOS)","value":"AppleMail"},{"label":"AvePoint Cloud Backup / Fly / Cloud Governance (hosted)","value":"AvePointCloud"},{"label":"AvePoint Fly Server","value":"AvePointFlyServer"}]}
+ {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.EWSAllowedAppIds.customAppIds","label":"Additional application (client) IDs, tenant variables such as %veeam_ews_appid% are supported"}
+ {"type":"switch","name":"standards.EWSAllowedAppIds.includeEwsPermissionApps","label":"Include apps holding EWS permissions (full_access_as_app, EWS.AccessAsUser.All, full_access_as_user)"}
+ {"type":"switch","name":"standards.EWSAllowedAppIds.includeHybridApp","label":"Include the dedicated Exchange hybrid app (ExchangeServerApp-*)","defaultValue":true}
+ {"type":"switch","name":"standards.EWSAllowedAppIds.removeMaliciousApps","label":"Remove known-malicious apps from the list"}
+ IMPACT
+ High Impact
+ ADDEDDATE
+ 2026-09-28
+ POWERSHELLEQUIVALENT
+ Set-OrganizationConfig -EwsEnabled $true -EwsAllowedAppIDs
+ RECOMMENDEDBY
+ REQUIREDCAPABILITIES
+ "EXCHANGE_S_STANDARD"
+ "EXCHANGE_S_ENTERPRISE"
+ "EXCHANGE_S_STANDARD_GOV"
+ "EXCHANGE_S_ENTERPRISE_GOV"
+ "EXCHANGE_LITE"
+ UPDATECOMMENTBLOCK
+ Run the Tools\Update-StandardsComments.ps1 script to update this comment block
+ .LINK
+ https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards
+ https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-ews-exchange-online
+ #>
+
+ param($Tenant, $Settings)
+ $TestResult = Test-CIPPStandardLicense -StandardName 'EWSAllowedAppIds' -TenantFilter $Tenant -Preset Exchange
+
+ if ($TestResult -eq $false) {
+ return $true
+ }
+
+ $StateParams = @{
+ TenantFilter = $Tenant
+ Presets = $Settings.presets
+ CustomAppIds = $Settings.customAppIds
+ IncludeEwsPermissionApps = [bool]$Settings.includeEwsPermissionApps
+ IncludeHybridApp = $Settings.includeHybridApp -ne $false
+ RemoveMaliciousApps = [bool]$Settings.removeMaliciousApps
+ }
+ try {
+ $State = Get-CIPPEwsAllowedAppIdState @StateParams
+ } catch {
+ $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the EWS allowed applications for $Tenant. Error: $ErrorMessage" -Sev Error
+ return
+ }
+
+ if ($Settings.remediate -eq $true) {
+ if (-not $State.NeedsWrite) {
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'EWS allowed applications are already configured.' -Sev Info
+ } else {
+ try {
+ New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{ EwsEnabled = $true; EwsAllowedAppIDs = ($State.DesiredAppIds -join ',') } -UseSystemMailbox $true
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Enabled EWS and set the EWS allowed applications. Added: $(if ($State.MissingAppIds.Count -gt 0) { $State.MissingAppIds -join ', ' } else { 'none' })." -Sev Info
+ $RemovedMalicious = @($State.MaliciousAppIdsPresent | Where-Object { $State.DesiredAppIds -notcontains $_ })
+ $State.EwsEnabled = $true
+ $State.MissingAppIds = @()
+ $State.MaliciousAppIdsPresent = @($State.MaliciousAppIdsPresent | Where-Object { $RemovedMalicious -notcontains $_ })
+ } catch {
+ $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to set the EWS allowed applications. Error: $ErrorMessage" -Sev Error
+ }
+ }
+ }
+
+ $Compliant = $State.EwsEnabled -eq $true -and $State.MissingAppIds.Count -eq 0 -and $State.MaliciousAppIdsPresent.Count -eq 0
+
+ if ($Settings.alert -eq $true) {
+ if ($Compliant) {
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'EWS is enabled and all required applications are on the EWS allow list.' -Sev Info
+ } else {
+ $Problems = [System.Collections.Generic.List[string]]::new()
+ if ($State.EwsEnabled -ne $true) { $Problems.Add('EWS is not enabled') }
+ if ($State.MissingAppIds.Count -gt 0) { $Problems.Add("missing app IDs: $($State.MissingAppIds -join ', ')") }
+ if ($State.MaliciousAppIdsPresent.Count -gt 0) { $Problems.Add("known-malicious app IDs on the list: $($State.MaliciousAppIdsPresent -join ', ')") }
+ $Message = "EWS allowed applications are not compliant: $($Problems -join '; ')."
+ Write-StandardsAlert -message $Message -object $State -tenant $Tenant -standardName 'EWSAllowedAppIds' -standardId $Settings.standardId
+ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message $Message -Sev Info
+ }
+ }
+
+ if ($Settings.report -eq $true) {
+ $CurrentValue = [PSCustomObject]@{
+ EwsEnabled = $State.EwsEnabled -eq $true
+ MissingAppIds = @($State.MissingAppIds)
+ MaliciousAppIdsPresent = @($State.MaliciousAppIdsPresent)
+ }
+ $ExpectedValue = [PSCustomObject]@{
+ EwsEnabled = $true
+ MissingAppIds = @()
+ MaliciousAppIdsPresent = @()
+ }
+ Set-CIPPStandardsCompareField -FieldName 'standards.EWSAllowedAppIds' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant
+ }
+}
diff --git a/Modules/CIPPTests/Public/Helpers/Invoke-CippSecuritySimulationTest.ps1 b/Modules/CIPPTests/Public/Helpers/Invoke-CippSecuritySimulationTest.ps1
index 6c0e65873078e..962f5b5adda38 100644
--- a/Modules/CIPPTests/Public/Helpers/Invoke-CippSecuritySimulationTest.ps1
+++ b/Modules/CIPPTests/Public/Helpers/Invoke-CippSecuritySimulationTest.ps1
@@ -25,13 +25,7 @@ function Invoke-CippSecuritySimulationTest {
return Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -Name $ScenarioId -ResultMarkdown "Scenario '$ScenarioId' is not defined."
}
- $Capabilities = $(try { Get-CIPPTenantCapabilities -TenantFilter $Tenant } catch { $null })
- $IsLicensed = {
- param($Required)
- $Needed = @($Required | Where-Object { $_ })
- $Needed.Count -eq 0 -or @($Needed | Where-Object { $Capabilities.$_ -eq $true }).Count -gt 0
- }
- $Licensed = & $IsLicensed $Scenario.requiredCapabilities
+ $Licensed = -not $Scenario.licensePresets -or (Test-CIPPStandardLicense -StandardName $TestId -TenantFilter $Tenant -Preset $Scenario.licensePresets -SkipLog)
$AlignmentTable = Get-CippTable -tablename 'BaselineAlignment'
$SafeTenant = ConvertTo-CIPPODataFilterValue -Value $Tenant
@@ -61,7 +55,7 @@ function Invoke-CippSecuritySimulationTest {
'^Skipped - No License$' { $State.status = 'License missing'; $State.compliant = $null }
default { $State.status = 'No data'; $State.compliant = $null }
}
- } elseif (-not (& $IsLicensed $Definition.requiredCapabilities)) {
+ } elseif ($Definition.requiredCapabilities -and -not (Test-CIPPStandardLicense -StandardName $Name -TenantFilter $Tenant -RequiredCapabilities @($Definition.requiredCapabilities | ForEach-Object { $_ }) -SkipLog)) {
$State.status = 'License missing'
$State.compliant = $null
} else {
@@ -150,7 +144,9 @@ function Invoke-CippSecuritySimulationTest {
$Steps = @($Scenario.steps | Where-Object { $_ })
$Persona = $(if ("$($Scenario.persona)") { "$($Scenario.persona)" } else { 'user' })
$NeedsIdentity = @($Steps | Where-Object { $_.whatIf }).Count -gt 0
- $Identity = $(if ($NeedsIdentity -and $Licensed) { Resolve-CIPPSimulationIdentity -TenantFilter $Tenant -Persona $Persona } else { $null })
+ # The What If API itself needs Entra ID P1 or P2, whatever else the scenario is licensed for.
+ $CALicensed = $NeedsIdentity -and $Licensed -and (Test-CIPPStandardLicense -StandardName $TestId -TenantFilter $Tenant -Preset Entra -SkipLog)
+ $Identity = $(if ($CALicensed) { Resolve-CIPPSimulationIdentity -TenantFilter $Tenant -Persona $Persona } else { $null })
$AttackerCanSatisfy = @($Scenario.attackerCanSatisfy | Where-Object { $_ })
$WhatIfCalls = 0
@@ -170,8 +166,8 @@ function Invoke-CippSecuritySimulationTest {
$WhatIf = $null
if ($Step.whatIf) {
- if (-not $Licensed) {
- $WhatIf = [PSCustomObject]@{ verdict = 'unknown'; detail = 'unlicensed'; error = 'This tenant is not licensed for Conditional Access.'; gaps = @(); policies = @() }
+ if (-not $CALicensed) {
+ $WhatIf = [PSCustomObject]@{ verdict = 'unknown'; detail = 'unlicensed'; error = $(if ($Licensed) { 'This tenant is not licensed for Conditional Access (Entra ID P1 or P2).' } else { 'This tenant is not licensed for the capabilities this scenario needs.' }); gaps = @(); policies = @() }
$WhatIfSkipped = $true
} elseif (-not $Identity) {
$WhatIf = [PSCustomObject]@{ verdict = 'unknown'; detail = 'noIdentity'; error = "No $Persona account is available in the cache to evaluate this sign-in."; gaps = @(); policies = @() }
@@ -186,7 +182,7 @@ function Invoke-CippSecuritySimulationTest {
} else {
$Verdict = Get-CIPPCAWhatIfVerdict -Policies $Evaluation.Policies -AttackerCanSatisfy $AttackerCanSatisfy
$Gaps = foreach ($Gap in @($Step.gaps | Where-Object { $_ })) {
- $GapLicensed = & $IsLicensed $Gap.requiredCapabilities
+ $GapLicensed = -not $Gap.licensePresets -or (Test-CIPPStandardLicense -StandardName $TestId -TenantFilter $Tenant -Preset $Gap.licensePresets -SkipLog)
$When = @($Gap.when | Where-Object { $_ } | ForEach-Object { "$_".ToLower() })
$Triggered = $GapLicensed -and (
($When -contains 'allowed' -and $Verdict.verdict -eq 'allowed') -or
diff --git a/Modules/CIPPTests/Public/Tests/SecuritySimulations/scenarios.json b/Modules/CIPPTests/Public/Tests/SecuritySimulations/scenarios.json
index 13496030fa005..fe05ab08a4395 100644
--- a/Modules/CIPPTests/Public/Tests/SecuritySimulations/scenarios.json
+++ b/Modules/CIPPTests/Public/Tests/SecuritySimulations/scenarios.json
@@ -5,13 +5,9 @@
"category": "Identity & Conditional Access",
"severity": "Critical",
"summary": "A Global Administrator signs in from an unmanaged, non-compliant computer, giving an attacker on that machine full control of the tenant.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "admin",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -31,10 +27,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "Admins are not required to use a compliant device, so any machine can hold an admin session.",
"role": "prevents",
"fix": {
@@ -88,9 +81,7 @@
"category": "SharePoint & Data",
"severity": "High",
"summary": "An attacker creates an Anyone link on a sensitive SharePoint site so files can be downloaded without signing in.",
- "requiredCapabilities": [
- "SHAREPOINTENTERPRISE"
- ],
+ "licensePresets": ["SharePoint"],
"steps": [
{
"id": "Assumption",
@@ -214,9 +205,7 @@
"category": "SharePoint & Data",
"severity": "High",
"summary": "A compromised user syncs entire document libraries to a personal, unmanaged computer, copying company data off managed systems.",
- "requiredCapabilities": [
- "SHAREPOINTENTERPRISE"
- ],
+ "licensePresets": ["SharePoint"],
"persona": "user",
"steps": [
{
@@ -237,10 +226,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "Access from unmanaged devices is not restricted, so the sync client can copy whole libraries.",
"role": "prevents",
"fix": {
@@ -289,13 +275,9 @@
"category": "Identity & Conditional Access",
"severity": "High",
"summary": "An attacker tricks a user into approving a device-code sign-in and receives a fully authenticated token that already includes the MFA claim.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "user",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -317,10 +299,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "Device code flow is not blocked, so a phished code becomes a working token.",
"role": "prevents",
"fix": {
@@ -372,9 +351,7 @@
"category": "SharePoint & Data",
"severity": "Medium",
"summary": "A guest re-shares files they were given, spreading access to more outsiders than the owner intended.",
- "requiredCapabilities": [
- "SHAREPOINTENTERPRISE"
- ],
+ "licensePresets": ["SharePoint"],
"persona": "guest",
"steps": [
{
@@ -430,13 +407,9 @@
"category": "Identity & Conditional Access",
"severity": "High",
"summary": "A guest account that was granted a directory role is used to read the directory and hand privileged access to another account.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "guest",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -455,10 +428,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "Guest sign-ins are not restricted, so an external account can carry a privileged role.",
"role": "limits",
"fix": {
@@ -522,9 +492,7 @@
"category": "Identity & Conditional Access",
"severity": "High",
"summary": "An attacker uses an older mail protocol that cannot prompt for MFA to reach a mailbox with a stolen password.",
- "requiredCapabilities": [
- "EXCHANGE_S_ENTERPRISE"
- ],
+ "licensePresets": ["Exchange"],
"persona": "user",
"steps": [
{
@@ -544,10 +512,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "Legacy authentication is still allowed, so a password alone reaches the mailbox.",
"role": "prevents",
"fix": {
@@ -611,9 +576,7 @@
"category": "Exchange & Email",
"severity": "High",
"summary": "An attacker with mailbox access sets up inbox rules and forwarding to copy mail out of the tenant automatically.",
- "requiredCapabilities": [
- "EXCHANGE_S_ENTERPRISE"
- ],
+ "licensePresets": ["Exchange"],
"steps": [
{
"id": "Assumption",
@@ -743,13 +706,9 @@
"category": "Audit & Detection",
"severity": "High",
"summary": "A compromised administrator deletes many user accounts at once to disrupt the business.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "admin",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -768,10 +727,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "MFA is not enforced, so a stolen password reaches user management.",
"role": "prevents",
"fix": {
@@ -834,13 +790,9 @@
"category": "Audit & Detection",
"severity": "Critical",
"summary": "After taking over a privileged account, an attacker strips users' MFA and revokes their sessions to lock in control.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "admin",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -917,9 +869,7 @@
"category": "Identity & Conditional Access",
"severity": "High",
"summary": "An attacker guesses a common password for an account that has no MFA registered and signs in unchallenged.",
- "requiredCapabilities": [
- "AAD_PREMIUM_P2"
- ],
+ "licensePresets": ["Entra"],
"persona": "user",
"steps": [
{
@@ -940,10 +890,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "The account has no MFA, so a correct password alone grants access.",
"role": "prevents",
"fix": {
@@ -951,14 +898,10 @@
}
},
{
- "when": [
- "allowed"
- ],
+ "when": ["allowed"],
"text": "No sign-in risk policy, so the high-risk sign-in is not challenged.",
"role": "detects",
- "requiredCapabilities": [
- "AAD_PREMIUM_P2"
- ],
+ "licensePresets": ["EntraP2"],
"fix": {
"caTemplate": "Require multifactor authentication for medium and high sign-in risk"
}
@@ -1028,13 +971,9 @@
"category": "Identity & Conditional Access",
"severity": "Critical",
"summary": "An attacker who took over an account with role-management rights grants itself higher privileges and plants a back-door application.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "user",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -1054,10 +993,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "MFA is not enforced for this account, so a stolen password reaches role management.",
"role": "prevents",
"fix": {
@@ -1132,13 +1068,9 @@
"category": "Identity & Conditional Access",
"severity": "High",
"summary": "An attacker signs in from a country the organization never operates in and reaches a user's mailbox and files.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "user",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -1157,10 +1089,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "No location policy, so sign-ins from countries the business never uses are allowed.",
"role": "prevents",
"fix": {
@@ -1222,13 +1151,9 @@
"category": "Identity & Conditional Access",
"severity": "Critical",
"summary": "An attacker replays a session token stolen through a fake sign-in page and signs in as the user without re-entering a password or MFA.",
- "requiredCapabilities": [
- "AAD_PREMIUM"
- ],
+ "licensePresets": ["Entra"],
"persona": "user",
- "attackerCanSatisfy": [
- "mfa"
- ],
+ "attackerCanSatisfy": ["mfa"],
"steps": [
{
"id": "Assumption",
@@ -1258,10 +1183,7 @@
},
"gaps": [
{
- "when": [
- "allowed",
- "weakGrant"
- ],
+ "when": ["allowed", "weakGrant"],
"text": "No compliant-device requirement, so a replayed token from any machine is accepted.",
"role": "prevents",
"fix": {
@@ -1269,14 +1191,10 @@
}
},
{
- "when": [
- "allowed"
- ],
+ "when": ["allowed"],
"text": "No sign-in risk policy, so the medium-risk replay is not challenged.",
"role": "detects",
- "requiredCapabilities": [
- "AAD_PREMIUM_P2"
- ],
+ "licensePresets": ["EntraP2"],
"fix": {
"caTemplate": "Require multifactor authentication for medium and high sign-in risk"
}
@@ -1351,9 +1269,7 @@
"category": "Exchange & Email",
"severity": "High",
"summary": "An attacker with Exchange admin rights creates an organization-wide transport rule that blind-copies mail to an outside address.",
- "requiredCapabilities": [
- "EXCHANGE_S_ENTERPRISE"
- ],
+ "licensePresets": ["Exchange"],
"steps": [
{
"id": "Assumption",
diff --git a/Modules/CippExtensions/Public/Extension Functions/Get-CippExtensionReportingData.ps1 b/Modules/CippExtensions/Public/Extension Functions/Get-CippExtensionReportingData.ps1
index 1d2fe899da251..531919229776a 100644
--- a/Modules/CippExtensions/Public/Extension Functions/Get-CippExtensionReportingData.ps1
+++ b/Modules/CippExtensions/Public/Extension Functions/Get-CippExtensionReportingData.ps1
@@ -14,6 +14,10 @@ function Get-CippExtensionReportingData {
.PARAMETER IncludeMailboxes
Include mailbox data (requires separate cache run with Type 'Mailboxes')
+ .PARAMETER SkipMailboxPermissions
+ With -IncludeMailboxes, leave out MailboxPermissions (the whole tenant's permission set) for callers that
+ do not read it.
+
.EXAMPLE
$ExtensionCache = Get-CippExtensionReportingData -TenantFilter 'contoso.onmicrosoft.com'
@@ -29,40 +33,37 @@ function Get-CippExtensionReportingData {
[string]$TenantFilter,
[Parameter(Mandatory = $false)]
- [switch]$IncludeMailboxes
+ [switch]$IncludeMailboxes,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$SkipMailboxPermissions
)
try {
$Return = @{}
- # Direct mappings - loop through items and parse each .Data property (filter out count entries)
- $UsersItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Users = if ($UsersItems) { $UsersItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $DomainsItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Domains' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Domains = if ($DomainsItems) { $DomainsItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $ConditionalAccessItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'ConditionalAccessPolicies' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.ConditionalAccess = if ($ConditionalAccessItems) { $ConditionalAccessItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $ManagedDevicesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Devices = if ($ManagedDevicesItems) { $ManagedDevicesItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
+ # Parse each type straight off the row stream, so a type's raw rows are released before the next type
+ # is read instead of every type's raw rows staying pinned beside the parsed objects until return.
+ # Same shapes as before: no rows -> $null, one row -> the object, several -> an array.
+ $Read = {
+ param($Type)
+ Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type | Where-Object { $_.RowKey -notlike '*-Count' } | ForEach-Object { $_.Data | ConvertFrom-Json }
+ }
- $OrganizationItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Organization' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Organization = if ($OrganizationItems) { ($OrganizationItems | ForEach-Object { $_.Data | ConvertFrom-Json } | Select-Object -First 1) } else { $null }
+ $Return.Users = & $Read 'Users'
+ $Return.Domains = & $Read 'Domains'
+ $Return.ConditionalAccess = & $Read 'ConditionalAccessPolicies'
+ $Return.Devices = & $Read 'ManagedDevices'
- # Groups with inline members (members are now in each group object)
- $GroupsItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Groups' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Groups = if ($GroupsItems) { $GroupsItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
+ $Return.Organization = & $Read 'Organization' | Select-Object -First 1
- # Roles with inline members (members are now in each role object)
- $RolesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Roles' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.AllRoles = if ($RolesItems) { $RolesItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
+ # Groups and Roles carry their members inline
+ $Return.Groups = & $Read 'Groups'
+ $Return.AllRoles = & $Read 'Roles'
# License mapping with property translation to maintain compatibility
- $LicenseItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'LicenseOverview' | Where-Object { $_.RowKey -notlike '*-Count' }
- if ($LicenseItems) {
- $ParsedLicenseData = $LicenseItems | ForEach-Object { $_.Data | ConvertFrom-Json }
+ $ParsedLicenseData = & $Read 'LicenseOverview'
+ if ($null -ne $ParsedLicenseData) {
$Return.Licenses = $ParsedLicenseData | Select-Object @{N = 'skuId'; E = { $_.skuId } },
@{N = 'skuPartNumber'; E = { $_.skuPartNumber } },
@{N = 'consumedUnits'; E = { $_.CountUsed } },
@@ -74,33 +75,19 @@ function Get-CippExtensionReportingData {
}
# Intune policies (renamed from DeviceCompliancePolicies to IntuneDeviceCompliancePolicies)
- $IntunePoliciesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceCompliancePolicies' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.DeviceCompliancePolicies = if ($IntunePoliciesItems) { $IntunePoliciesItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- # Secure Score
- $SecureScoreItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SecureScore' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.SecureScore = if ($SecureScoreItems) { $SecureScoreItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- # Secure Score Control Profiles
- $SecureScoreControlProfilesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SecureScoreControlProfiles' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.SecureScoreControlProfiles = if ($SecureScoreControlProfilesItems) { $SecureScoreControlProfilesItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
+ $Return.DeviceCompliancePolicies = & $Read 'IntuneDeviceCompliancePolicies'
+ $Return.SecureScore = & $Read 'SecureScore'
+ $Return.SecureScoreControlProfiles = & $Read 'SecureScoreControlProfiles'
# Mailboxes (optional - requires separate cache run)
if ($IncludeMailboxes) {
- $MailboxesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.Mailboxes = if ($MailboxesItems) { $MailboxesItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $CASMailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'CASMailbox' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.CASMailbox = if ($CASMailboxItems) { $CASMailboxItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $MailboxPermissionsItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxPermissions' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.MailboxPermissions = if ($MailboxPermissionsItems) { $MailboxPermissionsItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $OneDriveUsageItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveUsage' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.OneDriveUsage = if ($OneDriveUsageItems) { $OneDriveUsageItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
-
- $MailboxUsageItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxUsage' | Where-Object { $_.RowKey -notlike '*-Count' }
- $Return.MailboxUsage = if ($MailboxUsageItems) { $MailboxUsageItems | ForEach-Object { $_.Data | ConvertFrom-Json } } else { @() }
+ $Return.Mailboxes = & $Read 'Mailboxes'
+ $Return.CASMailbox = & $Read 'CASMailbox'
+ if (-not $SkipMailboxPermissions) {
+ $Return.MailboxPermissions = & $Read 'MailboxPermissions'
+ }
+ $Return.OneDriveUsage = & $Read 'OneDriveUsage'
+ $Return.MailboxUsage = & $Read 'MailboxUsage'
}
return $Return
diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1
index 86393938a0edc..a9405f35f3d02 100644
--- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1
+++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1
@@ -287,7 +287,7 @@ function Invoke-NinjaOneTenantSync {
[System.Collections.Generic.List[PSCustomObject]]$NinjaLicenseCreation = @()
# Replace direct Graph/Exchange calls with cached data
- $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Customer.defaultDomainName -IncludeMailboxes
+ $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Customer.defaultDomainName -IncludeMailboxes -SkipMailboxPermissions
# Map cached data to variables
$Users = $ExtensionCache.Users
@@ -299,7 +299,6 @@ function Invoke-NinjaOneTenantSync {
$CASFull = $ExtensionCache.CASMailbox
$MailboxDetailedFull = $ExtensionCache.Mailboxes
$MailboxStatsFull = $ExtensionCache.MailboxUsage
- $Permissions = $ExtensionCache.MailboxPermissions
$SecureScore = $ExtensionCache.SecureScore
$SecureScoreProfiles = $ExtensionCache.SecureScoreControlProfiles
$TenantDetails = $ExtensionCache.Organization
@@ -360,6 +359,32 @@ function Invoke-NinjaOneTenantSync {
$LicensesParsed = $Licenses | Where-Object { $_.PrepaidUnits.Enabled -gt 0 } | Select-Object @{N = 'License Name'; E = { $_.skuPartNumber } }, @{N = 'Active'; E = { $_.PrepaidUnits.Enabled } }, @{N = 'Consumed'; E = { $_.ConsumedUnits } }, @{N = 'Unused'; E = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits } }
}
+ # Lookups built once. The per-device and per-user steps below used to rescan whole lists with Where-Object
+ # for every item (users x groups x members, devices x compliance statuses, devices x NinjaOne devices, ...),
+ # which is what pushed large tenants past the task timeout and churned gigabytes of garbage.
+ # Keys compare case-insensitively like -eq, a $null value only matches $null (as '$null -in $x' does), and a
+ # lookup yields its matches in list order, exactly like the Where-Object / -in it replaces.
+ $NullKey = [string][char]0
+ $AddTo = {
+ param($Index, $Key, $Item)
+ $K = if ($null -eq $Key) { $NullKey } else { [string]$Key }
+ $L = $null
+ if (-not $Index.TryGetValue($K, [ref]$L)) { $L = [System.Collections.Generic.List[object]]::new(); $Index[$K] = $L }
+ if ($L.Count -eq 0 -or -not [object]::ReferenceEquals($L[$L.Count - 1], $Item)) { $L.Add($Item) }
+ }
+ $NewIndex = {
+ param($Items, [scriptblock]$KeysOf)
+ $Index = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[object]]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($Item in $Items) {
+ $Keys = & $KeysOf $Item
+ if ($null -eq $Keys) { $Keys = , $null }
+ foreach ($Key in $Keys) { & $AddTo $Index $Key $Item }
+ }
+ , $Index
+ }
+ $Find = { param($Index, $Key) $L = $null; if ($Index.TryGetValue($(if ($null -eq $Key) { $NullKey } else { [string]$Key }), [ref]$L)) { $L } }
+ $Has = { param($Index, $Key) $Index.ContainsKey($(if ($null -eq $Key) { $NullKey } else { [string]$Key })) }
+
Write-Verbose "$(Get-Date) - Parsing Device Compliance Policies"
$DeviceComplianceDetails = foreach ($Policy in $DeviceCompliancePolicies) {
@@ -369,6 +394,7 @@ function Invoke-NinjaOneTenantSync {
ID = $Policy.id
DisplayName = $Policy.displayName
DeviceStatuses = $DeviceStatuses
+ StatusIndex = & $NewIndex $DeviceStatuses { param($Stat) $Stat.deviceDisplayName }
}
}
@@ -383,6 +409,13 @@ function Invoke-NinjaOneTenantSync {
Members = $Members
}
}
+
+ $GroupById = & $NewIndex $Groups { param($Group) $Group.id }
+ $AllGroupsById = & $NewIndex $AllGroups { param($Group) $Group.id }
+ $GroupsByMemberId = & $NewIndex $Groups { param($Group) $Group.Members.id }
+ $GroupsByDeviceId = & $NewIndex $Groups { param($Group) $Group.members.deviceId }
+ $UserById = & $NewIndex $Users { param($User) $User.id }
+
Write-Verbose "$(Get-Date) - Parsing Conditional Access Polcies"
$ConditionalAccessMembers = foreach ($CAPolicy in $AllConditionalAccessPolicies) {
@@ -399,7 +432,7 @@ function Invoke-NinjaOneTenantSync {
# Now all members of groups
foreach ($CAIGroup in $CAPolicy.conditions.users.includeGroups) {
- foreach ($Member in ($Groups | Where-Object { $_.id -eq $CAIGroup }).Members) {
+ foreach ($Member in (& $Find $GroupById $CAIGroup).Members) {
$null = $CAMembers.add($Member.id)
}
}
@@ -411,8 +444,10 @@ function Invoke-NinjaOneTenantSync {
}
}
- # Parse to Unique members
- $CAMembers = $CAMembers | Select-Object -Unique
+ # Parse to Unique members - first occurrence wins and the compare is case-sensitive, like the
+ # Select-Object -Unique this replaces, without its compare-against-every-kept-item cost.
+ $UniqueMembers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
+ [System.Collections.Generic.List[PSCustomObject]]$CAMembers = @(foreach ($Member in $CAMembers) { if ($UniqueMembers.Add($(if ($null -eq $Member) { $NullKey } else { [string]$Member }))) { $Member } })
if ($CAMembers) {
# Now remove excluded users
@@ -420,13 +455,13 @@ function Invoke-NinjaOneTenantSync {
# Excluded Groups
foreach ($CAEGroup in $CAPolicy.conditions.users.excludeGroups) {
- foreach ($Member in ($Groups | Where-Object { $_.id -eq $CAEGroup }).Members) {
+ foreach ($Member in (& $Find $GroupById $CAEGroup).Members) {
$null = $CAMembers.remove($Member.id)
}
}
# Excluded Roles
- foreach ($CAIRole in $CAPolicy.conditions.users.excludeRoles) {
+ foreach ($CAERole in $CAPolicy.conditions.users.excludeRoles) {
foreach ($Member in ($Roles | Where-Object { $_.id -eq $CAERole }).Members) {
$null = $CAMembers.remove($Member.id)
}
@@ -440,6 +475,8 @@ function Invoke-NinjaOneTenantSync {
}
}
+ $CAsByUserId = & $NewIndex $ConditionalAccessMembers { param($Policy) $Policy.Members }
+
$FetchEnd = Get-Date
############################ Format and Synchronize to NinjaOne ############################
@@ -460,7 +497,12 @@ function Invoke-NinjaOneTenantSync {
[System.Collections.Generic.List[PSCustomObject]]$DeviceMap = @()
}
- $DevicesToProcess = $Devices | Where-Object { $_.id -notin $ParsedDevices.id }
+ # One pseudo-item holding every cached id keeps '-notin $ParsedDevices.id' semantics, empty list included.
+ $ParsedDeviceIds = & $NewIndex (, $ParsedDevices) { param($All) $All.id }
+ $DevicesToProcess = $Devices | Where-Object { -not (& $Has $ParsedDeviceIds $_.id) }
+ $DeviceMapById = & $NewIndex $DeviceMap { param($Map) $Map.M365ID }
+ $NinjaBySerial = & $NewIndex $NinjaDevices { param($Ninja) ($Ninja.system.biosSerialNumber -replace '\s', ''), ($Ninja.system.serialNumber -replace '\s', '') }
+ $NinjaByName = & $NewIndex $NinjaDevices { param($Ninja) $Ninja.systemName, $Ninja.dnsName }
# Look up the compliance policy settings each non-compliant device fails in one Graph batch for the tenant.
# If the lookup fails the field is left untouched this run rather than being cleared.
@@ -482,14 +524,11 @@ function Invoke-NinjaOneTenantSync {
# First lets match on serial (normalize by removing spaces for comparison)
$NormalizedDeviceSerial = $Device.SerialNumber -replace '\s', ''
- $MatchedNinjaDevice = $NinjaDevices | Where-Object {
- ($_.system.biosSerialNumber -replace '\s', '') -eq $NormalizedDeviceSerial -or
- ($_.system.serialNumber -replace '\s', '') -eq $NormalizedDeviceSerial
- }
+ $MatchedNinjaDevice = & $Find $NinjaBySerial $NormalizedDeviceSerial
# See if we found just one device, if not match on name
if (($MatchedNinjaDevice | Measure-Object).count -ne 1) {
- $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.systemName -eq $Device.deviceName -or $_.dnsName -eq $Device.deviceName }
+ $MatchedNinjaDevice = & $Find $NinjaByName $Device.deviceName
}
# Check on a match again and set name
@@ -504,7 +543,7 @@ function Invoke-NinjaOneTenantSync {
[System.Collections.Generic.List[String]]$DeviceUserIDs = @()
[System.Collections.Generic.List[PSCustomObject]]$DeviceUsersDetail = @()
- $MappedDevice = ($DeviceMap | Where-Object { $_.M365ID -eq $device.id })
+ $MappedDevice = (& $Find $DeviceMapById $device.id)
if (($MappedDevice | Measure-Object).count -eq 0) {
$DeviceMapItem = [PSCustomObject]@{
PartitionKey = $Customer.CustomerId
@@ -513,6 +552,7 @@ function Invoke-NinjaOneTenantSync {
M365ID = $device.id
}
$DeviceMap.Add($DeviceMapItem)
+ & $AddTo $DeviceMapById $DeviceMapItem.M365ID $DeviceMapItem
Add-CIPPAzDataTableEntity @DeviceMapTable -Entity $DeviceMapItem -Force
} elseif ($MappedDevice.NinjaOneID -ne $MatchedNinjaDevice.id) {
@@ -524,7 +564,7 @@ function Invoke-NinjaOneTenantSync {
foreach ($DeviceUser in $Device.usersloggedon) {
- $FoundUser = ($Users | Where-Object { $_.id -eq $DeviceUser.userid })
+ $FoundUser = (& $Find $UserById $DeviceUser.userid)
$DeviceUsers.add($FoundUser.DisplayName)
$DeviceUserIDs.add($DeviceUser.userId)
$DeviceUsersDetail.add([pscustomobject]@{
@@ -539,8 +579,8 @@ function Invoke-NinjaOneTenantSync {
# Compliance Polciies
[System.Collections.Generic.List[PSCustomObject]]$DevicePolcies = @()
foreach ($Policy in $DeviceComplianceDetails) {
- if ($device.deviceName -in $Policy.DeviceStatuses.deviceDisplayName) {
- $Status = $Policy.DeviceStatuses | Where-Object { $_.deviceDisplayName -eq $device.deviceName }
+ $Status = & $Find $Policy.StatusIndex $device.deviceName
+ if ($Status) {
foreach ($Stat in $Status) {
if ($Stat.status -ne 'unknown') {
$DevicePolcies.add([PSCustomObject]@{
@@ -557,11 +597,9 @@ function Invoke-NinjaOneTenantSync {
}
# Device Groups
- $DeviceGroups = foreach ($Group in $Groups) {
- if ($device.azureADDeviceId -in $Group.members.deviceId) {
- [PSCustomObject]@{
- Name = $Group.displayName
- }
+ $DeviceGroups = foreach ($Group in (& $Find $GroupsByDeviceId $device.azureADDeviceId)) {
+ [PSCustomObject]@{
+ Name = $Group.displayName
}
}
@@ -691,11 +729,9 @@ function Invoke-NinjaOneTenantSync {
$DeviceCompliancePoliciesCard = Get-NinjaOneCard -Title 'Device Compliance Policies' -Body $DevicePoliciesHTML -Icon 'fas fa-list-check' -TitleLink $TitleLink
# Device Groups
- $DeviceGroupsTable = foreach ($Group in $Groups) {
- if ($device.azureADDeviceId -in $Group.members.deviceId) {
- [PSCustomObject]@{
- Name = $Group.displayName
- }
+ $DeviceGroupsTable = foreach ($Group in (& $Find $GroupsByDeviceId $device.azureADDeviceId)) {
+ [PSCustomObject]@{
+ Name = $Group.displayName
}
}
$DeviceGroupsFormatted = $DeviceGroupsTable | ConvertTo-Html -Fragment
@@ -797,36 +833,40 @@ function Invoke-NinjaOneTenantSync {
[System.Collections.Generic.List[PSCustomObject]]$NinjaUserUpdates = @()
[System.Collections.Generic.List[PSCustomObject]]$NinjaUserCreation = @()
+ $UsersMapById = & $NewIndex $UsersMap { param($Map) $Map.M365ID }
+ $NinjaUserDocById = & $NewIndex $NinjaOneUserDocs { param($Doc) $Doc.ParsedFields.cippUserID }
+ $CasById = & $NewIndex $CASFull { param($Mailbox) $Mailbox.ExternalDirectoryObjectId }
+ $MailboxById = & $NewIndex $MailboxDetailedFull { param($Mailbox) $Mailbox.ExternalDirectoryObjectId }
+ $MailboxStatsByUpn = & $NewIndex $MailboxStatsFull { param($Stats) $Stats.userPrincipalName }
+ $OneDriveByUpn = & $NewIndex $OneDriveDetails { param($Stats) $Stats.ownerPrincipalName }
+ $ParsedDevicesByUserId = & $NewIndex $ParsedDevices { param($ParsedDevice) $ParsedDevice.UserIDS }
+ $LicenseBySku = & $NewIndex $Licenses { param($License) $License.SkuId }
+
foreach ($user in $SyncUsers | Where-Object { $_.id -notin $ParsedUsers.RowKey }) {
try {
- $NinjaOneUser = $NinjaOneUserDocs | Where-Object { $_.ParsedFields.cippUserID -eq $User.ID }
+ $NinjaOneUser = & $Find $NinjaUserDocById $User.ID
if (($NinjaOneUser | Measure-Object).count -gt 1) {
throw 'Multiple Users with the same ID found'
}
- $UserGroups = foreach ($Group in $Groups) {
- if ($User.id -in $Group.Members.id) {
- $FoundGroup = $AllGroups | Where-Object { $_.id -eq $Group.id }
- [PSCustomObject]@{
- 'Display Name' = $FoundGroup.displayName
- 'Mail Enabled' = $FoundGroup.mailEnabled
- 'Mail' = $FoundGroup.mail
- 'Security Group' = $FoundGroup.securityEnabled
- 'Group Types' = $FoundGroup.groupTypes -join ','
- }
+ $UserGroups = foreach ($Group in (& $Find $GroupsByMemberId $User.id)) {
+ $FoundGroup = & $Find $AllGroupsById $Group.id
+ [PSCustomObject]@{
+ 'Display Name' = $FoundGroup.displayName
+ 'Mail Enabled' = $FoundGroup.mailEnabled
+ 'Mail' = $FoundGroup.mail
+ 'Security Group' = $FoundGroup.securityEnabled
+ 'Group Types' = $FoundGroup.groupTypes -join ','
}
}
- $UserPolicies = foreach ($cap in $ConditionalAccessMembers) {
- if ($User.id -in $Cap.Members) {
- $temp = [PSCustomObject]@{
- displayName = $cap.displayName
- }
- $temp
+ $UserPolicies = foreach ($cap in (& $Find $CAsByUserId $User.id)) {
+ [PSCustomObject]@{
+ displayName = $cap.displayName
}
}
@@ -835,20 +875,11 @@ function Invoke-NinjaOneTenantSync {
$MailboxDetailedRequest = ''
$CASRequest = ''
- $CASRequest = $CASFull | Where-Object { $_.ExternalDirectoryObjectId -eq $User.iD }
- $MailboxDetailedRequest = $MailboxDetailedFull | Where-Object { $_.ExternalDirectoryObjectId -eq $User.iD }
- $StatsRequest = $MailboxStatsFull | Where-Object { $_.userPrincipalName -eq $User.UserPrincipalName }
+ $CASRequest = & $Find $CasById $User.iD
+ $MailboxDetailedRequest = & $Find $MailboxById $User.iD
+ $StatsRequest = & $Find $MailboxStatsByUpn $User.UserPrincipalName
- $ParsedPerms = foreach ($Perm in $Permissions) {
- if ($Perm.User -ne 'NT AUTHORITY\SELF') {
- [pscustomobject]@{
- User = $Perm.User
- AccessRights = $Perm.PermissionList.AccessRights -join ', '
- }
- }
- }
-
try {
$TotalItemSize = [math]::Round($StatsRequest.storageUsedInBytes / 1Gb, 2)
} catch {
@@ -866,7 +897,6 @@ function Invoke-NinjaOneTenantSync {
MailboxImapEnabled = $CASRequest.ImapEnabled
MailboxPopEnabled = $CASRequest.PopEnabled
MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled
- Permissions = $ParsedPerms
ProhibitSendQuota = $StatsRequest.prohibitSendQuotaInBytes
ProhibitSendReceiveQuota = $StatsRequest.prohibitSendReceiveQuotaInBytes
ItemCount = [math]::Round($StatsRequest.itemCount, 2)
@@ -875,10 +905,10 @@ function Invoke-NinjaOneTenantSync {
}
- $UserDevicesDetailsRaw = $ParsedDevices | Where-Object { $User.id -in $_.UserIDS }
+ $UserDevicesDetailsRaw = & $Find $ParsedDevicesByUserId $User.id
- $UserDevices = foreach ($UserDevice in $ParsedDevices | Where-Object { $User.id -in $_.UserIDS }) {
+ $UserDevices = foreach ($UserDevice in (& $Find $ParsedDevicesByUserId $User.id)) {
$MatchedNinjaDevice = $UserDevice.NinjaDevice
$ParsedDeviceName = $UserDevice.DeviceLink
@@ -915,14 +945,14 @@ function Invoke-NinjaOneTenantSync {
$userLicenses = ($user.AssignedLicenses.SkuID | ForEach-Object {
$UserLic = $_
try {
- $SkuPartNumber = ($Licenses | Where-Object { $_.SkuId -eq $UserLic }).SkuPartNumber
+ $SkuPartNumber = (& $Find $LicenseBySku $UserLic).SkuPartNumber
'' + "$($SkuPartNumber)"
} catch {}
}) -join ''
- $UserOneDriveStats = $OneDriveDetails | Where-Object { $_.ownerPrincipalName -eq $User.userPrincipalName } | Select-Object -First 1
+ $UserOneDriveStats = & $Find $OneDriveByUpn $User.userPrincipalName | Select-Object -First 1
$UserOneDriveUse = $UserOneDriveStats.storageUsedInBytes / 1GB
$UserOneDriveTotal = $UserOneDriveStats.storageAllocatedInBytes / 1GB
@@ -975,7 +1005,7 @@ function Invoke-NinjaOneTenantSync {
}
- $UserMailboxStats = $MailboxStatsFull | Where-Object { $_.userPrincipalName -eq $User.userPrincipalName } | Select-Object -First 1
+ $UserMailboxStats = & $Find $MailboxStatsByUpn $User.userPrincipalName | Select-Object -First 1
$UserMailUse = $UserMailboxStats.storageUsedInBytes / 1GB
$UserMailTotal = $UserMailboxStats.prohibitSendReceiveQuotaInBytes / 1GB
@@ -1247,6 +1277,9 @@ function Invoke-NinjaOneTenantSync {
} catch {
$ErrorMessage = Get-CippException -Exception $_
Write-LogMessage -tenant $Customer.defaultDomainName -API 'NinjaOneSync' -message "NinjaOne user document creation failed for $($Customer.displayName). NinjaOne rejects the whole batch if any single document is invalid, so all $(($NinjaUserCreation | Measure-Object).count) user(s) in this batch were not written: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ # Drop the rejected batch. Kept, it was re-sent with every following user and, if one
+ # document was invalid, failed every time - so no later user was written either.
+ [System.Collections.Generic.List[PSCustomObject]]$NinjaUserCreation = @()
}
try {
@@ -1260,6 +1293,8 @@ function Invoke-NinjaOneTenantSync {
} catch {
$ErrorMessage = Get-CippException -Exception $_
Write-LogMessage -tenant $Customer.defaultDomainName -API 'NinjaOneSync' -message "NinjaOne user document update failed for $($Customer.displayName). NinjaOne rejects the whole batch if any single document is invalid, so all $(($NinjaUserUpdates | Measure-Object).count) user(s) in this batch were not written: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ # Drop the rejected batch; see the creation batch above.
+ [System.Collections.Generic.List[PSCustomObject]]$NinjaUserUpdates = @()
}
@@ -1276,7 +1311,7 @@ function Invoke-NinjaOneTenantSync {
if ($Null -ne $Field.value -and $Field.value -ne '') {
- $MappedUser = ($UsersMap | Where-Object { $_.M365ID -eq $Field.value })
+ $MappedUser = (& $Find $UsersMapById $Field.value)
if (($MappedUser | Measure-Object).count -eq 0) {
$UserMapItem = [PSCustomObject]@{
PartitionKey = $Customer.CustomerId
@@ -1285,6 +1320,7 @@ function Invoke-NinjaOneTenantSync {
M365ID = $Field.value
}
$UsersMap.Add($UserMapItem)
+ & $AddTo $UsersMapById $UserMapItem.M365ID $UserMapItem
Add-CIPPAzDataTableEntity @UsersMapTable -Entity $UserMapItem -Force
} elseif ($MappedUser.NinjaOneID -ne $UserDoc.documentId) {
@@ -1355,7 +1391,7 @@ function Invoke-NinjaOneTenantSync {
if ($Null -ne $Field.value -and $Field.value -ne '') {
- $MappedUser = ($UsersMap | Where-Object { $_.M365ID -eq $Field.value })
+ $MappedUser = (& $Find $UsersMapById $Field.value)
if (($MappedUser | Measure-Object).count -eq 0) {
$UserMapItem = [PSCustomObject]@{
PartitionKey = $Customer.CustomerId
@@ -1364,6 +1400,7 @@ function Invoke-NinjaOneTenantSync {
M365ID = $Field.value
}
$UsersMap.Add($UserMapItem)
+ & $AddTo $UsersMapById $UserMapItem.M365ID $UserMapItem
Add-CIPPAzDataTableEntity @UsersMapTable -Entity $UserMapItem -Force
} elseif ($MappedUser.NinjaOneID -ne $UserDoc.documentId) {
@@ -1383,7 +1420,7 @@ function Invoke-NinjaOneTenantSync {
$RelatedItems = (Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/related-items/with-entity/NODE/$($LinkDevice.NinjaDevice.id)" -Method GET -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json').content | ConvertFrom-Json -Depth 100
[System.Collections.Generic.List[PSCustomObject]]$Relations = @()
foreach ($LinkUser in $LinkDevice.UserIDs) {
- $MatchedUser = $UsersMap | Where-Object { $_.M365ID -eq $LinkUser }
+ $MatchedUser = & $Find $UsersMapById $LinkUser
if (($MatchedUser | Measure-Object).count -eq 1) {
$ExistingRelation = $RelatedItems | Where-Object { $_.relEntityType -eq 'DOCUMENT' -and $_.relEntityId -eq $MatchedUser.NinjaOneID }
if (!$ExistingRelation) {
@@ -1415,16 +1452,20 @@ function Invoke-NinjaOneTenantSync {
### License Document Details
if ($Configuration.LicenseDocumentsEnabled -eq $True) {
+ # Read once: convert-skuname re-reads and re-parses this ~1 MB CSV on every call otherwise.
+ $SkuConvertTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv
+
$LicenseDetails = foreach ($License in $Licenses) {
$MatchedSubscriptions = $License.TermInfo
Write-Information "License info: $($License | ConvertTo-Json -Depth 100)"
$FriendlyLicenseName = $License.skuPartNumber
+ $LicensePlanIds = $License.servicePlans.servicePlanID
$LicenseUsers = foreach ($SubUser in $Users) {
$MatchedLicense = $SubUser.assignedLicenses | Where-Object { $License.skuId -in $_.skuId }
- $MatchedPlans = $SubUser.AssignedPlans | Where-Object { $_.servicePlanId -in $License.servicePlans.servicePlanID }
+ $MatchedPlans = $SubUser.AssignedPlans | Where-Object { $_.servicePlanId -in $LicensePlanIds }
if (($MatchedLicense | Measure-Object).count -gt 0 ) {
- $SubRelUserID = ($UsersMap | Where-Object { $_.M365ID -eq $SubUser.id }).NinjaOneID
+ $SubRelUserID = (& $Find $UsersMapById $SubUser.id).NinjaOneID
if ($SubRelUserID) {
$LicUserName = '' + $SubUser.displayName + ''
} else {
@@ -1460,7 +1501,7 @@ function Invoke-NinjaOneTenantSync {
$SubscriptionCardHTML = Get-NinjaOneCard -Title 'Subscriptions' -Body $SubscriptionsHTML -Icon 'fas fa-file-invoice'
- $LicenseItemsTable = $License.servicePlans | Select-Object @{n = 'Plan Name'; e = { convert-skuname -skuname $_.servicePlanName } }, @{n = 'Applies To'; e = { $_.appliesTo } }, @{n = 'Provisioning Status'; e = { $_.provisioningStatus } }
+ $LicenseItemsTable = $License.servicePlans | Select-Object @{n = 'Plan Name'; e = { convert-skuname -skuname $_.servicePlanName -ConvertTable $SkuConvertTable } }, @{n = 'Applies To'; e = { $_.appliesTo } }, @{n = 'Provisioning Status'; e = { $_.provisioningStatus } }
$LicenseItemsHTML = $LicenseItemsTable | ConvertTo-Html -As Table -Fragment
$LicenseItemsHTML = ([System.Web.HttpUtility]::HtmlDecode($LicenseItemsHTML) -replace '', ' | ') -replace ' | ', ' | '
@@ -2256,44 +2297,45 @@ function Invoke-NinjaOneTenantSync {
if ($Ex.cveId) { [void]$ExceptedCveIds.Add([string]$Ex.cveId) }
}
- # Fold the cached rows one at a time instead of materialising a parsed
- # copy of every Data blob before the CSV build - only the CSV rows are
- # needed, so each parsed graph is collectable as soon as its devices are
- # folded (see Get-CIPPCVEReport for the same pattern).
- $CsvRows = [System.Collections.Generic.List[object]]::new()
+ # Stream the cached rows and write each CSV line as it is produced. The rows used to be
+ # materialised by a foreach, then held as one PSCustomObject per device x CVE (a million-plus
+ # on a big tenant) until the CSV was built from them. Same cells, escaping and line endings.
+ $CsvEscape = { param($Value) if ($Value -match '[,"\r\n]') { '"' + ($Value -replace '"', '""') + '"' } else { $Value } }
+ $Csv = [System.Text.StringBuilder]::new()
+ [void]$Csv.AppendLine((@($DeviceIdHeader, $CveIdHeader) -join ','))
+ $CsvRowCount = 0
$VulnCount = 0
$ExceptedCount = 0
$SkippedCount = 0
- foreach ($Row in Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs') {
- if ($Row.RowKey -eq 'DefenderCVEs-Count' -or -not $Row.Data) { continue }
+ Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | ForEach-Object {
+ $Row = $_
+ if ($Row.RowKey -eq 'DefenderCVEs-Count' -or -not $Row.Data) { return }
$Item = $Row.Data | ConvertFrom-Json
$VulnCount++
if ([string]::IsNullOrWhiteSpace($Item.cveId)) {
$SkippedCount++
- continue
+ return
}
if ($ExceptedCveIds.Contains([string]$Item.cveId)) {
$ExceptedCount++
- continue
+ return
}
if ($Item.deviceDetailsJson) {
- $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique
- foreach ($Dev in $Devices) {
- [void]$CsvRows.Add([PSCustomObject]@{
- $DeviceIdHeader = $Dev.deviceName.Trim()
- $CveIdHeader = $Item.cveId.Trim()
- })
+ $CveCell = & $CsvEscape $Item.cveId.Trim()
+ $CveDevices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique
+ foreach ($Dev in $CveDevices) {
+ [void]$Csv.Append((& $CsvEscape $Dev.deviceName.Trim())).Append(',').AppendLine($CveCell)
+ $CsvRowCount++
}
}
}
if ($VulnCount -eq 0) {
Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning'
- [void]$CsvRows.Add([PSCustomObject]@{
- $DeviceIdHeader = ""
- $CveIdHeader = ""})
+ [void]$Csv.AppendLine(',')
+ $CsvRowCount++
} else {
if ($ExceptedCveIds.Count -gt 0) {
Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — filtered $ExceptedCount excepted CVEs, $($VulnCount - $ExceptedCount) remaining" -sev 'Info'
@@ -2302,7 +2344,8 @@ function Invoke-NinjaOneTenantSync {
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)
+ $CsvBytes = [System.Text.Encoding]::UTF8.GetBytes($Csv.ToString())
+ $Csv = $null
if ($CsvBytes -and $CsvBytes.Length -gt 0) {
$UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload"
@@ -2313,9 +2356,9 @@ function Invoke-NinjaOneTenantSync {
$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'
+ Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRowCount) 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'
+ Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRowCount) 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'
}
diff --git a/Shared/CIPPSharp/Reporting/ReportComponents.cs b/Shared/CIPPSharp/Reporting/ReportComponents.cs
index 797b4f46017f9..7b2825a10a150 100644
--- a/Shared/CIPPSharp/Reporting/ReportComponents.cs
+++ b/Shared/CIPPSharp/Reporting/ReportComponents.cs
@@ -944,7 +944,7 @@ private static List CalloutRows(string? title, string titleColou
return new List { new[] { new PdfTableCell(runs) } };
}
- private static PdfTableStyle CalloutStyle(string bgHex, string? stripeHex, double stripeWidth, string borderHex, double borderWidth, double bodySize, double firstSize)
+ private static PdfTableStyle CalloutStyle(string bgHex, string? stripeHex, double stripeWidth, string borderHex, double borderWidth, double bodySize, double firstSize, bool keepTogether = true)
{
// The client pads 12 inside the border (the stripe, on the left) and sets a line's baseline 0.9x its
// size under the line top; OfficeIMO sets a cell's first baseline CellAscent x the table's font size
@@ -975,7 +975,10 @@ private static PdfTableStyle CalloutStyle(string bgHex, string? stripeHex, doubl
[(0, 0)] = padding,
},
SpacingAfter = 0, // the gap after a callout is an explicit Spacer, not the table's
- KeepTogether = true, // a callout never splits across a page (client keeps each whole)
+ // Client keeps each callout whole, but OfficeIMO throws when a keep-together table is taller
+ // than the page (BEC's multi-line skipped-collector / signals boxes). Leave KeepTogether on
+ // only when the measured height fits; otherwise let the cell split across pages.
+ KeepTogether = keepTogether,
CellFills = new Dictionary<(int, int), PdfColor> { [(0, 0)] = Pdf(bgHex) },
};
if (stripeHex is not null)
@@ -988,6 +991,37 @@ private static PdfTableStyle CalloutStyle(string bgHex, string? stripeHex, doubl
return style;
}
+ // True when the callout's estimated height fits on one page. Uses the same Helvetica wrap as
+ // RichTable's keep-whole guard; a near-page-height callout is treated as too tall so OfficeIMO
+ // never throws "Table height exceeds the available page content height."
+ private static bool CalloutFitsOnPage(ReportContext ctx, string? title, string content, bool lines,
+ double titleSize, double bodySize, double leftChrome, double borderWidth)
+ {
+ const double leading = 1.4;
+ var textWidth = Math.Max(1, ctx.ContentWidth - (CalloutPadX + leftChrome) - (CalloutPadX + borderWidth));
+ var firstSize = string.IsNullOrEmpty(title) ? bodySize : titleSize;
+ var height = CalloutPadY + borderWidth + 0.9 * firstSize - CellAscent * bodySize
+ + CalloutPadY + borderWidth - (0.9 - CellAscent) * bodySize;
+ if (!string.IsNullOrEmpty(title))
+ {
+ height += WrappedLines(San(title!), textWidth, titleSize, bold: true) * titleSize * leading;
+ // Title/body gap matches CalloutRows: a sized nbsp plus a body-size newline.
+ height += 14 + 6 - 0.9 * (titleSize - bodySize);
+ }
+ if (lines)
+ {
+ foreach (var ln in San(content).Replace("\r\n", "\n").Split('\n'))
+ height += Math.Max(1, WrappedLines(ln, textWidth, bodySize, bold: false)) * bodySize * leading;
+ }
+ else
+ {
+ // Markdown is flattened to lines at draw time; counting the source as wrapped body text is a
+ // slight over-estimate (marks add no height), which prefers splitting over throwing.
+ height += Math.Max(1, WrappedLines(San(content), textWidth, bodySize, bold: false)) * bodySize * leading;
+ }
+ return height < ctx.ContentHeight - 2;
+ }
+
///
/// A titled note with an accent stripe down its left edge (client InfoBox). `tone` (ok/warn) tints
/// the background and title; `colour` recolours the stripe (and the title when tintTitle). `content`
@@ -998,8 +1032,10 @@ public static void InfoBox(ReportContext ctx, PdfContentBuilder item, string? ti
{
var (accent, bg, titleColour) = InfoBoxColours(ctx, tone, colour, tintTitle);
var body = CalloutBodyRuns(ctx, content, lines, ReportStyles.InfoText, ctx.Theme.Palette["subtitle"]);
+ var firstSize = string.IsNullOrEmpty(title) ? ReportStyles.InfoText : ReportStyles.InfoTitle;
+ var keep = CalloutFitsOnPage(ctx, title, content, lines, ReportStyles.InfoTitle, ReportStyles.InfoText, 4, 1);
item.Table(CalloutRows(title, titleColour, ReportStyles.InfoTitle, body, ReportStyles.InfoText), PdfAlign.Left,
- CalloutStyle(bg, accent, 4, ReportColours.Line, 1, ReportStyles.InfoText, string.IsNullOrEmpty(title) ? ReportStyles.InfoText : ReportStyles.InfoTitle));
+ CalloutStyle(bg, accent, 4, ReportColours.Line, 1, ReportStyles.InfoText, firstSize, keep));
item.Spacer(CalloutGap);
}
@@ -1023,8 +1059,11 @@ public static void AlertBox(ReportContext ctx, PdfContentBuilder item, string? t
{
var accent = string.IsNullOrEmpty(colour) ? ctx.Theme.Palette["card"] : colour!;
var body = CalloutBodyRuns(ctx, content, lines, ReportStyles.AlertText, ctx.Theme.Palette["body"]);
+ var firstSize = string.IsNullOrEmpty(title) ? ReportStyles.AlertText : ReportStyles.AlertTitle;
+ // AlertBox has no left stripe: left chrome is the 2pt border (same as CalloutStyle).
+ var keep = CalloutFitsOnPage(ctx, title, content, lines, ReportStyles.AlertTitle, ReportStyles.AlertText, 2, 2);
item.Table(CalloutRows(title, accent, ReportStyles.AlertTitle, body, ReportStyles.AlertText), PdfAlign.Left,
- CalloutStyle(ReportColours.AlertBg, null, 0, accent, 2, ReportStyles.AlertText, string.IsNullOrEmpty(title) ? ReportStyles.AlertText : ReportStyles.AlertTitle));
+ CalloutStyle(ReportColours.AlertBg, null, 0, accent, 2, ReportStyles.AlertText, firstSize, keep));
item.Spacer(AlertGap);
}
@@ -1097,8 +1136,11 @@ private static void InfoBoxCol(ReportContext ctx, PdfContentBuilder col, string?
{
var (accent, bg, titleColour) = InfoBoxColours(ctx, tone, colour, tintTitle);
var body = CalloutBodyRuns(ctx, content, false, ReportStyles.InfoText, ctx.Theme.Palette["subtitle"]);
+ var firstSize = string.IsNullOrEmpty(title) ? ReportStyles.InfoText : ReportStyles.InfoTitle;
+ // Column width is unknown here; short column callouts keep together (client). A rare oversized
+ // one still hits the full-width InfoBox path in practice - column items are captions.
col.Table(CalloutRows(title, titleColour, ReportStyles.InfoTitle, body, ReportStyles.InfoText), PdfAlign.Left,
- CalloutStyle(bg, accent, 4, ReportColours.Line, 1, ReportStyles.InfoText, string.IsNullOrEmpty(title) ? ReportStyles.InfoText : ReportStyles.InfoTitle));
+ CalloutStyle(bg, accent, 4, ReportColours.Line, 1, ReportStyles.InfoText, firstSize));
}
// Series colour for a chart entry: its own colour, else the theme series cycled by index.
diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll
index 134aabf16bcd1..6a4a05a519ef2 100644
Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ
diff --git a/Tests/ActivityTriggers/Push-BECRun.Tests.ps1 b/Tests/ActivityTriggers/Push-BECRun.Tests.ps1
index 9fc4a152382fe..489114f503892 100644
--- a/Tests/ActivityTriggers/Push-BECRun.Tests.ps1
+++ b/Tests/ActivityTriggers/Push-BECRun.Tests.ps1
@@ -132,7 +132,7 @@ Describe 'Push-BECRun' {
PeersResult = New-CIPPBecCollectorResult -Data @()
Peers = @{}
Geo = @{}
- Verdicts = @([pscustomobject]@{ IP = '203.0.113.10'; Verdict = 'LikelyAttacker'; SuccessfulSignIns = 1; Activities = 2 })
+ Verdicts = @([pscustomobject]@{ IP = '203.0.113.10'; Verdict = 'LikelyAttacker'; SuccessfulSignIns = 1; Activities = 2; Reasons = @([pscustomobject]@{ Code = 'FlaggedAction'; Weight = 4; Text = 'Behind flagged activity: Inbox rule change' }) })
Events = @()
}
}
diff --git a/Tests/Alerts/Send-CIPPAlert.Attachments.Tests.ps1 b/Tests/Alerts/Send-CIPPAlert.Attachments.Tests.ps1
index 8cebbe5e0f0f1..a3abc6803fcf2 100644
--- a/Tests/Alerts/Send-CIPPAlert.Attachments.Tests.ps1
+++ b/Tests/Alerts/Send-CIPPAlert.Attachments.Tests.ps1
@@ -1,6 +1,7 @@
# Pester tests for email attachments in Send-CIPPAlert.
# Graph sendMail rejects a request body over 4MB, so a scheduled report with a large PDF or raw data
-# file failed to send at all. Attachments that don't fit are now omitted and the mail still goes out.
+# file failed to send at all. Attachments that don't fit are uploaded to blob storage and linked from the
+# body instead; if that upload fails they are omitted and the mail still goes out.
BeforeAll {
$BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
@@ -12,6 +13,7 @@ BeforeAll {
function New-GraphPostRequest { param($uri, $tenantid, $NoAuthCheck, $type, $body) }
function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData, $headers) }
function Get-CippException { param($Exception) }
+ function New-CIPPReportAttachmentLink { param($Name, $ContentBytes, $ContentType) }
. $FunctionPath
@@ -33,6 +35,7 @@ Describe 'Send-CIPPAlert - email attachment size' {
Mock -CommandName Get-CIPPTextReplacement -MockWith { param($TenantFilter, $Text) $Text }
Mock -CommandName Write-LogMessage -MockWith { }
Mock -CommandName New-GraphPostRequest -MockWith { param($uri, $tenantid, $NoAuthCheck, $type, $body) $script:SentBody = $body }
+ Mock -CommandName New-CIPPReportAttachmentLink -MockWith { param($Name) "https://store.example/report-attachments/x/$($Name)?sig=a&se=b" }
}
It 'attaches everything when it fits' {
@@ -41,17 +44,29 @@ Describe 'Send-CIPPAlert - email attachment size' {
@($Message.attachments.name) | Should -Be @('report.pdf', 'data.csv')
}
- It 'omits an attachment that would push the request over 4MB and keeps the rest' {
+ It 'links an attachment that would push the request over 4MB and attaches the rest' {
$Message = Send-TestMail @((Get-TestAttachment 'report.pdf' 3MB), (Get-TestAttachment 'data.csv' 1MB), (Get-TestAttachment 'small.csv' 1000))
@($Message.attachments.name) | Should -Be @('report.pdf', 'small.csv')
+ Should -Invoke New-CIPPReportAttachmentLink -Times 1 -Exactly -ParameterFilter { $Name -eq 'data.csv' }
+ $Message.body.content | Should -BeLike ' body *too large*data.csv*'
[System.Text.Encoding]::UTF8.GetByteCount($script:SentBody) | Should -BeLessThan 4MB
}
- It 'still sends the mail without attachments when none fit' {
+ It 'links every attachment when none fit' {
$Message = Send-TestMail @((Get-TestAttachment 'report.pdf' 5MB))
Should -Invoke New-GraphPostRequest -Times 1 -Exactly
$Message.PSObject.Properties.Name | Should -Not -Contain 'attachments'
+ $Message.body.content | Should -BeLike '*>report.pdf*'
+ }
+
+ It 'omits the attachment and still sends when the upload fails' {
+ Mock -CommandName New-CIPPReportAttachmentLink -MockWith { throw 'storage down' }
+ $Message = Send-TestMail @((Get-TestAttachment 'report.pdf' 5MB))
+
+ Should -Invoke New-GraphPostRequest -Times 1 -Exactly
+ $Message.body.content | Should -Be 'body '
+ Should -Invoke Write-LogMessage -ParameterFilter { $message -like '*report.pdf*storage down*' }
}
}
diff --git a/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1 b/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1
index 2308d7fbd2f6c..caa59e3cc19f5 100644
--- a/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1
+++ b/Tests/Alerts/Send-CIPPScheduledTaskAlert.Tests.ps1
@@ -214,3 +214,86 @@ Describe 'Send-CIPPScheduledTaskAlert - PSA snooze links' {
}
}
}
+
+Describe 'Send-CIPPScheduledTaskAlert - display title' {
+ # Scripted multi-tenant alerts store Name as "{every selected tenant}: {subject}". That list
+ # must not appear in per-tenant PSA bodies - only the subject and the current Tenant line.
+ BeforeEach {
+ $script:SentAlerts = [System.Collections.Generic.List[object]]::new()
+
+ $script:Results = @(
+ [pscustomobject]@{ UsedStoragePercentage = 95; Tenant = 'contoso.com' }
+ )
+
+ $script:LongTenantName = 'Acme Corp (acme.com), Beta Ltd (beta.com), Contoso (contoso.com): Sharepoint Allowance is over 90%'
+
+ $script:TaskInfo = [pscustomobject]@{
+ RowKey = 'task-quota'
+ Name = $script:LongTenantName
+ Command = 'Get-CIPPAlertSharepointQuota'
+ PostExecution = 'psa'
+ AlertComment = ''
+ CustomSubject = ''
+ PsaTicketStrategy = 'consolidated'
+ Parameters = '{}'
+ }
+
+ Mock -CommandName Get-CippTable -MockWith { param([string]$TableName) @{ TableName = $TableName } }
+ Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = '00000000-0000-0000-0000-000000000001' } }
+ Mock -CommandName Get-CIPPTextReplacement -MockWith { param($Text, $TenantFilter) $Text }
+ Mock -CommandName Write-LogMessage -MockWith { }
+ Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
+ param($TableName, $Filter)
+ switch ($TableName) {
+ 'Config' { [pscustomobject]@{ Value = 'cipp.contoso.com' } }
+ 'Extensionsconfig' { New-HaloExtConfig -LinkTicketsToUsers $false }
+ default { $null }
+ }
+ }
+ Mock -CommandName Send-CIPPAlert -MockWith {
+ param($Type, $Title, $HTMLContent, $JSONContent, $TenantFilter, $AffectedUser, $PSAReference, $PSATicketId)
+ $script:SentAlerts.Add([pscustomobject]@{
+ Type = $Type
+ Title = $Title
+ HTMLContent = $HTMLContent
+ })
+ }
+ }
+
+ It 'strips the multi-tenant Name prefix from Alert body and title' {
+ Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert'
+
+ $script:SentAlerts.Count | Should -Be 1
+ $Body = $script:SentAlerts[0].HTMLContent
+ $Body | Should -Match 'Sharepoint Allowance is over 90%'
+ $Body | Should -Match 'Tenant:.*contoso\.com'
+ $Body | Should -Not -Match 'Acme Corp'
+ $Body | Should -Not -Match 'Beta Ltd'
+ $script:SentAlerts[0].Title | Should -Be 'Alert - contoso.com - Sharepoint Allowance is over 90%'
+ $script:SentAlerts[0].Title | Should -Not -Match 'Acme Corp'
+ }
+
+ It 'prefers CustomSubject over a legacy multi-tenant Name' {
+ $script:TaskInfo.CustomSubject = 'SharePoint quota high'
+ $script:TaskInfo.Name = $script:LongTenantName
+
+ Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Alert'
+
+ $Body = $script:SentAlerts[0].HTMLContent
+ $Body | Should -Match 'SharePoint quota high'
+ $Body | Should -Not -Match 'Acme Corp'
+ $Body | Should -Not -Match 'Sharepoint Allowance is over 90%'
+ $script:SentAlerts[0].Title | Should -Be 'SharePoint quota high - contoso.com'
+ }
+
+ It 'keeps the full task Name for non-Alert scheduled tasks' {
+ $script:TaskInfo.Name = 'Weekly report for Contoso (contoso.com)'
+ $script:TaskInfo.CustomSubject = ''
+
+ Send-CIPPScheduledTaskAlert -Results $script:Results -TaskInfo $script:TaskInfo -TenantFilter 'contoso.com' -TaskType 'Scheduled Task'
+
+ $Body = $script:SentAlerts[0].HTMLContent
+ $Body | Should -Match 'Weekly report for Contoso \(contoso\.com\)'
+ $script:SentAlerts[0].Title | Should -Be 'Scheduled Task - contoso.com - Weekly report for Contoso (contoso.com)'
+ }
+}
diff --git a/Tests/Api/OpenApiSpec.Tests.ps1 b/Tests/Api/OpenApiSpec.Tests.ps1
index 78e02ef152777..2d6410357c899 100644
--- a/Tests/Api/OpenApiSpec.Tests.ps1
+++ b/Tests/Api/OpenApiSpec.Tests.ps1
@@ -48,7 +48,8 @@ BeforeAll {
param([string]$Endpoint)
$PathItem = $script:Spec.paths["/api/$Endpoint"]
if (-not $PathItem) { return $null }
- $Method = @($PathItem.Keys)[0]
+ # a path documented as both GET and POST carries its body on the POST
+ $Method = if ($PathItem.Contains('post')) { 'post' } else { @($PathItem.Keys)[0] }
return [pscustomobject]@{ Method = $Method; Operation = $PathItem[$Method] }
}
@@ -144,11 +145,14 @@ Describe 'request schema fidelity' {
}
Describe 'invariants the MCP projection depends on' {
- It 'gives every path exactly one operation' {
- # Get-CippMcpToolList names a tool after the endpoint; two operations on one
- # path would advertise two tools with the same name
- $Multi = @($script:Spec.paths.GetEnumerator() | Where-Object { $_.Value.Keys.Count -ne 1 } | ForEach-Object { $_.Key })
- $Multi | Should -BeNullOrEmpty
+ It 'gives every path one operation, or a GET and POST pair' {
+ # The MCP catalog names a tool after the endpoint and keeps the POST of a pair,
+ # which also lists the query parameters, so a pair still yields one tool
+ $Bad = @($script:Spec.paths.GetEnumerator() | Where-Object {
+ $Keys = @($_.Value.Keys)
+ -not ($Keys.Count -eq 1 -or ($Keys.Count -eq 2 -and $Keys -contains 'get' -and $Keys -contains 'post'))
+ } | ForEach-Object { $_.Key })
+ $Bad | Should -BeNullOrEmpty
}
It 'uses only methods the API accepts' {
diff --git a/Tests/AuditLogs/Push-AuditLogSearchCreationV2.Tests.ps1 b/Tests/AuditLogs/Push-AuditLogSearchCreationV2.Tests.ps1
new file mode 100644
index 0000000000000..11cd6311f7c2c
--- /dev/null
+++ b/Tests/AuditLogs/Push-AuditLogSearchCreationV2.Tests.ps1
@@ -0,0 +1,47 @@
+# Push-AuditLogDownloadV2 re-plans failed searches (MANUAL-* included) and only this function
+# re-creates them, so the ledger read must cover the whole partition.
+
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1')
+
+ function Get-CippTable { param($TableName) }
+ function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) }
+ function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force, $OperationType) }
+ function Get-CippAuditLogPlannedWindows { param($ExistingRows, $Now) }
+ function Get-CippAuditLogReconciliationWindows { param($ExistingRows, $Now) }
+ function Get-CippAuditLogNextAttempt { param($Attempts) }
+ function New-CippAuditLogSearchV2 { param($TenantFilter, $StartTime, $EndTime) }
+}
+
+Describe 'Push-AuditLogSearchCreationV2 ledger read' {
+ BeforeEach {
+ $script:Reads = [System.Collections.Generic.List[object]]::new()
+ $script:Searched = [System.Collections.Generic.List[object]]::new()
+ $Start = (Get-Date).ToUniversalTime().AddDays(-2)
+
+ Mock Get-CippTable { @{ Context = 'ledger' } }
+ Mock Add-CIPPAzDataTableEntity {}
+ Mock Get-CIPPAzDataTableEntity {
+ $script:Reads.Add([pscustomobject]@{ Filter = $Filter; Property = $Property })
+ [pscustomobject]@{ RowKey = 'MANUAL-0a1b'; State = 'Planned'; WindowStart = $Start; WindowEnd = $Start.AddHours(6) }
+ }
+ Mock New-CippAuditLogSearchV2 {
+ $script:Searched.Add($StartTime)
+ [pscustomobject]@{ Outcome = 'Created'; Id = 'search-1'; Status = 'notStarted' }
+ }
+ }
+
+ It 're-creates a search for a re-planned manual row' {
+ Push-AuditLogSearchCreationV2 -Item @{ TenantFilter = 'contoso.onmicrosoft.com'; TenantId = 't' } | Should -BeTrue
+ $script:Searched.Count | Should -Be 1
+ }
+
+ It 'reads the whole partition in one query without split-entity markers' {
+ Push-AuditLogSearchCreationV2 -Item @{ TenantFilter = "o'brien.onmicrosoft.com"; TenantId = 't' } | Out-Null
+ $script:Reads.Count | Should -Be 1
+ $script:Reads[0].Filter | Should -Be "PartitionKey eq 'o''brien.onmicrosoft.com'"
+ $script:Reads[0].Property | Should -Not -Contain 'OriginalEntityId'
+ $script:Reads[0].Property | Should -Contain 'WindowEnd'
+ }
+}
diff --git a/Tests/Baselines/BaselineMigrationValueMaps.Tests.ps1 b/Tests/Baselines/BaselineMigrationValueMaps.Tests.ps1
index 05788090a73b8..f1c5cef0e7c4a 100644
--- a/Tests/Baselines/BaselineMigrationValueMaps.Tests.ps1
+++ b/Tests/Baselines/BaselineMigrationValueMaps.Tests.ps1
@@ -64,4 +64,41 @@ Describe 'Invoke-CIPPBaselineMigration value maps' {
$Options | Should -Contain $Value
}
}
+
+ Context 'Template picker multi-selects' {
+ It 'fans a V2 GroupTemplate multi-select out to one instance per template' {
+ $Result = Invoke-Migration @{ GroupTemplate = @(@{ action = @('Report'); groupTemplate = @(@{ label = 'Sales'; value = 'g-1' }, @{ label = 'HR'; value = 'g-2' }) }) }
+ $Configs = @($Result.Configs | Where-Object standard -EQ 'GroupTemplate')
+ $Configs.Count | Should -Be 2
+ @($Configs.variables.groupTemplate) | Should -Be @('g-1', 'g-2')
+ @($Configs.instance | Select-Object -Unique).Count | Should -Be 2
+ }
+
+ It 'unwraps a single-item V2 selection to the plain template id' {
+ $Result = Invoke-Migration @{ GroupTemplate = @(@{ action = @('Report'); groupTemplate = @(@{ label = 'Sales'; value = 'g-1' }) }) }
+ $Config = $Result.Configs | Where-Object standard -EQ 'GroupTemplate'
+ $Config.variables.groupTemplate | Should -BeExactly 'g-1'
+ }
+
+ It 'keys the instance the same way as a single-value selection so re-migration updates in place' {
+ $Multi = Invoke-Migration @{ GroupTemplate = @(@{ action = @('Report'); groupTemplate = @(@{ label = 'Sales'; value = 'g-1' }) }) }
+ $Single = Invoke-Migration @{ GroupTemplate = @(@{ action = @('Report'); groupTemplate = @{ label = 'Sales'; value = 'g-1' } }) }
+ ($Multi.Configs | Where-Object standard -EQ 'GroupTemplate').instance | Should -Be ($Single.Configs | Where-Object standard -EQ 'GroupTemplate').instance
+ }
+
+ It 'carries the remaining settings onto every fanned-out instance' {
+ $Result = Invoke-Migration @{ TransportRuleTemplate = @(@{ action = @('Report'); transportRuleTemplate = @(@{ label = 'A'; value = 't-1' }, @{ label = 'B'; value = 't-2' }); overwrite = $true }) }
+ $Configs = @($Result.Configs | Where-Object standard -EQ 'TransportRuleTemplate')
+ $Configs.Count | Should -Be 2
+ $Configs | ForEach-Object { $_.variables.overwrite | Should -BeTrue }
+ }
+
+ It 'maps the generic V2 picker keys onto the V3 identity variable' {
+ $Reusable = Invoke-Migration @{ ReusableSettingsTemplate = @(@{ action = @('Report'); TemplateList = @(@{ label = 'A'; value = 'r-1' }, @{ label = 'B'; value = 'r-2' }) }) }
+ $SafeLinks = Invoke-Migration @{ SafeLinksTemplatePolicy = @{ action = @('Report'); standards = @{ SafeLinksTemplatePolicy = @{ TemplateIds = @(@{ label = 'A'; value = 's-1' }) } } } }
+ @(($Reusable.Configs | Where-Object standard -EQ 'ReusableSettingsTemplate').variables.reusableSettingsTemplate) | Should -Be @('r-1', 'r-2')
+ ($SafeLinks.Configs | Where-Object standard -EQ 'SafeLinksTemplatePolicy').variables.safeLinksTemplate | Should -BeExactly 's-1'
+ @($Reusable.Report.warnings) + @($SafeLinks.Report.warnings) | Where-Object { $_ -match 'does not map' } | Should -BeNullOrEmpty
+ }
+ }
}
diff --git a/Tests/Baselines/Get-CIPPBaseline.SourceSync.Tests.ps1 b/Tests/Baselines/Get-CIPPBaseline.SourceSync.Tests.ps1
index e28ee4ee611f4..16d11c781ee65 100644
--- a/Tests/Baselines/Get-CIPPBaseline.SourceSync.Tests.ps1
+++ b/Tests/Baselines/Get-CIPPBaseline.SourceSync.Tests.ps1
@@ -174,3 +174,45 @@ Describe 'Get-CIPPBaseline excluded tenant group expansion' {
$Result.tenantStates.tenantFilter | Should -Contain 'tenant6.onmicrosoft.com'
}
}
+
+# Issues #745/#751: free-text identities (Autopilot profile DisplayName) are the value itself;
+# only picker identities are template references worth resolving into {label, value}.
+Describe 'Get-CIPPBaseline identity label resolution' {
+ BeforeEach {
+ function Get-CIPPBaselineDefinition { }
+ Mock -CommandName Get-CIPPBaselineDefinition -MockWith {
+ @(
+ [pscustomobject]@{ name = 'AutopilotProfile'; instanceIdentity = 'DisplayName'; variables = [pscustomobject]@{ DisplayName = [pscustomobject]@{ type = 'textField' } }; remediate = [pscustomobject]@{ executor = 'AutopilotProfile' } }
+ [pscustomobject]@{ name = 'ConditionalAccessTemplate'; instanceIdentity = 'caTemplate'; variables = [pscustomobject]@{ caTemplate = [pscustomobject]@{ type = 'autoComplete' } }; remediate = [pscustomobject]@{ executor = 'CATemplate' } }
+ )
+ }
+ Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
+ param($Context, $Filter)
+ if ($Filter -like "*PartitionKey eq 'rollout'*") {
+ @([pscustomobject]@{ RowKey = 'baseline-7'; templateName = 'Identity Baseline'; Stages = '[{"name":"Default","logic":"and","conditions":[]}]' })
+ } elseif ($Filter -like '*standardItem*') {
+ @(
+ [pscustomobject]@{ scope = 'tenant'; scopeId = 't1.onmicrosoft.com'; stage = 1; standardName = 'AutopilotProfile#m1'; expectedValue = '{"DisplayName":"Workstations"}' }
+ [pscustomobject]@{ scope = 'tenant'; scopeId = 't1.onmicrosoft.com'; stage = 1; standardName = 'ConditionalAccessTemplate#m2'; expectedValue = '{"caTemplate":"ca-guid-1"}' }
+ )
+ } elseif ($Filter -like "*PartitionKey eq 'CATemplate'*") {
+ @([pscustomobject]@{ RowKey = 'ca-guid-1'; JSON = '{"displayName":"Block legacy auth"}' })
+ } else {
+ @()
+ }
+ }
+ }
+
+ It 'leaves a free-text identity as its plain string' {
+ $Result = Get-CIPPBaseline -ID 'baseline-7' -ResolveIdentityLabels
+ $Config = $Result.stages[0].standardsConfig | Where-Object { $_.standard -eq 'AutopilotProfile' }
+ $Config.variables.DisplayName | Should -Be 'Workstations'
+ }
+
+ It 'still resolves a template picker identity into a label/value option' {
+ $Result = Get-CIPPBaseline -ID 'baseline-7' -ResolveIdentityLabels
+ $Config = $Result.stages[0].standardsConfig | Where-Object { $_.standard -eq 'ConditionalAccessTemplate' }
+ $Config.variables.caTemplate.label | Should -Be 'Block legacy auth'
+ $Config.variables.caTemplate.value | Should -Be 'ca-guid-1'
+ }
+}
diff --git a/Tests/Baselines/Get-CIPPStatsBaselines.Tests.ps1 b/Tests/Baselines/Get-CIPPStatsBaselines.Tests.ps1
new file mode 100644
index 0000000000000..3dc964142f886
--- /dev/null
+++ b/Tests/Baselines/Get-CIPPStatsBaselines.Tests.ps1
@@ -0,0 +1,40 @@
+# Pester tests for Get-CIPPStatsBaselines: counts come from the rollout and delta tables only.
+
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ function Get-CippTable { param($tablename) @{ TableName = $tablename } }
+ function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property) }
+ function Write-LogMessage { param($API, $tenant, $message, $sev) }
+ . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPStatsBaselines.ps1')
+}
+
+Describe 'Get-CIPPStatsBaselines' {
+ BeforeEach {
+ Mock Get-CIPPAzDataTableEntity {
+ if ($TableName -eq 'BaselineRollouts') {
+ @([pscustomobject]@{ RowKey = 'a' }, [pscustomobject]@{ RowKey = 'b' })
+ } else {
+ @(
+ [pscustomobject]@{ standardName = 'DisableGuests'; scope = 'tenant'; scopeId = 't1.com' }
+ [pscustomobject]@{ standardName = 'DisableGuests'; scope = 'tenant'; scopeId = 't2.com' }
+ [pscustomobject]@{ standardName = 'IntuneTemplate#1'; scope = 'tenant'; scopeId = 't1.com' }
+ [pscustomobject]@{ standardName = 'IntuneTemplate#2'; scope = 'allTenants'; scopeId = 'AllTenants' }
+ )
+ }
+ }
+ }
+
+ It 'counts baselines, distinct scopes and distinct standards' {
+ $r = Get-CIPPStatsBaselines
+ $r.BaselineCount | Should -Be 2
+ $r.BaselineTenantCount | Should -Be 3
+ $r.BaselineStandardsCount | Should -Be 2
+ }
+
+ It 'returns nulls when the table read fails' {
+ Mock Get-CIPPAzDataTableEntity { throw 'boom' }
+ $r = Get-CIPPStatsBaselines
+ $r.BaselineCount | Should -BeNullOrEmpty
+ $r.BaselineStandardsCount | Should -BeNullOrEmpty
+ }
+}
diff --git a/Tests/Build/Build-OpenApi.Tests.ps1 b/Tests/Build/Build-OpenApi.Tests.ps1
index 38331854db900..9fe1549f01961 100644
--- a/Tests/Build/Build-OpenApi.Tests.ps1
+++ b/Tests/Build/Build-OpenApi.Tests.ps1
@@ -263,9 +263,36 @@ Describe 'endpoint discovery' {
}
}
- It 'emits exactly one operation per path so MCP tool names stay unique' {
+ It 'emits one operation per path unless a read endpoint also takes body-only fields' {
foreach ($Path in $script:Spec.paths.Values) { $Path.Keys.Count | Should -Be 1 }
}
+
+ It 'documents both GET and POST for a read endpoint with body-only fields' {
+ $Dir = Join-Path $TestDrive 'dual-method'
+ $null = New-Item -ItemType Directory -Path $Dir -Force
+ Set-Content -Path (Join-Path $Dir 'Invoke-ListDual.ps1') -Value @'
+function Invoke-ListDual {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Identity.User.Read
+ #>
+ param($Request, $TriggerMetadata)
+ $Tenant = $Request.Query.tenantFilter
+ if ($Request.Body.ClearCache -eq $true) { $Tenant = $null }
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK; Body = @($Tenant) })
+}
+'@
+ $Spec = Invoke-Generator -Name 'dual-method' -With @{ EntrypointPath = $Dir }
+ $PathItem = $Spec.paths['/api/ListDual']
+ @($PathItem.Keys) | Should -Be @('get', 'post')
+ $PathItem.get.operationId | Should -Be 'ListDual'
+ $PathItem.post.operationId | Should -Be 'ListDualPost'
+ $PathItem.get.Contains('requestBody') | Should -BeFalse
+ $PathItem.post.requestBody.content.'application/json'.schema.properties.Keys | Should -Contain 'ClearCache'
+ @($PathItem.get.parameters | ForEach-Object { $_.'$ref' }) | Should -Contain '#/components/parameters/tenantFilter'
+ }
}
Describe 'nested member chains' {
diff --git a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1
index 8c3b4ab1e7900..84b9e8cac4f5a 100644
--- a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1
+++ b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1
@@ -172,6 +172,25 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' {
(($script:Rows[0].deviceDetailsJson | ConvertFrom-Json).deviceId | Sort-Object) | Should -Be @('d1', 'd2')
}
+ It 'dedupes device ids case-insensitively and keeps the text of each CVE''s first record' {
+ Mock -CommandName Get-DefenderTvmRaw -MockWith {
+ New-TvmRecord -cveId 'CVE-A' -deviceId 'ABC' -deviceName 'PC-UPPER'
+ New-TvmRecord -cveId 'CVE-A' -deviceId 'abc' -deviceName 'pc-lower'
+ New-TvmRecord -cveId 'CVE-B' -deviceId 'abc' -deviceName 'pc-lower'
+ New-TvmRecord -cveId 'CVE-B' -deviceId 'ABC' -deviceName 'PC-UPPER'
+ }
+
+ Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant
+
+ $ByCve = @{}; foreach ($Row in $script:Rows) { $ByCve[$Row.cveId] = $Row }
+ $ByCve['CVE-A'].deviceCount | Should -Be 1
+ # Compare parsed fields: the fragment comes from a plain @{} so its key order varies by process.
+ $A = $ByCve['CVE-A'].deviceDetailsJson | ConvertFrom-Json
+ $B = $ByCve['CVE-B'].deviceDetailsJson | ConvertFrom-Json
+ "$($A.deviceId)|$($A.deviceName)" | Should -BeExactly 'ABC|PC-UPPER'
+ "$($B.deviceId)|$($B.deviceName)" | Should -BeExactly 'abc|pc-lower'
+ }
+
It 'skips software-inventory rows with no CVE without throwing or logging an error' {
Mock -CommandName Get-DefenderTvmRaw -MockWith {
[pscustomobject]@{ cveId = $null; deviceId = 'd0'; deviceName = 'PC-0' }
@@ -287,6 +306,24 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' {
$script:JsonCalls | Should -Be 30
}
+ It 'serialises a device once per tenant, not once per CVE it is affected by' {
+ # The fold keeps one JSON fragment per device and gives each CVE integer indexes into
+ # that table. Serialising per CVE x device pair is what retained ~350-400 bytes per
+ # pair on a large tenant.
+ Mock -CommandName Get-DefenderTvmRaw -MockWith {
+ foreach ($i in 1..30) { New-TvmRecord -cveId "CVE-$i" -deviceId 'd1' -deviceName 'PC-1' }
+ }
+
+ $script:JsonCalls = 0
+ Mock -CommandName ConvertTo-Json -MockWith { $script:JsonCalls++; '{"deviceId":"d1","deviceName":"PC-1"}' }
+
+ Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant
+
+ $script:JsonCalls | Should -Be 1
+ $script:Rows.Count | Should -Be 30
+ $script:Rows | ForEach-Object { $_.deviceDetailsJson | Should -BeExactly '{"deviceId":"d1","deviceName":"PC-1"}' }
+ }
+
It 'passes AddCount exactly once so the stored count is the run total' {
Mock -CommandName Get-DefenderTvmRaw -MockWith {
foreach ($i in 1..10) { New-TvmRecord -cveId "CVE-$i" -deviceId "d$i" }
diff --git a/Tests/GraphHelper/Get-CippSamPermissions.Grants.Tests.ps1 b/Tests/GraphHelper/Get-CippSamPermissions.Grants.Tests.ps1
deleted file mode 100644
index d51002eeebb65..0000000000000
--- a/Tests/GraphHelper/Get-CippSamPermissions.Grants.Tests.ps1
+++ /dev/null
@@ -1,159 +0,0 @@
-# The permission diff must reflect what is actually granted on the CIPP-SAM enterprise
-# application, not what CIPP recorded that it applied. An instance with no admin consent
-# reported "all the required permissions" while every Exchange call returned 401.
-
-BeforeAll {
- $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
- $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CippSamPermissions.ps1' -File -ErrorAction SilentlyContinue |
- Select-Object -First 1 -ExpandProperty FullName
- if (-not $FunctionPath) { throw 'Could not locate Get-CippSamPermissions.ps1 under Modules/' }
-
- function Get-CippTable { param($tablename) @{ Context = 'stub' } }
- function Get-CippAzDataTableEntity { param($Context, $Filter) }
- function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) }
- function New-GraphGetRequest { param($Uri, $tenantid, $NoAuthCheck, $AsApp) }
- function New-GraphBulkRequest { param($tenantid, $Requests, $NoAuthCheck, $asapp) }
- function Write-LogMessage { param($message, $tenant, $API, $sev, $Headers, $LogData) }
-
- . $FunctionPath
-
- $script:GraphAppId = '00000003-0000-0000-c000-000000000000'
- $script:GraphSpId = 'aaaaaaaa-0000-0000-0000-000000000001'
- $script:SamAppId = 'bbbbbbbb-0000-0000-0000-000000000002'
- $script:SamSpId = 'cccccccc-0000-0000-0000-000000000003'
- $script:ScopeId = '11111111-1111-1111-1111-111111111111'
- $script:ScopeName = 'Directory.Read.All'
- $script:RoleId = '22222222-2222-2222-2222-222222222222'
- $script:RoleName = 'Directory.ReadWrite.All'
-
- $script:ConfigRoot = Join-Path ([IO.Path]::GetTempPath()) ("samgrants-" + [guid]::NewGuid())
- $null = New-Item -ItemType Directory -Path (Join-Path $script:ConfigRoot 'Config') -Force
- @{
- requiredResourceAccess = @(
- @{
- resourceAppId = $script:GraphAppId
- resourceAccess = @(
- @{ id = $script:ScopeId; type = 'Scope' },
- @{ id = $script:RoleId; type = 'Role' }
- )
- }
- )
- } | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $script:ConfigRoot 'Config/SAMManifest.json')
- '[]' | Set-Content -Path (Join-Path $script:ConfigRoot 'Config/AdditionalPermissions.json')
-
- $env:CIPPRootPath = $script:ConfigRoot
- $env:TenantID = '00000000-0000-0000-0000-000000000001'
- $env:ApplicationID = $script:SamAppId
-}
-
-AfterAll {
- Remove-Item -Path $script:ConfigRoot -Recurse -Force -ErrorAction SilentlyContinue
-}
-
-Describe 'Get-CippSamPermissions grant diff' {
- BeforeEach {
- $script:CippSamPermissionsCache = $null
- $script:CippSamPermissionsCacheTime = $null
- # Grants returned for the CIPP-SAM service principal; each test sets these.
- $script:AppRoleAssignments = @()
- $script:OAuthGrants = @()
- $script:GrantLookupThrows = $false
-
- Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } }
- Mock -CommandName Get-CippAzDataTableEntity -MockWith { $null }
- Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { }
- Mock -CommandName New-GraphBulkRequest -MockWith {
- @(
- @{
- body = [pscustomobject]@{
- appId = $script:GraphAppId
- displayName = 'Microsoft Graph'
- appRoles = @([pscustomobject]@{ id = $script:RoleId; value = $script:RoleName })
- publishedPermissionScopes = @([pscustomobject]@{ id = $script:ScopeId; value = $script:ScopeName })
- }
- }
- )
- }
- Mock -CommandName New-GraphGetRequest -MockWith {
- if ($Uri -match 'servicePrincipals\?') {
- return @(
- [pscustomobject]@{ id = $script:GraphSpId; appId = $script:GraphAppId; displayName = 'Microsoft Graph' },
- [pscustomobject]@{ id = $script:SamSpId; appId = $script:SamAppId; displayName = 'CIPP-SAM' }
- )
- }
- if ($script:GrantLookupThrows) { throw 'Request failed with status code Forbidden' }
- if ($Uri -match "servicePrincipals\(appId='") { return [pscustomobject]@{ id = $script:SamSpId } }
- if ($Uri -match 'appRoleAssignments') { return $script:AppRoleAssignments }
- if ($Uri -match 'oauth2PermissionGrants') { return $script:OAuthGrants }
- return @()
- }
- }
-
- It 'reports nothing missing when both permissions are granted on the service principal' {
- $script:AppRoleAssignments = @([pscustomobject]@{ resourceId = $script:GraphSpId; appRoleId = $script:RoleId })
- $script:OAuthGrants = @([pscustomobject]@{ resourceId = $script:GraphSpId; scope = $script:ScopeName })
-
- $Result = Get-CippSamPermissions
-
- $Result.MissingPermissions.PSObject.Properties.Name | Should -BeNullOrEmpty
- $Result.GrantCheckFailed | Should -Not -BeTrue
- }
-
- It 'reports the delegated permission as missing when consent was never granted' {
- $script:AppRoleAssignments = @([pscustomobject]@{ resourceId = $script:GraphSpId; appRoleId = $script:RoleId })
- $script:OAuthGrants = @()
-
- $Result = Get-CippSamPermissions
-
- $Missing = $Result.MissingPermissions.($script:GraphAppId)
- @($Missing.delegatedPermissions.value) | Should -Contain $script:ScopeName
- @($Missing.applicationPermissions) | Should -BeNullOrEmpty
- }
-
- It 'reports the application permission as missing when its app role is not assigned' {
- $script:AppRoleAssignments = @()
- $script:OAuthGrants = @([pscustomobject]@{ resourceId = $script:GraphSpId; scope = $script:ScopeName })
-
- $Result = Get-CippSamPermissions
-
- $Missing = $Result.MissingPermissions.($script:GraphAppId)
- @($Missing.applicationPermissions.id) | Should -Contain $script:RoleId
- @($Missing.delegatedPermissions) | Should -BeNullOrEmpty
- }
-
- It 'reports everything missing when the app has no consent at all' {
- $Result = Get-CippSamPermissions
-
- $Missing = $Result.MissingPermissions.($script:GraphAppId)
- @($Missing.delegatedPermissions.value) | Should -Contain $script:ScopeName
- @($Missing.applicationPermissions.id) | Should -Contain $script:RoleId
- }
-
- It 'surfaces a grant lookup failure instead of reporting a clean result' {
- $script:GrantLookupThrows = $true
-
- $Result = Get-CippSamPermissions
-
- $Result.GrantCheckFailed | Should -BeTrue
- $Result.GrantCheckError | Should -Not -BeNullOrEmpty
- }
-
- It 'lists grants that are not in the effective set as extras' {
- $ExtraScope = 'Mail.Read'
- $script:AppRoleAssignments = @([pscustomobject]@{ resourceId = $script:GraphSpId; appRoleId = $script:RoleId })
- $script:OAuthGrants = @([pscustomobject]@{ resourceId = $script:GraphSpId; scope = "$($script:ScopeName) $ExtraScope" })
-
- $Result = Get-CippSamPermissions
-
- @($Result.PartnerAppDiff.($script:GraphAppId).extraDelegatedPermissions.value) | Should -Contain $ExtraScope
- }
-
- It 'skips the grant lookup entirely when called with -NoDiff' {
- $script:GrantLookupThrows = $true
-
- $Result = Get-CippSamPermissions -NoDiff
-
- $Result.MissingPermissions.PSObject.Properties.Name | Should -BeNullOrEmpty
- $Result.GrantCheckFailed | Should -Not -BeTrue
- }
-}
diff --git a/Tests/Mcp/Get-CippMcpToolCatalog.Tests.ps1 b/Tests/Mcp/Get-CippMcpToolCatalog.Tests.ps1
index 07179a38f3796..265b541d15804 100644
--- a/Tests/Mcp/Get-CippMcpToolCatalog.Tests.ps1
+++ b/Tests/Mcp/Get-CippMcpToolCatalog.Tests.ps1
@@ -358,10 +358,9 @@ Describe 'tool identity' {
Initialize-FixtureSpec -Paths @{
'/api/ListUsers' = @{ get = (Get-OperationFixture); post = (Get-OperationFixture) }
}
- $Tools = Get-ToolList
- $Tools.Count | Should -Be 2
- # documents today's behaviour: the guard against this lives in the generator
- # and in Spec.Tests.ps1, not here
- @($Tools.name | Select-Object -Unique).Count | Should -Be 1
+ $Tools = @(Get-ToolList)
+ $Tools.Count | Should -Be 1
+ # the POST also lists the query parameters, so it is the one kept
+ $Tools[0]._method | Should -Be 'POST'
}
}
diff --git a/Tests/Private/BecIPCollectors.Tests.ps1 b/Tests/Private/BecIPCollectors.Tests.ps1
index c4ab0ee706425..c1e1c8d79c669 100644
--- a/Tests/Private/BecIPCollectors.Tests.ps1
+++ b/Tests/Private/BecIPCollectors.Tests.ps1
@@ -7,7 +7,7 @@ BeforeAll {
function Get-CIPPIPAllowBlockList { param($TenantFilter) }
function Get-NormalizedError { param($message) $message }
function Search-CIPPBecAuditLog { param($TenantFilter, $StartDate, $EndDate, $Operations, $UserIds, $IPAddresses, $Anchor, $MaxPages) }
- foreach ($File in @('ConvertTo-CIPPODataFilterValue.ps1', 'Authentication/ConvertTo-CIPPIPRange.ps1', 'BEC/ConvertTo-CIPPBecHostAddress.ps1', 'BEC/New-CIPPBecCollectorResult.ps1', 'BEC/Get-CIPPBecSignInBaseline.ps1', 'BEC/Get-CIPPBecIPPeers.ps1', 'BEC/Get-CIPPBecIPGuidance.ps1', 'BEC/Get-CIPPBecBlastRadius.ps1')) {
+ foreach ($File in @('ConvertTo-CIPPODataFilterValue.ps1', 'Authentication/ConvertTo-CIPPIPRange.ps1', 'BEC/ConvertTo-CIPPBecHostAddress.ps1', 'BEC/New-CIPPBecCollectorResult.ps1', 'BEC/Get-CIPPBecSignInBaseline.ps1', 'BEC/Get-CIPPBecIPPeers.ps1', 'BEC/Get-CIPPBecCorrelatedUserPeers.ps1', 'BEC/Get-CIPPBecIPGuidance.ps1', 'BEC/Get-CIPPBecBlastRadius.ps1')) {
. (Join-Path $RepoRoot "Modules/CIPPCore/Public/$File")
}
function New-SignIn {
@@ -89,12 +89,67 @@ Describe 'Get-CIPPBecIPPeers' {
Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { @($NoPaginateIds) -contains 'n0' -and @($NoPaginateIds) -contains 'n1' -and $Requests[0].url -match "ipAddress eq '203.0.113.10'" }
}
+ It 'reads the non-interactive sign-ins before the window separately, so a busy address still shows its colleagues' {
+ Mock New-GraphBulkRequest {
+ @(
+ [pscustomobject]@{ id = 'i0'; status = 200; body = [pscustomobject]@{ value = @() } }
+ # the in-window page is full of token refreshes...
+ [pscustomobject]@{ id = 'n0'; status = 200; body = [pscustomobject]@{ '@odata.nextLink' = 'more'; value = @([pscustomobject]@{ userId = 'x'; userPrincipalName = 'x@contoso.com'; createdDateTime = '2026-09-20T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }) } }
+ # ...and the colleagues show up in the before-window page
+ [pscustomobject]@{ id = 'b0'; status = 200; body = [pscustomobject]@{ value = @(
+ [pscustomobject]@{ userId = 'a'; userPrincipalName = 'a@contoso.com'; createdDateTime = '2026-09-01T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }
+ [pscustomobject]@{ userId = 'b'; userPrincipalName = 'b@contoso.com'; createdDateTime = '2026-09-02T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }
+ ) } }
+ )
+ }
+ $Peers = Get-CIPPBecIPPeers -TenantFilter 'contoso.com' -UserId 'me' -IPs @('203.0.113.10') -StartDate '2026-08-15' -WindowStart '2026-09-16'
+ $Peers['203.0.113.10'].OtherUsersBefore | Should -Be 2
+ Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter {
+ $N = $Requests | Where-Object { $_.id -eq 'n0' }; $B = $Requests | Where-Object { $_.id -eq 'b0' }
+ $N.url -notmatch 'createdDateTime lt ' -and $B.url -match 'createdDateTime lt ' -and @($NoPaginateIds) -contains 'b0'
+ }
+ }
+
+ It 'looks an IPv6 address up by its /64 once for all its addresses, and keeps only sign-ins inside it' {
+ Mock New-GraphBulkRequest {
+ @([pscustomobject]@{ id = 'i0'; status = 200; body = [pscustomobject]@{ value = @(
+ [pscustomobject]@{ userId = 'a'; userPrincipalName = 'a@contoso.com'; ipAddress = '2001:db8:1:2::77'; createdDateTime = '2026-09-01T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }
+ [pscustomobject]@{ userId = 'b'; userPrincipalName = 'b@contoso.com'; ipAddress = '2001:db8:1:2:9::1'; createdDateTime = '2026-09-02T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }
+ [pscustomobject]@{ userId = 'z'; userPrincipalName = 'z@contoso.com'; ipAddress = '2001:db8:1:20::1'; createdDateTime = '2026-09-02T00:00:00Z'; status = [pscustomobject]@{ errorCode = 0 } }
+ ) } })
+ }
+ $Peers = Get-CIPPBecIPPeers -TenantFilter 'contoso.com' -UserId 'me' -IPs @('2001:db8:1:2::5', '2001:db8:1:2:abcd::1') -StartDate '2026-08-15' -WindowStart '2026-09-16'
+ foreach ($IP in @('2001:db8:1:2::5', '2001:db8:1:2:abcd::1')) {
+ $Peers[$IP].OtherUsersBefore | Should -Be 2
+ $Peers[$IP].Network | Should -Be '2001:db8:1:2::/64'
+ }
+ Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { @($Requests).Count -eq 3 -and $Requests[0].url -match "startswith\(ipAddress,'2001:db8:1:2:'\)" }
+ }
+
It 'returns nothing without calling Graph when there are no addresses' {
Mock New-GraphBulkRequest { throw 'should not be called' }
(Get-CIPPBecIPPeers -TenantFilter 'contoso.com' -UserId 'me' -IPs @() -StartDate '2026-08-15' -WindowStart '2026-09-16').Count | Should -Be 0
}
}
+Describe 'Get-CIPPBecCorrelatedUserPeers' {
+ It 'matches a colleague on the /64 of an IPv6 case address, before or during the window' {
+ Mock New-GraphBulkRequest {
+ @(
+ [pscustomobject]@{ id = 'i0'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ userId = 'a'; userPrincipalName = 'a@contoso.com'; ipAddress = '2001:db8:1:2::77'; createdDateTime = '2026-09-01T00:00:00Z' }) } }
+ [pscustomobject]@{ id = 'b0'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ userId = 'a'; userPrincipalName = 'a@contoso.com'; ipAddress = '203.0.113.10'; createdDateTime = '2026-09-02T00:00:00Z' }) } }
+ [pscustomobject]@{ id = 'n1'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ userId = 'b'; userPrincipalName = 'b@contoso.com'; ipAddress = '2001:db8:9::1'; createdDateTime = '2026-09-20T00:00:00Z' }) } }
+ )
+ }
+ $Peers = Get-CIPPBecCorrelatedUserPeers -TenantFilter 'contoso.com' -UserIds @('11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222') -IPs @('2001:db8:1:2::5', '203.0.113.10') -StartDate '2026-08-15' -WindowStart '2026-09-16'
+ $Peers['2001:db8:1:2::5'].OtherUsersBefore | Should -Be 1
+ $Peers['2001:db8:1:2::5'].Network | Should -Be '2001:db8:1:2::/64'
+ $Peers['203.0.113.10'].OtherUsersBefore | Should -Be 1 -Because 'the before-window page is read on its own'
+ $Peers.Keys | Should -Not -Contain '2001:db8:9::1'
+ Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { @($NoPaginateIds) -contains 'b0' -and @($NoPaginateIds) -contains 'n1' }
+ }
+}
+
Describe 'Get-CIPPBecBlastRadius' {
BeforeAll {
$script:Verdicts = @(
diff --git a/Tests/Private/ConvertTo-CIPPBecHostAddress.Tests.ps1 b/Tests/Private/ConvertTo-CIPPBecHostAddress.Tests.ps1
index 2294c388e9acf..852cc8b5dfced 100644
--- a/Tests/Private/ConvertTo-CIPPBecHostAddress.Tests.ps1
+++ b/Tests/Private/ConvertTo-CIPPBecHostAddress.Tests.ps1
@@ -14,9 +14,24 @@ Describe 'ConvertTo-CIPPBecHostAddress' {
ConvertTo-CIPPBecHostAddress -Address '2001:db8::1' | Should -Be '2001:db8::1'
}
+ It 'writes one host one way: IPv6 compressed and lower case, IPv4-mapped as plain IPv4' {
+ ConvertTo-CIPPBecHostAddress -Address '2603:10A6:20B:4C::12' | Should -Be '2603:10a6:20b:4c::12'
+ ConvertTo-CIPPBecHostAddress -Address '2603:10a6:020b:004c:0000:0000:0000:0012' | Should -Be '2603:10a6:20b:4c::12'
+ ConvertTo-CIPPBecHostAddress -Address '::ffff:203.0.113.10' | Should -Be '203.0.113.10'
+ ConvertTo-CIPPBecHostAddress -Address '[::ffff:203.0.113.10]:443' | Should -Be '203.0.113.10' -Because 'the port used to stay glued on and split the host per connection'
+ }
+
+ It 'gives the /64 of an IPv6 address with -Network, and the host of an IPv4 one' {
+ ConvertTo-CIPPBecHostAddress -Address '[2001:DB8:1:2:a1b2:c3d4:e5f6:1]:51234' -Network | Should -Be '2001:db8:1:2::/64'
+ ConvertTo-CIPPBecHostAddress -Address '2001:db8:1:2::99' -Network | Should -Be '2001:db8:1:2::/64'
+ ConvertTo-CIPPBecHostAddress -Address '203.0.113.10:80' -Network | Should -Be '203.0.113.10'
+ }
+
It 'returns null for an empty address and leaves other text alone' {
ConvertTo-CIPPBecHostAddress -Address '' | Should -BeNullOrEmpty
ConvertTo-CIPPBecHostAddress -Address $null | Should -BeNullOrEmpty
ConvertTo-CIPPBecHostAddress -Address '' | Should -Be ''
+ ConvertTo-CIPPBecHostAddress -Address 'XXX.XXX.XXX.XXX' | Should -Be 'XXX.XXX.XXX.XXX'
+ ConvertTo-CIPPBecHostAddress -Address '12345' | Should -Be '12345' -Because '.NET would read a bare number as IPv4'
}
}
diff --git a/Tests/Private/Get-CIPPBecAttackerActivity.Tests.ps1 b/Tests/Private/Get-CIPPBecAttackerActivity.Tests.ps1
index 1cff6f2f0c9e4..a86bc54e6b53b 100644
--- a/Tests/Private/Get-CIPPBecAttackerActivity.Tests.ps1
+++ b/Tests/Private/Get-CIPPBecAttackerActivity.Tests.ps1
@@ -71,6 +71,17 @@ Describe 'Get-CIPPBecAttackerActivity' {
$Result.Mail.Summary.Unattributed | Should -Be 1 -Because 'a record tied to nothing is counted, not guessed'
}
+ It 'keeps a record on its own address when that address has a verdict, however bad its session partners are' {
+ # the user's own mail client shares the Entra session with the attacker address
+ $Own = New-Mail 'MailItemsAccessed' '203.0.113.10' @{ AppAccessContext = [pscustomobject]@{ AADSessionId = 'ENTRA-1' }; Folders = @([pscustomobject]@{ Path = '\Inbox'; FolderItems = @([pscustomobject]@{ InternetMessageId = '' }) }) }
+ $Unknown = New-Mail 'MailItemsAccessed' '192.0.2.44' @{ AppAccessContext = [pscustomobject]@{ UniqueTokenId = 'TOK-ATTACKER' }; Folders = @([pscustomobject]@{ Path = '\Inbox'; FolderItems = @([pscustomobject]@{ InternetMessageId = '' }) }) }
+ $Result = Invoke-Attacker -MailRecords @($Own, $Unknown)
+ @($Result.Mail.Data.InternetMessageId) | Should -Not -Contain '' -Because 'it came from the address of the user'
+ $Row = $Result.Mail.Data | Where-Object InternetMessageId -EQ ''
+ $Row.IPSource | Should -Be 'record'
+ $Row.IPVerdict | Should -Be 'Unknown'
+ }
+
It 'searches files only for the attacker-side addresses and link usage by item' {
Mock Search-CIPPBecAuditLog -ParameterFilter { $IPAddresses } {
[pscustomobject]@{ Complete = $true; Records = @([pscustomobject]@{ AuditData = [pscustomobject]@{ Operation = 'FileDownloaded'; CreationTime = '2026-09-20T02:00:00Z'; SourceFileName = 'payroll.xlsx'; ObjectId = 'https://contoso-my.sharepoint.com/personal/victim/Documents/payroll.xlsx'; SiteUrl = 'https://contoso-my.sharepoint.com/personal/victim'; ClientIP = '198.51.100.7'; UserAgent = 'python-requests/2.31' } }) }
diff --git a/Tests/Private/Get-CIPPBecIPVerdicts.Tests.ps1 b/Tests/Private/Get-CIPPBecIPVerdicts.Tests.ps1
index d1298ff21fbf2..de7fbed0227ba 100644
--- a/Tests/Private/Get-CIPPBecIPVerdicts.Tests.ps1
+++ b/Tests/Private/Get-CIPPBecIPVerdicts.Tests.ps1
@@ -22,8 +22,8 @@ BeforeAll {
'40.107.1.1' = [pscustomobject]@{ CountryOrRegion = 'US'; City = 'Boydton'; Proxy = $false; Hosting = $true; ASName = 'MICROSOFT-CORP-MSN-AS-BLOCK' }
}
function Get-Verdicts {
- param($SignIns = @(), $NonInteractive = @(), $Events = @(), $Guidance = @(), $Overrides = @(), $Peers = @{}, $Baseline = $script:Baseline)
- Get-CIPPBecIPVerdicts -SignIns $SignIns -NonInteractiveSignIns $NonInteractive -Events $Events -Baseline $Baseline -Guidance $Guidance -Overrides $Overrides -Peers $Peers -Geo $script:Geo -UsageLocation 'AU' -Heuristics $script:Heuristics
+ param($SignIns = @(), $NonInteractive = @(), $Events = @(), $Guidance = @(), $Overrides = @(), $Peers = @{}, $Baseline = $script:Baseline, $ServiceRanges = @())
+ Get-CIPPBecIPVerdicts -SignIns $SignIns -NonInteractiveSignIns $NonInteractive -Events $Events -Baseline $Baseline -Guidance $Guidance -Overrides $Overrides -Peers $Peers -Geo $script:Geo -UsageLocation 'AU' -Heuristics $script:Heuristics -ServiceRanges $ServiceRanges
}
function Get-Row { param($Rows, $IP) $Rows | Where-Object IP -EQ $IP }
}
@@ -74,6 +74,59 @@ Describe 'Get-CIPPBecIPVerdicts' {
@($Rented.Reasons.Code) | Should -Contain 'HostingOrProxy'
}
+ It 'classes an address in the Microsoft 365 ranges as a service even when the user signed in from it' {
+ # the shape that used to snowball: a Microsoft front end in the user's non-interactive sign-ins,
+ # hosting + foreign + new to the user = LikelyAttacker on heuristics alone
+ $Ranges = @('40.107.0.0/16', '2603:1006::/40', '2603:1036::/36')
+ $NonInteractive = @(
+ New-SignIn -IP '2603:1036:303:2c50::5' -Country 'US' -City 'Boydton' -ASN '8075'
+ New-SignIn -IP '40.107.1.1' -Country 'US' -City 'Boydton' -ASN '8075'
+ )
+ $Rows = Get-Verdicts -NonInteractive $NonInteractive -ServiceRanges $Ranges
+ foreach ($IP in @('2603:1036:303:2c50::5', '40.107.1.1')) {
+ $Row = Get-Row $Rows $IP
+ $Row.Verdict | Should -Be 'Service' -Because "$IP is a Microsoft 365 front end"
+ $Row.Source | Should -Match 'Microsoft 365 service address'
+ }
+ (Get-Row (Get-Verdicts -NonInteractive $NonInteractive) '40.107.1.1').Verdict | Should -Not -Be 'Service' -Because 'without the list a signed-in Microsoft address is still judged (rented Azure)'
+ $Rented = Get-Row (Get-Verdicts -SignIns @(New-SignIn -IP '20.55.1.1' -Country 'US' -ASN '8075') -ServiceRanges $Ranges) '20.55.1.1'
+ $Rented.Verdict | Should -Not -Be 'Service' -Because 'Azure compute outside the Microsoft 365 ranges can be the attacker'
+ }
+
+ It 'knows a new IPv6 privacy address by the /64 the user signed in from before the window' {
+ $Baseline = [pscustomobject]@{
+ Successful = 40
+ # a device rotating through temporary addresses: every one seen on a day or two
+ IPs = @(1..8 | ForEach-Object { [pscustomobject]@{ IP = "2001:db8:1:2:a:b:c:$_"; SignIns = 5; Share = 0.125; Days = 1 } })
+ ASNs = @([pscustomobject]@{ ASN = '1221'; SignIns = 40; Share = 1.0 })
+ Locations = @([pscustomobject]@{ Country = 'AU'; City = 'Sydney'; SignIns = 40; Share = 1.0 })
+ }
+ $Rows = Get-Verdicts -SignIns @((New-SignIn -IP '2001:db8:1:2:dead:beef:0:1'), (New-SignIn -IP '2001:db8:9:9::1')) -Baseline $Baseline
+ $HomeRow = Get-Row $Rows '2001:db8:1:2:dead:beef:0:1'
+ @($HomeRow.Reasons.Code) | Should -Contain 'BaselineRegular'
+ @($HomeRow.Reasons.Code) | Should -Not -Contain 'NewToUser'
+ ($HomeRow.Reasons | Where-Object Code -EQ 'BaselineRegular').Text | Should -Match '2001:db8:1:2::/64'
+ $HomeRow.Verdict | Should -Be 'LikelyUser'
+ @((Get-Row $Rows '2001:db8:9:9::1').Reasons.Code) | Should -Contain 'NewToUser' -Because 'another /64 is still new'
+ }
+
+ It 'never lifts an address the user used before the window through a shared session' {
+ # one dual-stack device: its IPv4 and IPv6 addresses carry the same Entra session
+ $Baseline = [pscustomobject]@{
+ Successful = 40
+ IPs = @([pscustomobject]@{ IP = '203.0.113.99'; SignIns = 1; Share = 0.025; Days = 1 })
+ ASNs = @([pscustomobject]@{ ASN = '1221'; SignIns = 40; Share = 1.0 })
+ Locations = @([pscustomobject]@{ Country = 'AU'; City = 'Sydney'; SignIns = 40; Share = 1.0 })
+ }
+ $SignIns = @(
+ New-SignIn -IP '198.51.100.7' -Country 'NG' -Risk 'high' -SessionId 'S1'
+ New-SignIn -IP '203.0.113.99' -SessionId 'S1'
+ )
+ $Rows = Get-Verdicts -SignIns $SignIns -Baseline $Baseline -Events @([pscustomobject]@{ IP = '198.51.100.7'; Kind = 'Directory change'; Flagged = $true })
+ (Get-Row $Rows '198.51.100.7').Verdict | Should -Be 'LikelyAttacker'
+ @((Get-Row $Rows '203.0.113.99').Reasons.Code) | Should -Not -Contain 'SharedSession'
+ }
+
It 'never calls an address with only failed sign-ins more than suspicious' {
$SignIns = @(1..5 | ForEach-Object { New-SignIn -IP '198.51.100.7' -Status 'Failed' -Country 'NG' -Risk 'high' })
$Row = Get-Row (Get-Verdicts -SignIns $SignIns) '198.51.100.7'
diff --git a/Tests/Private/Get-CIPPBecMailActivity.Tests.ps1 b/Tests/Private/Get-CIPPBecMailActivity.Tests.ps1
index ad78d62a9b3e9..dd74d6464d1c7 100644
--- a/Tests/Private/Get-CIPPBecMailActivity.Tests.ps1
+++ b/Tests/Private/Get-CIPPBecMailActivity.Tests.ps1
@@ -206,8 +206,8 @@ Describe 'Get-CIPPBecMailActivity' {
$Result.Data[0].ClientInfoString | Should -BeLike 'Client=REST;x*...'
$Result.Summary.HardDeleteCount | Should -Be 0
$Result.Summary.MailItemsAccessedCount | Should -Be 4
- }
-
+ }
+
It 'reads the access type from OperationProperties, lists the sessions per row, and hands the raw records on' {
$script:UserRecords = @(
New-Record -Operation 'MailItemsAccessed' -AccessType 'Sync' -AccessTypeInProperties -SessionId 'S1'
diff --git a/Tests/Private/Get-CIPPBecScore.Tests.ps1 b/Tests/Private/Get-CIPPBecScore.Tests.ps1
index c0de519bea771..7cb9c1d76b73a 100644
--- a/Tests/Private/Get-CIPPBecScore.Tests.ps1
+++ b/Tests/Private/Get-CIPPBecScore.Tests.ps1
@@ -28,6 +28,9 @@ BeforeAll {
foreach ($Key in $Overrides.Keys) { $Base[$Key] = $Overrides[$Key] }
[pscustomobject]$Base
}
+ # a confirmed attacker address with no sign-ins or activity of its own: backs the derived
+ # attacker signals without adding AttackerIPs
+ $script:Backed = @([pscustomobject]@{ Verdict = 'Compromised'; SuccessfulSignIns = 0; Activities = 0 })
}
AfterAll {
@@ -115,12 +118,14 @@ Describe 'Get-CIPPBecScore' {
# an attacker address that only failed to sign in, or a suspicious one, does not count
@{ Key = 'IPVerdicts'; Value = @([pscustomobject]@{ Verdict = 'LikelyAttacker'; SuccessfulSignIns = 1; Activities = 0 }, [pscustomobject]@{ Verdict = 'Compromised'; SuccessfulSignIns = 0; Activities = 0 }, [pscustomobject]@{ Verdict = 'Suspicious'; SuccessfulSignIns = 3; Activities = 2 }); Expected = 4; Signal = 'AttackerIPs' }
# item-level activity counts only from addresses judged the attacker's
- @{ Key = 'AttackerMailActivity'; Value = @([pscustomobject]@{ IPVerdict = 'LikelyAttacker'; MailboxOwner = 'victim@contoso.com' }, [pscustomobject]@{ IPVerdict = 'Unknown'; MailboxOwner = 'victim@contoso.com' }); Expected = 3; Signal = 'AttackerMailAccess' }
- @{ Key = 'AttackerFileActivity'; Value = @([pscustomobject]@{ IPVerdict = 'Compromised' }); Expected = 2; Signal = 'AttackerFileAccess' }
- @{ Key = 'FormsActivity'; Value = @([pscustomobject]@{ Flagged = $true; IPVerdict = 'LikelyAttacker' }, [pscustomobject]@{ Flagged = $true; IPVerdict = 'Suspicious' }); Expected = 3; Signal = 'AttackerForms' }
+ @{ Key = 'AttackerMailActivity'; Value = @([pscustomobject]@{ IPVerdict = 'LikelyAttacker'; MailboxOwner = 'victim@contoso.com' }, [pscustomobject]@{ IPVerdict = 'Unknown'; MailboxOwner = 'victim@contoso.com' }); Expected = 3; Signal = 'AttackerMailAccess'; Backed = $true }
+ @{ Key = 'AttackerFileActivity'; Value = @([pscustomobject]@{ IPVerdict = 'Compromised' }); Expected = 2; Signal = 'AttackerFileAccess'; Backed = $true }
+ @{ Key = 'FormsActivity'; Value = @([pscustomobject]@{ Flagged = $true; IPVerdict = 'LikelyAttacker' }, [pscustomobject]@{ Flagged = $true; IPVerdict = 'Suspicious' }); Expected = 3; Signal = 'AttackerForms'; Backed = $true }
)
foreach ($Case in $Cases) {
- $Score = Get-CIPPBecScore -Results (New-Results @{ $Case.Key = $Case.Value }) -Heuristics $script:Heuristics
+ $Payload = @{ $Case.Key = $Case.Value }
+ if ($Case.Backed) { $Payload.IPVerdicts = $script:Backed }
+ $Score = Get-CIPPBecScore -Results (New-Results $Payload) -Heuristics $script:Heuristics
$Score.Value | Should -Be $Case.Expected -Because "$($Case.Signal) should add $($Case.Expected)"
($Score.Breakdown | Where-Object { $_.Signal -eq $Case.Signal }).Applied | Should -BeTrue -Because "$($Case.Signal) should be applied"
}
@@ -131,20 +136,43 @@ Describe 'Get-CIPPBecScore' {
[pscustomobject]@{ IPVerdict = 'LikelyAttacker'; MailboxOwner = 'ceo@contoso.com' }
[pscustomobject]@{ IPVerdict = 'LikelyAttacker'; MailboxOwner = 'Victim@contoso.com' }
)
- $Score = Get-CIPPBecScore -Results (New-Results @{ UserPrincipalName = 'victim@contoso.com'; AttackerMailActivity = $Mail }) -Heuristics $script:Heuristics
+ $Score = Get-CIPPBecScore -Results (New-Results @{ UserPrincipalName = 'victim@contoso.com'; AttackerMailActivity = $Mail; IPVerdicts = $script:Backed }) -Heuristics $script:Heuristics
($Score.Breakdown | Where-Object Signal -EQ 'DelegatedMailboxAttackerAccess').Count | Should -Be 1 -Because 'the own mailbox, in any case, is not a delegated one'
$Score.Value | Should -Be 6
}
It 'counts only the other accounts an attacker address reached, not the attempts' {
$Blast = @([pscustomobject]@{ UserPrincipalName = 'a@contoso.com'; Reached = $true }, [pscustomobject]@{ UserPrincipalName = 'b@contoso.com'; Reached = $false })
- $Score = Get-CIPPBecScore -Results (New-Results @{ BlastRadius = $Blast }) -Heuristics $script:Heuristics
+ $Score = Get-CIPPBecScore -Results (New-Results @{ BlastRadius = $Blast; IPVerdicts = $script:Backed }) -Heuristics $script:Heuristics
$Signal = $Score.Breakdown | Where-Object Signal -EQ 'OtherAccountsReached'
$Signal.Count | Should -Be 1
$Signal.Applied | Should -BeTrue
$Signal.Weight | Should -Be 3
}
+ It 'counts one heuristic-only attacker verdict once, not again through everything derived from it' {
+ # a Microsoft or user address misjudged on network heuristics alone used to add 4+3+2+3+3 = 15
+ $Heuristic = [pscustomobject]@{ Verdict = 'LikelyAttacker'; SuccessfulSignIns = 3; Activities = 5; Reasons = @([pscustomobject]@{ Code = 'HostingOrProxy' }, [pscustomobject]@{ Code = 'Foreign' }, [pscustomobject]@{ Code = 'NewToUser' }) }
+ $Payload = @{
+ UserPrincipalName = 'victim@contoso.com'
+ IPVerdicts = @($Heuristic)
+ AttackerMailActivity = @([pscustomobject]@{ IPVerdict = 'LikelyAttacker'; MailboxOwner = 'ceo@contoso.com' })
+ AttackerFileActivity = @([pscustomobject]@{ IPVerdict = 'LikelyAttacker' })
+ BlastRadius = @([pscustomobject]@{ UserPrincipalName = 'a@contoso.com'; Reached = $true })
+ }
+ $Score = Get-CIPPBecScore -Results (New-Results $Payload) -Heuristics $script:Heuristics
+ $Score.Value | Should -Be 4 -Because 'only AttackerIPs counts'
+ $Held = @($Score.Breakdown | Where-Object { $_.Signal -in @('AttackerMailAccess', 'AttackerFileAccess', 'OtherAccountsReached', 'DelegatedMailboxAttackerAccess') })
+ @($Held | Where-Object Applied).Count | Should -Be 0
+ @($Held | Where-Object { $_.Description -like '*not counted*' }).Count | Should -Be 4 -Because 'each held-back signal says why'
+
+ # the same address behind an attacker action, or rated risky by Entra, backs them
+ foreach ($Code in @('FlaggedAction', 'RiskySignIn')) {
+ $Payload.IPVerdicts = @([pscustomobject]@{ Verdict = 'LikelyAttacker'; SuccessfulSignIns = 3; Activities = 5; Reasons = @([pscustomobject]@{ Code = $Code }) })
+ (Get-CIPPBecScore -Results (New-Results $Payload) -Heuristics $script:Heuristics).Value | Should -Be 15 -Because "$Code corroborates the verdict"
+ }
+ }
+
It 'does not score a Defender detection that was blocked, or a dismissed risky user' {
(Get-CIPPBecScore -Results (New-Results @{ DefenderDetections = @([pscustomobject]@{ Delivered = $false }) }) -Heuristics $script:Heuristics).Value | Should -Be 0
(Get-CIPPBecScore -Results (New-Results @{ RiskState = [pscustomobject]@{ Listed = $true; RiskState = 'dismissed'; RiskLevel = 'high' } }) -Heuristics $script:Heuristics).Value | Should -Be 0
diff --git a/Tests/Private/Get-CIPPLicenseRecommendation.Tests.ps1 b/Tests/Private/Get-CIPPLicenseRecommendation.Tests.ps1
index 2fdbab195190b..72895bc31b51f 100644
--- a/Tests/Private/Get-CIPPLicenseRecommendation.Tests.ps1
+++ b/Tests/Private/Get-CIPPLicenseRecommendation.Tests.ps1
@@ -359,6 +359,184 @@ Describe 'Get-CIPPLicenseRecommendation' {
@($Report.Terms | Where-Object { $_.skuId -eq $Storage }).Count | Should -Be 0
}
+ It 'excludes an opaque add-on (no capability-mapped service plans) from consolidation' {
+ $Win365 = 'aaaaaaaa-1111-1111-1111-111111111111'
+ $PlanWin365Opaque = 'aaaaaaaa-2222-2222-2222-222222222222'
+
+ Mock -CommandName Get-CIPPLicenseCatalog -MockWith {
+ [pscustomobject]@{
+ meta = [pscustomobject]@{ monthlyCommitmentUplift = 0.2; seatLimits = [pscustomobject]@{ business = 300 } }
+ capabilities = @(
+ [pscustomobject]@{ id = 'email'; label = 'Email and calendar'; signal = 'exchange'; servicePlanIds = @($script:PlanExchange) }
+ [pscustomobject]@{ id = 'teams'; label = 'Teams chat and meetings'; signal = 'teams'; servicePlanIds = @($script:PlanTeams) }
+ [pscustomobject]@{ id = 'files'; label = 'File storage and sharing'; signal = 'files'; servicePlanIds = @($script:PlanSpo) }
+ [pscustomobject]@{ id = 'desktopApps'; label = 'Office desktop apps'; signal = 'desktopApps'; servicePlanIds = @($script:PlanOfficeBiz) }
+ [pscustomobject]@{ id = 'copilot'; label = 'Microsoft 365 Copilot'; signal = 'copilot'; servicePlanIds = @($script:PlanCopilot) }
+ [pscustomobject]@{ id = 'deviceManagement'; label = 'Device management'; signal = $null; servicePlanIds = @($script:PlanIntune) }
+ [pscustomobject]@{ id = 'signInSecurity'; label = 'Advanced sign-in security'; signal = $null; servicePlanIds = @($script:PlanAadP1) }
+ [pscustomobject]@{ id = 'endpointSecurity'; label = 'Device threat protection'; signal = $null; servicePlanIds = @($script:PlanMde) }
+ )
+ families = @()
+ products = @(
+ [pscustomobject]@{ skuId = $script:Basic; name = 'Business Basic'; family = 'business'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Standard; name = 'Business Standard'; family = 'business'; tier = 2; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Premium; name = 'Business Premium'; family = 'business'; tier = 3; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:AppsBiz; name = 'Apps for Business'; family = 'apps'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:ExP1; name = 'Exchange P1'; family = 'exchange'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Copilot; name = 'Copilot'; family = 'addon'; tier = 0; eligibleTarget = $false }
+ [pscustomobject]@{ skuId = $Win365; name = 'Windows 365'; family = 'other'; tier = 0; eligibleTarget = $false }
+ )
+ }
+ }
+ Mock -CommandName Get-CIPPLicensePrice -MockWith {
+ @(
+ [pscustomobject]@{ skuId = $script:Basic; Product_Display_Name = 'Business Basic'; MonthlyPrice = 7.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Standard; Product_Display_Name = 'Business Standard'; MonthlyPrice = 14.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Premium; Product_Display_Name = 'Business Premium'; MonthlyPrice = 22.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:AppsBiz; Product_Display_Name = 'Apps for Business'; MonthlyPrice = 10.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:ExP1; Product_Display_Name = 'Exchange P1'; MonthlyPrice = 4.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Copilot; Product_Display_Name = 'Copilot'; MonthlyPrice = 30.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $Win365; Product_Display_Name = 'Windows 365'; MonthlyPrice = 36.59; Currency = 'USD' }
+ )
+ }
+
+ $PlanIds = $script:PlanIds.Clone()
+ $PlanIds[$Win365] = @($PlanWin365Opaque)
+ $Users = @($script:Users) + (New-User 'combo-w365@contoso.com' @($script:Premium, $Win365) $script:Old)
+
+ $Report = Get-CIPPLicenseRecommendation -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $Users -ActivityDetail $script:Activity -AppUsage $script:Apps -MailboxUsage @() -CopilotUsage @() -PlanIdsBySku $PlanIds
+
+ @($Report.Upgrades | Where-Object { $_.Type -eq 'Consolidate' -and $_.Users.userPrincipalName -contains 'combo-w365@contoso.com' }) | Should -BeNullOrEmpty
+ @($Report.Suggestions | Where-Object { $_.Type -eq 'Combine licenses' -and $_.User -eq 'combo-w365@contoso.com' }) | Should -BeNullOrEmpty
+
+ # The existing describable consolidation still fires for other users
+ $Row = $Report.Upgrades | Where-Object { $_.Type -eq 'Consolidate' -and $_.ToSkuId -eq $script:Standard }
+ $Row | Should -Not -BeNullOrEmpty
+ $Row.Users.userPrincipalName | Should -Contain 'combo@contoso.com'
+ }
+
+ It 'excludes an opaque add-on from the Protect cost basis and target family' {
+ $Win365 = 'aaaaaaaa-1111-1111-1111-111111111111'
+ $PlanWin365Opaque = 'aaaaaaaa-2222-2222-2222-222222222222'
+
+ Mock -CommandName Get-CIPPLicenseCatalog -MockWith {
+ [pscustomobject]@{
+ meta = [pscustomobject]@{ monthlyCommitmentUplift = 0.2; seatLimits = [pscustomobject]@{ business = 300 } }
+ capabilities = @(
+ [pscustomobject]@{ id = 'email'; label = 'Email and calendar'; signal = 'exchange'; servicePlanIds = @($script:PlanExchange) }
+ [pscustomobject]@{ id = 'teams'; label = 'Teams chat and meetings'; signal = 'teams'; servicePlanIds = @($script:PlanTeams) }
+ [pscustomobject]@{ id = 'files'; label = 'File storage and sharing'; signal = 'files'; servicePlanIds = @($script:PlanSpo) }
+ [pscustomobject]@{ id = 'desktopApps'; label = 'Office desktop apps'; signal = 'desktopApps'; servicePlanIds = @($script:PlanOfficeBiz) }
+ [pscustomobject]@{ id = 'copilot'; label = 'Microsoft 365 Copilot'; signal = 'copilot'; servicePlanIds = @($script:PlanCopilot) }
+ [pscustomobject]@{ id = 'deviceManagement'; label = 'Device management'; signal = $null; servicePlanIds = @($script:PlanIntune) }
+ [pscustomobject]@{ id = 'signInSecurity'; label = 'Advanced sign-in security'; signal = $null; servicePlanIds = @($script:PlanAadP1) }
+ [pscustomobject]@{ id = 'endpointSecurity'; label = 'Device threat protection'; signal = $null; servicePlanIds = @($script:PlanMde) }
+ )
+ families = @()
+ products = @(
+ [pscustomobject]@{ skuId = $script:Basic; name = 'Business Basic'; family = 'business'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Standard; name = 'Business Standard'; family = 'business'; tier = 2; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Premium; name = 'Business Premium'; family = 'business'; tier = 3; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:AppsBiz; name = 'Apps for Business'; family = 'apps'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:ExP1; name = 'Exchange P1'; family = 'exchange'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Copilot; name = 'Copilot'; family = 'addon'; tier = 0; eligibleTarget = $false }
+ [pscustomobject]@{ skuId = $Win365; name = 'Windows 365'; family = 'other'; tier = 0; eligibleTarget = $false }
+ )
+ }
+ }
+ Mock -CommandName Get-CIPPLicensePrice -MockWith {
+ @(
+ [pscustomobject]@{ skuId = $script:Basic; Product_Display_Name = 'Business Basic'; MonthlyPrice = 7.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Standard; Product_Display_Name = 'Business Standard'; MonthlyPrice = 14.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Premium; Product_Display_Name = 'Business Premium'; MonthlyPrice = 22.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:AppsBiz; Product_Display_Name = 'Apps for Business'; MonthlyPrice = 10.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:ExP1; Product_Display_Name = 'Exchange P1'; MonthlyPrice = 4.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Copilot; Product_Display_Name = 'Copilot'; MonthlyPrice = 30.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $Win365; Product_Display_Name = 'Windows 365'; MonthlyPrice = 36.59; Currency = 'USD' }
+ )
+ }
+
+ $PlanIds = $script:PlanIds.Clone()
+ $PlanIds[$Win365] = @($PlanWin365Opaque)
+ $Users = @($script:Users) + (New-User 'basic-w365@contoso.com' @($script:Basic, $Win365) $script:Old)
+ $Activity = @($script:Activity) + (New-Activity 'basic-w365@contoso.com' $true $true $true)
+ $Apps = @($script:Apps) + (New-AppUsage 'basic-w365@contoso.com' $true)
+
+ $Report = Get-CIPPLicenseRecommendation -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $Users -ActivityDetail $Activity -AppUsage $Apps -MailboxUsage @() -CopilotUsage @() -PlanIdsBySku $PlanIds
+
+ $Row = $Report.Upgrades | Where-Object { $_.Type -eq 'Protect' -and $_.Users.userPrincipalName -contains 'basic-w365@contoso.com' }
+ $Row | Should -Not -BeNullOrEmpty
+ # The opaque add-on never joins the cost basis: only Business Basic is named and priced
+ $Row.FromLicenses | Should -Be @('Business Basic')
+ $Row.UnitCost | Should -Be 7.0
+
+ $Suggestion = $Report.Suggestions | Where-Object { $_.Type -eq 'Add protection' -and $_.User -eq 'basic-w365@contoso.com' }
+ $Suggestion | Should -Not -BeNullOrEmpty
+ $Suggestion.License | Should -Be 'Business Basic'
+ }
+
+ It 'reports the friendly name of a service plan a consolidation target would silently drop' {
+ $WinE3 = 'bbbbbbbb-1111-1111-1111-111111111111'
+ $PlanWinEnterprise = 'bbbbbbbb-2222-2222-2222-222222222222'
+ $PlanWinOpaque = 'bbbbbbbb-3333-3333-3333-333333333333'
+
+ Mock -CommandName Get-CIPPLicenseCatalog -MockWith {
+ [pscustomobject]@{
+ meta = [pscustomobject]@{ monthlyCommitmentUplift = 0.2; seatLimits = [pscustomobject]@{ business = 300 } }
+ capabilities = @(
+ [pscustomobject]@{ id = 'email'; label = 'Email and calendar'; signal = 'exchange'; servicePlanIds = @($script:PlanExchange) }
+ [pscustomobject]@{ id = 'teams'; label = 'Teams chat and meetings'; signal = 'teams'; servicePlanIds = @($script:PlanTeams) }
+ [pscustomobject]@{ id = 'files'; label = 'File storage and sharing'; signal = 'files'; servicePlanIds = @($script:PlanSpo) }
+ [pscustomobject]@{ id = 'desktopApps'; label = 'Office desktop apps'; signal = 'desktopApps'; servicePlanIds = @($script:PlanOfficeBiz) }
+ [pscustomobject]@{ id = 'copilot'; label = 'Microsoft 365 Copilot'; signal = 'copilot'; servicePlanIds = @($script:PlanCopilot) }
+ [pscustomobject]@{ id = 'deviceManagement'; label = 'Device management'; signal = $null; servicePlanIds = @($script:PlanIntune) }
+ [pscustomobject]@{ id = 'signInSecurity'; label = 'Advanced sign-in security'; signal = $null; servicePlanIds = @($script:PlanAadP1) }
+ [pscustomobject]@{ id = 'endpointSecurity'; label = 'Device threat protection'; signal = $null; servicePlanIds = @($script:PlanMde) }
+ [pscustomobject]@{ id = 'windowsEnterprise'; label = 'Windows Enterprise upgrade rights'; signal = $null; servicePlanIds = @($PlanWinEnterprise) }
+ )
+ families = @()
+ products = @(
+ [pscustomobject]@{ skuId = $script:Basic; name = 'Business Basic'; family = 'business'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Standard; name = 'Business Standard'; family = 'business'; tier = 2; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Premium; name = 'Business Premium'; family = 'business'; tier = 3; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:AppsBiz; name = 'Apps for Business'; family = 'apps'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:ExP1; name = 'Exchange P1'; family = 'exchange'; tier = 1; eligibleTarget = $true }
+ [pscustomobject]@{ skuId = $script:Copilot; name = 'Copilot'; family = 'addon'; tier = 0; eligibleTarget = $false }
+ [pscustomobject]@{ skuId = $WinE3; name = 'Windows Enterprise E3'; family = 'other'; tier = 0; eligibleTarget = $false }
+ )
+ }
+ }
+ Mock -CommandName Get-CIPPLicensePrice -MockWith {
+ @(
+ [pscustomobject]@{ skuId = $script:Basic; Product_Display_Name = 'Business Basic'; MonthlyPrice = 7.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Standard; Product_Display_Name = 'Business Standard'; MonthlyPrice = 14.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Premium; Product_Display_Name = 'Business Premium'; MonthlyPrice = 22.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:AppsBiz; Product_Display_Name = 'Apps for Business'; MonthlyPrice = 10.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:ExP1; Product_Display_Name = 'Exchange P1'; MonthlyPrice = 4.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $script:Copilot; Product_Display_Name = 'Copilot'; MonthlyPrice = 30.0; Currency = 'USD' }
+ [pscustomobject]@{ skuId = $WinE3; Product_Display_Name = 'Windows Enterprise E3'; MonthlyPrice = 10.0; Currency = 'USD' }
+ )
+ }
+
+ # Premium also carries the Windows Enterprise upgrade rights plan here, so it can cover
+ # (and be cheaper than) Standard + Windows Enterprise E3 combined.
+ $PlanIds = $script:PlanIds.Clone()
+ $PlanIds[$script:Premium] = @($script:PlanIds[$script:Premium]) + $PlanWinEnterprise
+ $PlanIds[$WinE3] = @($PlanWinEnterprise, $PlanWinOpaque)
+ $PlanNames = @{ $PlanWinOpaque = 'Windows 10/11 Enterprise (New)' }
+ $Users = @($script:Users) + (New-User 'winE3user@contoso.com' @($script:Standard, $WinE3) $script:Old)
+
+ $Report = Get-CIPPLicenseRecommendation -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $Users -ActivityDetail $script:Activity -AppUsage $script:Apps -MailboxUsage @() -CopilotUsage @() -PlanIdsBySku $PlanIds -PlanNamesById $PlanNames
+
+ $Row = $Report.Upgrades | Where-Object { $_.Type -eq 'Consolidate' -and $_.Users.userPrincipalName -contains 'winE3user@contoso.com' }
+ $Row | Should -Not -BeNullOrEmpty
+ $Row.Loses | Should -Contain 'Windows 10/11 Enterprise (New)'
+
+ $Suggestion = $Report.Suggestions | Where-Object { $_.Type -eq 'Combine licenses' -and $_.User -eq 'winE3user@contoso.com' }
+ $Suggestion | Should -Not -BeNullOrEmpty
+ $Suggestion.Reason | Should -Match 'would lose Windows 10/11 Enterprise \(New\)'
+ }
+
It 'sums the potential into the summary and lists what is paid for' {
$Report = Get-CIPPLicenseRecommendation -TenantFilter 'contoso.com' -Licenses $script:Licenses -Users $script:Users -ActivityDetail $script:Activity -AppUsage $script:Apps -MailboxUsage @() -CopilotUsage @() -PlanIdsBySku $script:PlanIds
diff --git a/Tests/Private/Get-CIPPMicrosoft365IPRanges.Tests.ps1 b/Tests/Private/Get-CIPPMicrosoft365IPRanges.Tests.ps1
new file mode 100644
index 0000000000000..f85204aabbd79
--- /dev/null
+++ b/Tests/Private/Get-CIPPMicrosoft365IPRanges.Tests.ps1
@@ -0,0 +1,49 @@
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ function Get-CIPPTable { param($TableName) @{ TableName = $TableName } }
+ function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
+ function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
+ function Invoke-CIPPRestMethod { param($Uri, $Method, $TimeoutSec) }
+ . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPMicrosoft365IPRanges.ps1')
+ $script:Service = [pscustomobject]@{ ips = @('40.107.0.0/16', '2603:1036::/36') }
+}
+
+Describe 'Get-CIPPMicrosoft365IPRanges' {
+ BeforeEach {
+ $script:M365IPRanges = $null
+ $script:Written = $null
+ Mock Add-CIPPAzDataTableEntity { $script:Written = $Entity }
+ }
+
+ It 'reads the web service once and keeps the list in the table, so a recycled worker does not refetch' {
+ Mock Get-CIPPAzDataTableEntity { }
+ Mock Invoke-CIPPRestMethod { @($script:Service, [pscustomobject]@{ ips = @('40.107.0.0/16') }) }
+ $Ranges = Get-CIPPMicrosoft365IPRanges
+ $Ranges | Should -Be @('40.107.0.0/16', '2603:1036::/36')
+ $script:Written.RowKey | Should -Be 'worldwide'
+ @($script:Written.JSON | ConvertFrom-Json) | Should -Be @('40.107.0.0/16', '2603:1036::/36')
+
+ # a recycled worker: the memo is gone, the table row is fresh
+ $script:M365IPRanges = $null
+ Mock Get-CIPPAzDataTableEntity { [pscustomobject]@{ JSON = $script:Written.JSON; Timestamp = [datetimeoffset]::UtcNow.AddHours(-2) } }
+ Get-CIPPMicrosoft365IPRanges | Should -Be @('40.107.0.0/16', '2603:1036::/36')
+ Should -Invoke Invoke-CIPPRestMethod -Times 1 -Exactly
+ }
+
+ It 'refreshes a day-old copy, and falls back to it when the web service is down' {
+ Mock Get-CIPPAzDataTableEntity { [pscustomobject]@{ JSON = '["13.107.6.152/31"]'; Timestamp = [datetimeoffset]::UtcNow.AddDays(-3) } }
+ Mock Invoke-CIPPRestMethod { $script:Service }
+ Get-CIPPMicrosoft365IPRanges | Should -Be @('40.107.0.0/16', '2603:1036::/36')
+
+ $script:M365IPRanges = $null
+ Mock Invoke-CIPPRestMethod { throw 'endpoints.office.com unreachable' }
+ Get-CIPPMicrosoft365IPRanges | Should -Be @('13.107.6.152/31')
+ $script:M365IPRanges.Expires | Should -BeLessThan ([datetime]::UtcNow.AddMinutes(15)) -Because 'a stale copy is retried soon'
+ }
+
+ It 'throws when there is neither a list nor a cached copy' {
+ Mock Get-CIPPAzDataTableEntity { }
+ Mock Invoke-CIPPRestMethod { throw 'endpoints.office.com unreachable' }
+ { Get-CIPPMicrosoft365IPRanges } | Should -Throw '*unreachable*'
+ }
+}
diff --git a/Tests/Private/Invoke-CIPPBecIPAnalysis.Tests.ps1 b/Tests/Private/Invoke-CIPPBecIPAnalysis.Tests.ps1
index 8ef64d059b07c..1797779442c30 100644
--- a/Tests/Private/Invoke-CIPPBecIPAnalysis.Tests.ps1
+++ b/Tests/Private/Invoke-CIPPBecIPAnalysis.Tests.ps1
@@ -6,6 +6,7 @@ BeforeAll {
function Get-CIPPBecColleagueSample { param($TenantFilter, $ExcludeUserId, $StartDate, $Count) }
function Get-CIPPBecCorrelatedUserPeers { param($TenantFilter, $UserIds, $IPs, $StartDate, $WindowStart) }
function Get-CIPPGeoIPLocationBatch { param([string[]]$IPs) }
+ function Get-CIPPMicrosoft365IPRanges { }
function Get-NormalizedError { param($message) $message }
foreach ($File in @('Authentication/ConvertTo-CIPPIPRange.ps1', 'Authentication/Test-IpInRange.ps1', 'Authentication/Resolve-CIPPIPAllowBlockList.ps1', 'BEC/ConvertTo-CIPPBecHostAddress.ps1', 'BEC/New-CIPPBecCollectorResult.ps1', 'BEC/Get-CIPPBecIPVerdicts.ps1', 'BEC/ConvertTo-CIPPBecIPEvents.ps1', 'BEC/Invoke-CIPPBecIPAnalysis.ps1')) {
. (Join-Path $RepoRoot "Modules/CIPPCore/Public/$File")
@@ -28,6 +29,7 @@ Describe 'Invoke-CIPPBecIPAnalysis' {
BeforeEach {
Mock Get-CIPPBecIPGuidance { New-CIPPBecCollectorResult -Data @() }
Mock Get-CIPPBecSignInBaseline { New-CIPPBecCollectorResult -Data $script:Baseline -Count 30 }
+ Mock Get-CIPPMicrosoft365IPRanges { throw 'endpoints.office.com unreachable' }
Mock Get-CIPPGeoIPLocationBatch { @{ '198.51.100.7' = [pscustomobject]@{ CountryOrRegion = 'NG'; City = 'Lagos'; Hosting = $true; Proxy = $false; ASName = 'DIGITALOCEAN-ASN' } } }
Mock Get-CIPPBecIPPeers { $R = @{}; foreach ($IP in $IPs) { $R[$IP] = [pscustomobject]@{ IP = $IP; OtherUsersBefore = 0; OtherUsersInWindowOnly = 2; Users = @('x@contoso.com', 'y@contoso.com') } }; $R }
}
@@ -42,6 +44,12 @@ Describe 'Invoke-CIPPBecIPAnalysis' {
$Analysis.PeersResult.Complete | Should -BeTrue
}
+ It 'classes Microsoft 365 front ends as a service, and judges as before when the list cannot be read' {
+ Mock Get-CIPPMicrosoft365IPRanges { @('198.51.100.0/24') }
+ ($script:Analysis = Invoke-Analysis).Verdicts | Where-Object IP -EQ '198.51.100.7' | ForEach-Object { $_.Verdict | Should -Be 'Service' }
+ Should -Invoke Get-CIPPBecIPPeers -Times 0 -Because 'a settled service address needs no other-account lookup'
+ }
+
It 're-uses the stored baseline and peers of the case instead of fetching them again' {
$Known = @([pscustomobject]@{ IP = '198.51.100.7'; OtherUsersBefore = 0; OtherUsersInWindowOnly = 0; Users = @() })
$null = Invoke-Analysis -Extra @{ Baseline = $script:Baseline; KnownPeers = $Known }
diff --git a/Tests/Private/Start-LogRetentionCleanup.Tests.ps1 b/Tests/Private/Start-LogRetentionCleanup.Tests.ps1
new file mode 100644
index 0000000000000..e703a754ec05f
--- /dev/null
+++ b/Tests/Private/Start-LogRetentionCleanup.Tests.ps1
@@ -0,0 +1,62 @@
+# Pester tests for Start-LogRetentionCleanup.
+#
+# CippLogs is partitioned by day (yyyyMMdd), so the cutoff is a PartitionKey range the table
+# service can seek to rather than a Timestamp filter over every row. The rerun guard used a 24h
+# interval against a daily timer, so every other run was blocked; it is 23h now.
+
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Start-LogRetentionCleanup.ps1' -File |
+ Select-Object -First 1 -ExpandProperty FullName
+ if (-not $FunctionPath) { throw 'Could not locate Start-LogRetentionCleanup.ps1 under Modules/' }
+
+ function Test-CIPPRerun { param($TenantFilter, $Type, $API, $Interval) }
+ function Get-CippTable { param($tablename) @{ TableName = $tablename } }
+ function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
+ function Get-AzDataTableEntity { param($TableName, $Filter, $Property, $First) }
+ function Remove-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
+ function Write-LogMessage { param($API, $message, $Sev, $LogData) }
+ function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } }
+
+ . $FunctionPath
+}
+
+Describe 'Start-LogRetentionCleanup' {
+ BeforeEach {
+ Mock Test-CIPPRerun { $false }
+ Mock Get-CIPPAzDataTableEntity { $null }
+ Mock Get-AzDataTableEntity { @() }
+ Mock Remove-CIPPAzDataTableEntity {}
+ Mock Write-LogMessage {}
+ }
+
+ It 'filters on a day PartitionKey range, not Timestamp' {
+ Start-LogRetentionCleanup
+ $Expected = (Get-Date).ToUniversalTime().AddDays(-90).ToString('yyyyMMdd')
+ Should -Invoke Get-AzDataTableEntity -Times 1 -Exactly -ParameterFilter { $Filter -eq "PartitionKey lt '$Expected'" }
+ }
+
+ It 'honours a configured retention' {
+ Mock Get-CIPPAzDataTableEntity { [pscustomobject]@{ RetentionDays = '30' } }
+ Start-LogRetentionCleanup
+ $Expected = (Get-Date).ToUniversalTime().AddDays(-30).ToString('yyyyMMdd')
+ Should -Invoke Get-AzDataTableEntity -ParameterFilter { $Filter -eq "PartitionKey lt '$Expected'" }
+ }
+
+ It 'uses a rerun interval shorter than the daily timer' {
+ Start-LogRetentionCleanup
+ Should -Invoke Test-CIPPRerun -ParameterFilter { $Interval -eq 82800 }
+ }
+
+ It 'keeps deleting while full batches come back' {
+ $script:Calls = 0
+ Mock Get-AzDataTableEntity {
+ $script:Calls++
+ $Size = if ($script:Calls -eq 1) { 5000 } else { 10 }
+ 1..$Size | ForEach-Object { [pscustomobject]@{ PartitionKey = '20200101'; RowKey = "$_" } }
+ }
+ Start-LogRetentionCleanup
+ Should -Invoke Remove-CIPPAzDataTableEntity -Times 2 -Exactly
+ Should -Invoke Write-LogMessage -ParameterFilter { $message -like 'Deleted 5010 old log entries in 2 batch*' }
+ }
+}
diff --git a/Tests/Private/Start-ReportAttachmentRetentionCleanup.Tests.ps1 b/Tests/Private/Start-ReportAttachmentRetentionCleanup.Tests.ps1
new file mode 100644
index 0000000000000..13d455275d5ee
--- /dev/null
+++ b/Tests/Private/Start-ReportAttachmentRetentionCleanup.Tests.ps1
@@ -0,0 +1,53 @@
+# Pester tests for Start-ReportAttachmentRetentionCleanup.
+#
+# Report attachments too large to email are uploaded to blob storage and tracked in ReportAttachmentBlobs.
+# The cleanup deletes each expired blob and drops its row; a row whose blob delete failed stays for the next run.
+
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Start-ReportAttachmentRetentionCleanup.ps1' -File |
+ Select-Object -First 1 -ExpandProperty FullName
+
+ function Get-CippTable { param($tablename) @{ TableName = $tablename } }
+ function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
+ function New-CIPPAzStorageRequest { param($Service, $Resource, $Method, $ConnectionString) }
+ function Remove-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
+ function Write-LogMessage { param($API, $message, $Sev, $LogData) }
+ function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } }
+
+ . $FunctionPath
+}
+
+Describe 'Start-ReportAttachmentRetentionCleanup' {
+ BeforeEach {
+ Mock Get-CIPPAzDataTableEntity -ParameterFilter { $TableName -eq 'Config' } { $null }
+ Mock Get-CIPPAzDataTableEntity -ParameterFilter { $TableName -eq 'ReportAttachmentBlobs' } {
+ @([pscustomobject]@{ RowKey = 'a'; BlobPath = 'report-attachments/a/r.pdf' }, [pscustomobject]@{ RowKey = 'b'; BlobPath = 'report-attachments/b/r.pdf' })
+ }
+ Mock New-CIPPAzStorageRequest {}
+ Mock Remove-CIPPAzDataTableEntity {}
+ Mock Write-LogMessage {}
+ }
+
+ It 'defaults to 360 days and deletes each expired blob and its row' {
+ Start-ReportAttachmentRetentionCleanup
+ $Expected = (Get-Date).AddDays(-360).ToUniversalTime().ToString('yyyy-MM-dd')
+ Should -Invoke Get-CIPPAzDataTableEntity -ParameterFilter { $TableName -eq 'ReportAttachmentBlobs' -and $Filter -like "*Timestamp lt datetime'$Expected*" }
+ Should -Invoke New-CIPPAzStorageRequest -Times 2 -Exactly -ParameterFilter { $Method -eq 'DELETE' }
+ Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { @($Entity.RowKey) -join ',' -eq 'a,b' }
+ }
+
+ It 'keeps the row when the blob delete fails, but drops it when the blob is already gone' {
+ Mock New-CIPPAzStorageRequest -ParameterFilter { $Resource -like '*/a/*' } { throw '500 InternalError' }
+ Mock New-CIPPAzStorageRequest -ParameterFilter { $Resource -like '*/b/*' } { throw '404 BlobNotFound' }
+ Start-ReportAttachmentRetentionCleanup
+ Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { @($Entity.RowKey) -join ',' -eq 'b' }
+ }
+
+ It 'honours a configured retention' {
+ Mock Get-CIPPAzDataTableEntity -ParameterFilter { $TableName -eq 'Config' } { [pscustomobject]@{ RetentionDays = '30' } }
+ Start-ReportAttachmentRetentionCleanup
+ $Expected = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-dd')
+ Should -Invoke Get-CIPPAzDataTableEntity -ParameterFilter { $TableName -eq 'ReportAttachmentBlobs' -and $Filter -like "*datetime'$Expected*" }
+ }
+}
diff --git a/Tests/Private/Update-CIPPSAMCertificate.Tests.ps1 b/Tests/Private/Update-CIPPSAMCertificate.Tests.ps1
index 476002fffe373..d8aa98716f7da 100644
--- a/Tests/Private/Update-CIPPSAMCertificate.Tests.ps1
+++ b/Tests/Private/Update-CIPPSAMCertificate.Tests.ps1
@@ -111,7 +111,7 @@ Describe 'Update-CIPPSAMCertificate' {
$script:MintCalled | Should -BeFalse
}
- It 'reconciles CIPP displayName orphans without minting' {
+ It 'keeps CIPP-named certs from other instances sharing the app' {
Mock -CommandName Get-CIPPSAMCertificateVersions -MockWith {
New-MockVersions -Current $script:CurrentCert
}
@@ -119,7 +119,7 @@ Describe 'Update-CIPPSAMCertificate' {
$Creds = [System.Collections.Generic.List[object]]::new()
$Creds.Add((New-MockCredential -Thumbprint $script:CurrentCert.Thumbprint -DisplayName 'CIPP-SAM Certificate (cipp-test)'))
foreach ($Orphan in $script:OrphanCerts) {
- $Creds.Add((New-MockCredential -Thumbprint $Orphan.Thumbprint -DisplayName "CIPP-SAM Certificate (orphan)"))
+ $Creds.Add((New-MockCredential -Thumbprint $Orphan.Thumbprint -DisplayName "CIPP-SAM Certificate (other-instance)"))
}
[PSCustomObject]@{ id = $script:AppObjectId; keyCredentials = @($Creds) }
}
@@ -127,13 +127,9 @@ Describe 'Update-CIPPSAMCertificate' {
$Result = Update-CIPPSAMCertificate
$Result.Renewed | Should -BeFalse
- $Result.Reconciled | Should -BeTrue
- $Result.RemovedCount | Should -Be 5
+ $Result.Reconciled | Should -BeFalse
$script:MintCalled | Should -BeFalse
- $script:PatchBodies.Count | Should -Be 1
- $Parsed = $script:PatchBodies[0] | ConvertFrom-Json
- @($Parsed.keyCredentials).Count | Should -Be 1
- $Parsed.keyCredentials[0].customKeyIdentifier | Should -Be $script:CurrentCert.Thumbprint
+ $script:PatchBodies.Count | Should -Be 0
}
It 'removes extras whose thumbprint is in HistoricalThumbprints' {
@@ -241,7 +237,7 @@ Describe 'Update-CIPPSAMCertificate' {
($Keys | Where-Object { $_.customKeyIdentifier -eq $script:PreviousCert.Thumbprint }).Count | Should -Be 0
}
- It 'bootstraps with no current and drops CIPP extras keeping unknowns' {
+ It 'bootstraps with no current and keeps every existing credential' {
Mock -CommandName Get-CIPPSAMCertificateVersions -MockWith {
New-MockVersions
}
@@ -261,10 +257,10 @@ Describe 'Update-CIPPSAMCertificate' {
$script:MintCalled | Should -BeTrue
$Parsed = $script:PatchBodies[0] | ConvertFrom-Json
$Keys = @($Parsed.keyCredentials)
- $Keys.Count | Should -Be 2
+ $Keys.Count | Should -Be 3
($Keys | Where-Object { $_.customKeyIdentifier -eq $script:UnknownCert.Thumbprint }).Count | Should -Be 1
($Keys | Where-Object { $_.key -eq $script:MintedCert.PublicKeyBase64 }).Count | Should -Be 1
- ($Keys | Where-Object { $_.customKeyIdentifier -eq $script:OrphanCert.Thumbprint }).Count | Should -Be 0
+ ($Keys | Where-Object { $_.customKeyIdentifier -eq $script:OrphanCert.Thumbprint }).Count | Should -Be 1
}
It 'rolls back only the new key when storage fails after mint' {
diff --git a/Tests/Reports/ConvertTo-CippReportPdf.Tests.ps1 b/Tests/Reports/ConvertTo-CippReportPdf.Tests.ps1
index fe555a2aefde3..d0455ec0276fd 100644
--- a/Tests/Reports/ConvertTo-CippReportPdf.Tests.ps1
+++ b/Tests/Reports/ConvertTo-CippReportPdf.Tests.ps1
@@ -83,6 +83,14 @@ Describe 'ConvertTo-CippReportPdf' {
$b = @(@{ type = 'richtable'; title = 'Tall'; columns = $Cols; rows = $Rows; limit = 10 })
Test-IsPdf (ConvertTo-CippReportPdf -Blocks $b) | Should -BeTrue
}
+ It 'renders a callout taller than a page (KeepTogether turns off so OfficeIMO does not throw)' {
+ # InfoBox/AlertBox are single-cell keep-together tables. A BEC full report can put dozens of
+ # skipped-collector or signal lines in one box; without the height guard that throws
+ # "Table height exceeds the available page content height."
+ $Lines = 1..80 | ForEach-Object { "Collector $_`: Missing licence for Exchange Online (not applicable)" }
+ $b = @(@{ type = 'alertbox'; title = '80 check(s) could not run'; content = ($Lines -join "`n"); lines = $true })
+ Test-IsPdf (ConvertTo-CippReportPdf -Blocks $b) | Should -BeTrue
+ }
It 'moves a row that would straddle a page break whole onto the next page' {
# Twenty short lines in a third-width column: about 220pt, under half a page, so it is kept
# whole. Filler rows put its top near the page foot, where it would otherwise be cut (a
diff --git a/Tests/Scheduler/Add-CIPPScheduledTask.HashtableInput.Tests.ps1 b/Tests/Scheduler/Add-CIPPScheduledTask.HashtableInput.Tests.ps1
index f628ff9b38290..c11639c271799 100644
--- a/Tests/Scheduler/Add-CIPPScheduledTask.HashtableInput.Tests.ps1
+++ b/Tests/Scheduler/Add-CIPPScheduledTask.HashtableInput.Tests.ps1
@@ -53,4 +53,16 @@ Describe 'Add-CIPPScheduledTask hashtable input' {
$Stored.Keys | Sort-Object | Should -Be @('message', 'Sev')
$Stored.message | Should -Be 'hi'
}
+
+ It 'stores ScheduledTime as a string when re-queuing with RunNow' {
+ Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
+ [pscustomobject]@{ PartitionKey = 'ScheduledTask'; RowKey = 'abc'; Name = 'Existing'; ScheduledTime = $script:Future; TaskState = 'Completed' }
+ }
+ Mock -CommandName Add-CippQueueMessage -MockWith { $true }
+
+ Add-CIPPScheduledTask -RunNow -RowKey 'abc'
+
+ $script:CapturedEntity.ScheduledTime | Should -BeOfType [string]
+ [int64]$script:CapturedEntity.ScheduledTime | Should -BeLessThan ([int64]$script:Future)
+ }
}
diff --git a/Tests/Standards/Invoke-CIPPStandardEWSAllowedAppIds.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardEWSAllowedAppIds.Tests.ps1
new file mode 100644
index 0000000000000..1ae6bb907488a
--- /dev/null
+++ b/Tests/Standards/Invoke-CIPPStandardEWSAllowedAppIds.Tests.ps1
@@ -0,0 +1,339 @@
+# Pester tests for the EWSAllowedAppIds standard, its shared state helper, the EWS permission
+# app discovery helper, and the baseline prepare hook / executor.
+#
+# Set-OrganizationConfig -EwsAllowedAppIDs replaces the whole list, so every write must carry
+# the existing IDs. Known-malicious IDs are never added and only removed when opted in.
+
+BeforeAll {
+ $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
+ $Files = @(
+ 'Get-CIPPEwsAllowedAppIdState.ps1'
+ 'Get-CIPPEwsPermissionApps.ps1'
+ 'Invoke-CIPPStandardEWSAllowedAppIds.ps1'
+ 'Get-CIPPBaselineEWSAllowedAppIdsState.ps1'
+ 'Invoke-CIPPBaselineEWSAllowedAppIds.ps1'
+ )
+ foreach ($File in $Files) {
+ $Path = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter $File -File | Select-Object -First 1 -ExpandProperty FullName
+ if (-not $Path) { throw "Could not locate $File under Modules/" }
+ . $Path
+ }
+
+ function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset) }
+ function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) }
+ function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid) }
+ function New-CIPPDbRequest { [CmdletBinding()] param($TenantFilter, $Type, $Fields) }
+ function New-GraphBulkRequest { [CmdletBinding()] param($Requests, $tenantid) }
+ function Get-CIPPBecRogueAppFeed { [CmdletBinding()] param() }
+ function Get-CIPPTextReplacement { [CmdletBinding()] param($TenantFilter, $Text) }
+ function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev) }
+ function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) }
+ function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) }
+ function Get-NormalizedError { [CmdletBinding()] param($Message) $Message }
+
+ $script:Tenant = 'contoso.onmicrosoft.com'
+ $script:Office = 'd3590ed6-52b3-4102-aeff-aad2292ab01c'
+ $script:PowerQuery = 'a672d62c-fc7b-4e81-a576-e60dc46e951d'
+ $script:PowerBI = 'b52893c8-bc2e-47fc-918b-77022b299bbc'
+ $script:AppleMail = 'f8d98a96-0999-43f5-8af3-69971c7bb423'
+ $script:Defaults = @($script:Office, $script:PowerQuery, $script:PowerBI, $script:AppleMail)
+ $script:Existing = '11111111-1111-1111-1111-111111111111'
+ $script:Bad = 'bad00000-0000-0000-0000-000000000bad'
+ $script:ExoSpId = 'exo-sp'
+ $script:FullAccessRole = 'dc890d15-9560-4a4c-9b7f-a736ec74ec40'
+
+ # Classic settings: discovery off unless a test asks for it.
+ function script:New-Settings {
+ param([hashtable]$Extra = @{})
+ $Settings = @{ remediate = $true; alert = $true; report = $true; includeHybridApp = $false }
+ foreach ($Key in $Extra.Keys) { $Settings[$Key] = $Extra[$Key] }
+ $Settings
+ }
+ function script:Get-WrittenIds {
+ @("$($script:SetCalls[-1].EwsAllowedAppIDs)" -split ',')
+ }
+}
+
+Describe 'Invoke-CIPPStandardEWSAllowedAppIds' {
+ BeforeEach {
+ $script:OrgConfig = [pscustomobject]@{ EwsEnabled = $true; EwsAllowedAppIDs = $null }
+ $script:SetCalls = [System.Collections.Generic.List[hashtable]]::new()
+ $script:Logs = [System.Collections.Generic.List[object]]::new()
+ $script:Compare = $null
+ $script:Cache = @{ ServicePrincipals = @(); AppRoleAssignments = @(); OAuth2PermissionGrants = @() }
+
+ Mock -CommandName Test-CIPPStandardLicense -MockWith { $true }
+ Mock -CommandName New-ExoRequest -MockWith {
+ param($tenantid, $cmdlet, $cmdParams)
+ if ($cmdlet -eq 'Get-OrganizationConfig') { return $script:OrgConfig }
+ if ($cmdlet -eq 'Set-OrganizationConfig') { $script:SetCalls.Add($cmdParams) }
+ }
+ Mock -CommandName Get-CIPPBecRogueAppFeed -MockWith {
+ [pscustomobject]@{ Apps = @{ $script:Bad = [pscustomobject]@{ Name = 'Evil Sync' } } }
+ }
+ Mock -CommandName Get-CIPPTextReplacement -MockWith {
+ param($TenantFilter, $Text)
+ $Text -replace '%veeam_ews_appid%', '22222222-2222-2222-2222-222222222222,33333333-3333-3333-3333-333333333333'
+ }
+ Mock -CommandName New-CIPPDbRequest -MockWith { param($TenantFilter, $Type) $script:Cache[$Type] }
+ # Assignments and grants are always read live; only service principals come from the cache.
+ Mock -CommandName New-GraphGetRequest -MockWith {
+ param($uri)
+ switch -Regex ($uri) {
+ 'appRoleAssignedTo' { return $script:Cache.AppRoleAssignments }
+ 'oauth2PermissionGrants' { return $script:Cache.OAuth2PermissionGrants }
+ default { return @() }
+ }
+ }
+ Mock -CommandName New-GraphBulkRequest -MockWith { @() }
+ Mock -CommandName Write-LogMessage -MockWith { param($API, $tenant, $message, $sev) $script:Logs.Add(@{ Message = $message; Sev = $sev }) }
+ Mock -CommandName Write-StandardsAlert -MockWith { }
+ Mock -CommandName Set-CIPPStandardsCompareField -MockWith { param($FieldName, $CurrentValue) $script:Compare = $CurrentValue }
+ }
+
+ It 'keeps every existing ID when adding the required ones' {
+ $script:OrgConfig.EwsAllowedAppIDs = "$($script:Existing),44444444-4444-4444-4444-444444444444"
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ presets = @(@{ label = 'Office'; value = 'MicrosoftOffice' }) })
+
+ $script:SetCalls.Count | Should -Be 1
+ $script:SetCalls[0].EwsEnabled | Should -BeTrue
+ $Written = Get-WrittenIds
+ $Written | Should -Contain $script:Existing
+ $Written | Should -Contain '44444444-4444-4444-4444-444444444444'
+ $Written | Should -Contain $script:Office
+ $Written.Count | Should -Be 3
+ }
+
+ It 'uses the default presets when none are selected' {
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings)
+
+ (Get-WrittenIds | Sort-Object) | Should -Be ($script:Defaults | Sort-Object)
+ }
+
+ It 'de-duplicates case-insensitively and accepts the list as an array' {
+ $script:OrgConfig.EwsAllowedAppIDs = @($script:Office.ToUpperInvariant(), $script:Existing)
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ customAppIds = @($script:PowerQuery.ToUpperInvariant(), $script:PowerQuery) })
+
+ $Written = Get-WrittenIds
+ @($Written | Where-Object { $_ -eq $script:Office }).Count | Should -Be 1
+ @($Written | Where-Object { $_ -eq $script:PowerQuery }).Count | Should -Be 1
+ $Written | Should -Contain $script:Existing
+ $Written.Count | Should -Be 5
+ $Written | ForEach-Object { $_ | Should -BeExactly $_.ToLowerInvariant() }
+ }
+
+ It 'does not call Set-OrganizationConfig when already compliant' {
+ $script:OrgConfig.EwsAllowedAppIDs = (@($script:Defaults) + $script:Existing) -join ','
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings)
+
+ $script:SetCalls.Count | Should -Be 0
+ $script:Compare.MissingAppIds | Should -BeNullOrEmpty
+ $script:Compare.EwsEnabled | Should -BeTrue
+ }
+
+ It 'enables EWS when the list is complete but EwsEnabled is not true' {
+ $script:OrgConfig.EwsEnabled = $null
+ $script:OrgConfig.EwsAllowedAppIDs = $script:Defaults -join ','
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings)
+
+ $script:SetCalls.Count | Should -Be 1
+ $script:SetCalls[0].EwsEnabled | Should -BeTrue
+ (Get-WrittenIds | Sort-Object) | Should -Be ($script:Defaults | Sort-Object)
+ }
+
+ It 'expands tenant variables and skips invalid custom entries without failing the run' {
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ customAppIds = @('%veeam_ews_appid%', 'not-a-guid') })
+
+ $Written = Get-WrittenIds
+ $Written | Should -Contain '22222222-2222-2222-2222-222222222222'
+ $Written | Should -Contain '33333333-3333-3333-3333-333333333333'
+ $Written | Should -Not -Contain 'not-a-guid'
+ @($script:Logs | Where-Object { $_.Message -match "not-a-guid" -and $_.Sev -eq 'Warning' }).Count | Should -Be 1
+ }
+
+ It 'never adds a known-malicious app ID' {
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ customAppIds = @($script:Bad.ToUpperInvariant()) })
+
+ Get-WrittenIds | Should -Not -Contain $script:Bad
+ @($script:Logs | Where-Object { $_.Message -match 'Evil Sync' -and $_.Message -match 'not added' }).Count | Should -Be 1
+ }
+
+ It 'keeps and reports a known-malicious app already on the list when removal is off' {
+ $script:OrgConfig.EwsAllowedAppIDs = "$($script:Bad),$($script:Office)"
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings)
+
+ Get-WrittenIds | Should -Contain $script:Bad
+ $script:Compare.MaliciousAppIdsPresent | Should -Be @($script:Bad)
+ Should -Invoke Write-StandardsAlert -Times 1
+ }
+
+ It 'removes a known-malicious app already on the list only when removal is on' {
+ $script:OrgConfig.EwsAllowedAppIDs = (@($script:Defaults) + $script:Bad) -join ','
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ removeMaliciousApps = $true })
+
+ $script:SetCalls.Count | Should -Be 1
+ Get-WrittenIds | Should -Not -Contain $script:Bad
+ $script:Compare.MaliciousAppIdsPresent | Should -BeNullOrEmpty
+ }
+
+ It 'does not write when only a known-malicious app is present and removal is off' {
+ $script:OrgConfig.EwsAllowedAppIDs = (@($script:Defaults) + $script:Bad) -join ','
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings)
+
+ $script:SetCalls.Count | Should -Be 0
+ $script:Compare.MaliciousAppIdsPresent | Should -Be @($script:Bad)
+ }
+
+ Context 'discovered apps' {
+ BeforeEach {
+ $script:HybridAppId = 'aaaaaaaa-0000-0000-0000-00000000000a'
+ $script:LookalikeAppId = 'bbbbbbbb-0000-0000-0000-00000000000b'
+ $script:DelegatedAppId = 'cccccccc-0000-0000-0000-00000000000c'
+ $script:Cache.ServicePrincipals = @(
+ [pscustomobject]@{ id = $script:ExoSpId; appId = '00000002-0000-0ff1-ce00-000000000000'; displayName = 'Office 365 Exchange Online'; appRoles = @([pscustomobject]@{ id = $script:FullAccessRole; value = 'full_access_as_app' }) }
+ [pscustomobject]@{ id = 'sp-hybrid'; appId = $script:HybridAppId; displayName = 'ExchangeServerApp-5d8ac7f9-1111-2222-3333-444444444444' }
+ [pscustomobject]@{ id = 'sp-lookalike'; appId = $script:LookalikeAppId; displayName = 'ExchangeServerApp-lookalike' }
+ [pscustomobject]@{ id = 'sp-delegated'; appId = $script:DelegatedAppId; displayName = 'Legacy EWS tool' }
+ [pscustomobject]@{ id = 'sp-evil'; appId = $script:Bad; displayName = 'Evil Sync' }
+ )
+ $script:Cache.AppRoleAssignments = @(
+ [pscustomobject]@{ principalId = 'sp-hybrid'; principalType = 'ServicePrincipal'; resourceId = $script:ExoSpId; appRoleId = $script:FullAccessRole }
+ [pscustomobject]@{ principalId = 'sp-lookalike'; principalType = 'ServicePrincipal'; resourceId = $script:ExoSpId; appRoleId = 'some-other-role' }
+ [pscustomobject]@{ principalId = 'sp-evil'; principalType = 'ServicePrincipal'; resourceId = $script:ExoSpId; appRoleId = $script:FullAccessRole }
+ )
+ $script:Cache.OAuth2PermissionGrants = @(
+ [pscustomobject]@{ clientId = 'sp-delegated'; resourceId = $script:ExoSpId; scope = 'openid EWS.AccessAsUser.All' }
+ [pscustomobject]@{ clientId = 'sp-lookalike'; resourceId = $script:ExoSpId; scope = 'full_access_as_user' }
+ [pscustomobject]@{ clientId = 'sp-hybrid'; resourceId = 'graph-sp'; scope = 'EWS.AccessAsUser.All' }
+ )
+ }
+
+ It 'adds the Exchange hybrid app but not a same-prefix app without full_access_as_app' {
+ # The lookalike holds a delegated EWS grant and a non-EWS app role, never full_access_as_app.
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ includeHybridApp = $true })
+
+ $Written = Get-WrittenIds
+ $Written | Should -Contain $script:HybridAppId
+ $Written | Should -Not -Contain $script:LookalikeAppId
+ $Written | Should -Not -Contain $script:DelegatedAppId
+ }
+
+ It 'includes the hybrid app when the setting is absent (default on)' {
+ $Settings = New-Settings
+ $Settings.Remove('includeHybridApp')
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings $Settings
+
+ Get-WrittenIds | Should -Contain $script:HybridAppId
+ }
+
+ It 'adds application and delegated EWS permission holders, but never a malicious one' {
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ includeEwsPermissionApps = $true })
+
+ $Written = Get-WrittenIds
+ $Written | Should -Contain $script:HybridAppId
+ $Written | Should -Contain $script:DelegatedAppId
+ $Written | Should -Contain $script:LookalikeAppId
+ $Written | Should -Not -Contain $script:Bad
+ }
+
+ It 'describes each EWS permission holder' {
+ $Apps = @(Get-CIPPEwsPermissionApps -TenantFilter $script:Tenant)
+
+ $Apps.Count | Should -Be 4
+ $Hybrid = $Apps | Where-Object appId -EQ $script:HybridAppId
+ $Hybrid.isExchangeHybridApp | Should -BeTrue
+ $Hybrid.permissionType | Should -Be 'Application'
+ $Hybrid.permissions | Should -Be @('full_access_as_app')
+ $Hybrid.servicePrincipalId | Should -Be 'sp-hybrid'
+ $Delegated = $Apps | Where-Object appId -EQ $script:DelegatedAppId
+ $Delegated.permissionType | Should -Be 'Delegated'
+ $Delegated.permissions | Should -Be @('EWS.AccessAsUser.All')
+ $Delegated.isExchangeHybridApp | Should -BeFalse
+ ($Apps | Where-Object appId -EQ $script:LookalikeAppId).isExchangeHybridApp | Should -BeFalse
+ $Hybrid.permissionType | Should -Not -Match 'Delegated'
+ $Evil = $Apps | Where-Object appId -EQ $script:Bad
+ $Evil.isKnownMalicious | Should -BeTrue
+ $Evil.maliciousName | Should -Be 'Evil Sync'
+ Should -Invoke New-GraphGetRequest -Times 2 -Exactly
+ Should -Invoke New-GraphBulkRequest -Times 0
+ }
+
+ It 'reads service principals live when the cache is empty for the tenant' {
+ $script:Live = $script:Cache.Clone()
+ $script:Cache = @{ ServicePrincipals = @(); AppRoleAssignments = $script:Live.AppRoleAssignments; OAuth2PermissionGrants = $script:Live.OAuth2PermissionGrants }
+ Mock -CommandName New-GraphGetRequest -MockWith {
+ param($uri)
+ switch -Regex ($uri) {
+ 'appRoleAssignedTo' { return $script:Live.AppRoleAssignments }
+ 'oauth2PermissionGrants' { return $script:Live.OAuth2PermissionGrants }
+ 'servicePrincipals\?' { return $script:Live.ServicePrincipals }
+ }
+ }
+
+ $Apps = @(Get-CIPPEwsPermissionApps -TenantFilter $script:Tenant)
+
+ ($Apps.appId | Sort-Object) | Should -Be (@($script:HybridAppId, $script:LookalikeAppId, $script:DelegatedAppId, $script:Bad) | Sort-Object)
+ Should -Invoke New-GraphGetRequest -Times 3 -Exactly
+ }
+
+ It 'resolves holders missing from the cache with one batch request' {
+ $Missing = $script:Cache.ServicePrincipals | Where-Object id -EQ 'sp-hybrid'
+ $script:Cache.ServicePrincipals = @($script:Cache.ServicePrincipals | Where-Object id -NE 'sp-hybrid')
+ Mock -CommandName New-GraphBulkRequest -MockWith {
+ param($Requests)
+ @($Requests) | ForEach-Object { @{ id = $_.id; status = 200; body = $Missing } }
+ }
+
+ $Apps = @(Get-CIPPEwsPermissionApps -TenantFilter $script:Tenant)
+
+ ($Apps | Where-Object appId -EQ $script:HybridAppId).isExchangeHybridApp | Should -BeTrue
+ Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { @($Requests).Count -eq 1 -and $Requests[0].id -eq 'sp-hybrid' }
+ }
+
+ It 'keeps going with presets when discovery fails' {
+ $script:Cache.ServicePrincipals = @()
+ Mock -CommandName New-GraphGetRequest -MockWith { throw 'Graph unavailable' }
+
+ Invoke-CIPPStandardEWSAllowedAppIds -Tenant $script:Tenant -Settings (New-Settings @{ includeEwsPermissionApps = $true })
+
+ (Get-WrittenIds | Sort-Object) | Should -Be ($script:Defaults | Sort-Object)
+ @($script:Logs | Where-Object { $_.Message -match 'could not discover' }).Count | Should -Be 1
+ }
+ }
+
+ Context 'baseline hook and executor' {
+ It 'grades missing IDs and the executor merges them into the live list' {
+ $script:OrgConfig.EwsAllowedAppIDs = $script:Existing
+ $Item = @{ Variables = [pscustomobject]@{ presets = @('MicrosoftOffice'); customAppIds = @(); includeEwsPermissionApps = $false; includeHybridApp = $false; removeMaliciousApps = $false } }
+
+ $Prepared = Get-CIPPBaselineEWSAllowedAppIdsState -Item $Item -TenantFilter $script:Tenant
+ $Prepared.Current.missingAppIds | Should -Be @($script:Office)
+ $Prepared.Expected.missingAppIds | Should -BeNullOrEmpty
+
+ # The list changed between grading and writing: the live entry must survive.
+ $script:OrgConfig.EwsAllowedAppIDs = "$($script:Existing),55555555-5555-5555-5555-555555555555"
+ Invoke-CIPPBaselineEWSAllowedAppIds -Remediate $null -TenantFilter $script:Tenant -Current $Prepared.Current
+
+ $Written = Get-WrittenIds
+ $Written | Should -Contain $script:Existing
+ $Written | Should -Contain '55555555-5555-5555-5555-555555555555'
+ $Written | Should -Contain $script:Office
+ }
+
+ It 'throws after writing when a known-malicious app stays on the list' {
+ $script:OrgConfig.EwsAllowedAppIDs = $script:Bad
+ $Item = @{ Variables = [pscustomobject]@{ presets = @('MicrosoftOffice'); includeHybridApp = $false; removeMaliciousApps = $false } }
+ $Prepared = Get-CIPPBaselineEWSAllowedAppIdsState -Item $Item -TenantFilter $script:Tenant
+
+ { Invoke-CIPPBaselineEWSAllowedAppIds -Remediate $null -TenantFilter $script:Tenant -Current $Prepared.Current } | Should -Throw '*known-malicious*'
+ Get-WrittenIds | Should -Contain $script:Office
+ }
+ }
+}
diff --git a/version_latest.txt b/version_latest.txt
index 4a68b557e6db9..071973805f533 100644
--- a/version_latest.txt
+++ b/version_latest.txt
@@ -1 +1 @@
-11.0.0
\ No newline at end of file
+11.0.1
|