diff --git a/Config/PermissionsTranslator.json b/Config/PermissionsTranslator.json index 52f29c1194335..c7163822eb098 100644 --- a/Config/PermissionsTranslator.json +++ b/Config/PermissionsTranslator.json @@ -5371,5 +5371,23 @@ "userConsentDescription": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", "userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", "value": "TeamsTelephoneNumber.ReadWrite.All" + }, + { + "description": "Allows the app to read and write Copilot policy settings for the organization, on behalf of the signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", + "Origin": "Delegated", + "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, on your behalf.", + "userConsentDisplayName": "Read and write Copilot policy settings", + "value": "CopilotPolicySettings.ReadWrite" + }, + { + "description": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", + "Origin": "Application", + "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", + "userConsentDisplayName": "Read and write Copilot policy settings", + "value": "CopilotPolicySettings.ReadWrite" } ] diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index 3b304150e7cf8..56fc2811de489 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -582,6 +582,42 @@ { "id": "b7887744-6746-4312-813d-72daeaee7e2d", "type": "Scope" + }, + { + "id": "dd199f4a-f148-40a4-a2ec-f0069cc799ec", + "type": "Role" + }, + { + "id": "8c026be3-8e26-4774-9372-8d5d6f21daff", + "type": "Scope" + }, + { + "id": "fee28b28-e1f3-4841-818e-2704dc62245f", + "type": "Role" + }, + { + "id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd", + "type": "Scope" + }, + { + "id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8", + "type": "Role" + }, + { + "id": "31e08e0a-d3f7-4ca2-ac39-7343fb83e8ad", + "type": "Role" + }, + { + "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68", + "type": "Scope" + }, + { + "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", + "type": "Role" + }, + { + "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", + "type": "Scope" } ] }, @@ -689,4 +725,4 @@ "isEnabled": true, "allProperties": true } -} \ No newline at end of file +} diff --git a/Config/standards.json b/Config/standards.json index 5a8d0a83eba9f..52630842cdce3 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1371,21 +1371,38 @@ "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)"], "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], - "helpText": "Sets the state of the registration campaign for the tenant", - "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", + "helpText": "Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users.", + "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name).", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", "addedComponent": [ { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select value", + "label": "Registration campaign state", "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, { "label": "Disabled", "value": "disabled" } ] }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Authentication method to nudge users to register (default is Microsoft Authenticator)", + "name": "standards.NudgeMFA.targetedAuthenticationMethod", + "options": [ + { "label": "Microsoft Authenticator", "value": "microsoftAuthenticator" }, + { "label": "Passkey (FIDO2)", "value": "fido2" } + ], + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, { "type": "number", "name": "standards.NudgeMFA.snoozeDurationInDays", @@ -1395,6 +1412,39 @@ "min": { "value": 0, "message": "Minimum value is 0" }, "max": { "value": 14, "message": "Maximum value is 14" } } + }, + { + "type": "switch", + "name": "standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes", + "label": "Limited number of snoozes (require registration after 3 snoozes)", + "defaultValue": true, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.includeTargets", + "label": "Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.excludeTargets", + "label": "Exclude groups (comma separated group names, blank = keep current exclusions)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } } ], "label": "Sets the state for the request to setup Authenticator", @@ -5070,6 +5120,7 @@ }, { "name": "standards.SPDirectSharing", + "deprecated": true, "cat": "SharePoint Standards", "tag": [], "helpText": "This standard has been deprecated in favor of the Default Sharing Link standard. ", @@ -6530,7 +6581,7 @@ "label": "Conditional Access Template", "cat": "Templates", "multiple": true, - "disabledFeatures": { "report": true, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", "tag": [ @@ -8043,7 +8094,7 @@ "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", - "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforce).", "addedComponent": [ { "type": "number", @@ -8070,7 +8121,7 @@ "label": "On-Premises Mode", "options": [ { "label": "Audit", "value": "Audit" }, - { "label": "Enforced", "value": "Enforced" } + { "label": "Enforce", "value": "Enforce" } ] } ], diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index f0eac8d4d0020..a0bb9f7693082 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -198,7 +198,7 @@ function Push-CIPPDBCacheData { } else { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } - + if ($DefenderCapable) { $Tasks.Add(@{ FunctionName = 'ExecCIPPDBCache' @@ -219,15 +219,7 @@ function Push-CIPPDBCacheData { QueueId = $QueueId QueueName = "DB Cache SharePoint - $TenantFilter" }) - # SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and - # spawns a child orchestrator (one activity per site) that needs its own time budget. - $Tasks.Add(@{ - FunctionName = 'ExecCIPPDBCache' - Name = 'SharePointSharingLinks' - TenantFilter = $TenantFilter - QueueId = $QueueId - QueueName = "DB Cache SharePointSharingLinks - $TenantFilter" - }) + # SharePointSharingLinks runs adhoc since it can take a long time to enumerate all sharing links for large tenants } else { Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" } diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 0a5fc8b318b1a..f6bd718c2b00a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -377,12 +377,23 @@ function Get-CIPPDrift { $tenantPolicy.policy | Add-Member -MemberType NoteProperty -Name 'URLName' -Value $TenantPolicy.Type -Force $TenantPolicyName = if ($TenantPolicy.Policy.displayName) { $TenantPolicy.Policy.displayName } else { $TenantPolicy.Policy.name } foreach ($TemplatePolicy in $TemplateIntuneTemplates) { - $TemplatePolicyName = if ($TemplatePolicy.displayName) { $TemplatePolicy.displayName } else { $TemplatePolicy.name } - - if ($TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName -or - $TemplatePolicy.name -eq $TenantPolicy.Policy.name -or - $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name -or - $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName) { + # Compare displayName-to-displayName and name-to-name (plus the cross pairings, + # since some policy types are captured under one property but deployed under the + # other) but require BOTH sides of each pairing to be non-empty before treating it + # as a match. Most Intune policy types (compliance policies, device configurations, + # group policy configs, etc.) only expose displayName and have no .name property at + # all, so comparing raw .name values directly (as before) compared $null -eq $null, + # which is $true in PowerShell - causing every tenant policy to falsely "match" the + # first template as soon as any template lacked a .name property, suppressing all + # tenant-only deviations. Note: templates always get a .displayName forced onto them + # (see Add-Member above) even for name-only policy types like Settings Catalog + # (deviceManagement/configurationPolicies), so the name-to-name pairing must still be + # compared directly from the raw properties - collapsing to a single "effective name + # preferring displayName" per side would silently break matching for those policies. + if (($TemplatePolicy.displayName -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName) -or + ($TemplatePolicy.name -and $TenantPolicy.Policy.name -and $TemplatePolicy.name -eq $TenantPolicy.Policy.name) -or + ($TemplatePolicy.displayName -and $TenantPolicy.Policy.name -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name) -or + ($TemplatePolicy.name -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName)) { $PolicyFound = $true break } diff --git a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 index f3b6d1b8f06cb..ed6568c6d364b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 @@ -4,15 +4,66 @@ function Get-CIPPTestData { Cached wrapper around New-CIPPDbRequest for test functions .DESCRIPTION - Returns cached tenant data during test suite execution. The cache is - backed by CIPP.TestDataCache (static ConcurrentDictionary in C#) so - it is shared across all PowerShell runspaces within the worker process. + Returns cached tenant data during test suite execution. The cache is backed by + CIPP.TestDataCache (static ConcurrentDictionary in C#) so it is shared across all + PowerShell runspaces within the worker process. + + RECORDS ARE FIELD-PROJECTED. Only the fields listed for $Type in + Get-CippTestDataFieldManifest are materialized. Reading an unlisted field returns $null + with no error, so the test emits a wrong verdict silently. If you add a field read to a + test, add it to the manifest first. A type absent from the manifest is fetched whole, + which is the safe default. + + Projection is record-level: a kept field keeps its entire subtree, so + `$policy.conditions.users.includeRoles` only requires 'conditions'. + + Records are PSCustomObject and must stay so. Hashtable output changes scalar semantics + ($x[0] becomes a key lookup returning $null; $x.Count returns the record's field count + instead of 1) and only when a pipeline yields exactly one record — data-dependent, and + easily missed in testing. .PARAMETER TenantFilter The tenant domain or GUID to filter by .PARAMETER Type The data type to retrieve (e.g., Users, Groups, ConditionalAccessPolicies) + + .PARAMETER Fields + Optional. Override the manifest for this call: keep only these top-level fields. Prefer + the manifest; this is for one-off and diagnostic callers, not tests. + + The field set is part of the cache key — it must be, or callers asking for the same + tenant+type with different lists would receive each other's projection. Distinct lists + therefore fragment the cache: the same rows parsed once per list, all alive together for + the TTL. Per-call-site lists measured several times worse than one shared per-type entry. + Fields belong to the type — see Get-CippTestDataFieldManifest. + + .PARAMETER NoProjection + Return every field, ignoring the manifest. + + Required for the custom-script sandbox (Get-CippSandboxData): those scripts are + customer-authored, so the fields they read are unknowable and a manifest built from our + own tests would silently hand them incomplete records. Any caller fetching data on behalf + of code we did not write must pass this. + + .EXAMPLE + Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + Normal use: the manifest decides the fields. This is what tests should do. + + .EXAMPLE + Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' -NoProjection + Every field. For callers serving code we did not author. + + .NOTES + Verifying a change: "0 errored" proves nothing, because the failure modes here are silent + — a broken test still reports success. Diff test verdicts against a baseline, across more + than one tenant: a bug that needs exactly one matching record will not show on a tenant + that has two. + + Useful endpoints: ExecTestRun then ListTests for verdicts; ListDBCache to confirm a field + exists and its real casing before adding it to the manifest (type=_availableTypes lists + the types); ListWorkerHealth?Action=CacheDiag for entries, hit rate and a per-type + breakdown whose keys carry the projected field list. #> [CmdletBinding()] param( @@ -20,7 +71,13 @@ function Get-CIPPTestData { [string]$TenantFilter, [Parameter(Mandatory = $false)] - [string]$Type + [string]$Type, + + [Parameter(Mandatory = $false)] + [string[]]$Fields, + + [Parameter(Mandatory = $false)] + [switch]$NoProjection ) # Enforce tenant lock when running inside custom script execution @@ -32,14 +89,36 @@ function Get-CIPPTestData { throw 'TenantFilter is required.' } - $CacheKey = '{0}|{1}' -f $TenantFilter, $Type + # Resolve the field set: an explicit -Fields wins, otherwise consult the per-type manifest. + # -NoProjection suppresses both (the sandbox path must never receive projected records). + $EffectiveFields = if ($NoProjection) { + $null + } elseif ($Fields) { + $Fields + } else { + Get-CippTestDataFieldManifest -Type $Type + } + + # Normalize the field set (sorted + de-duplicated) so that callers asking for the same + # fields in a different order share one cache entry instead of parsing twice. + $NormalizedFields = if ($EffectiveFields) { @($EffectiveFields | Sort-Object -Unique) } else { $null } + + $CacheKey = if ($NormalizedFields) { + '{0}|{1}|{2}' -f $TenantFilter, $Type, ($NormalizedFields -join ',') + } else { + '{0}|{1}' -f $TenantFilter, $Type + } $CachedValue = $null if ([CIPP.TestDataCache]::TryGet($CacheKey, [ref]$CachedValue)) { return $CachedValue } - $Data = New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type + $Data = if ($NormalizedFields) { + New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type -Fields $NormalizedFields + } else { + New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type + } [CIPP.TestDataCache]::Set($CacheKey, $Data) diff --git a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 index 0b5b026df61aa..78b144efa856c 100644 --- a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 +++ b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 @@ -1,4 +1,47 @@ function Get-CippDbRoleMembers { + <# + .SYNOPSIS + Resolve the members of a directory role from cached PIM + directoryRole data. + + .DESCRIPTION + Merges three sources into one member list, de-duplicated by principal id: + Active - PIM roleAssignmentScheduleInstances with assignmentType 'Assigned' + Eligible - PIM roleEligibilitySchedules (can activate, not currently active) + Direct - directoryRole membership assigned outside PIM + + A principal may be a user, a servicePrincipal, or a group — use '@odata.type' to tell + them apart. userPrincipalName is null for anything that isn't a user, and appId is + populated only for servicePrincipals. + + .PARAMETER TenantFilter + The tenant to resolve members for. + + .PARAMETER RoleTemplateId + The role's TEMPLATE id (e.g. Global Administrator = 62e90394-69f5-4237-9190-012177145e10). + + NOT the directoryRole instance id. PIM's roleDefinitionId carries template ids, so passing + an instance id (Roles.id) matches nothing and silently returns an empty list. When starting + from a Get-CippDbRole record, pass $Role.roleTemplateId — never $Role.id. + + .OUTPUTS + PSCustomObject per member: + id - principal object id + displayName - principal display name + userPrincipalName - users only; null for servicePrincipals and groups + appId - servicePrincipals only; null otherwise + '@odata.type' - '#microsoft.graph.user' | '...servicePrincipal' | '...group' + AssignmentType - 'Active' | 'Eligible' | 'Direct' + EndDateTime - when the assignment expires; null when it does not + IsPermanent - $true when the assignment has no expiry + + .NOTES + Depends on the cached records carrying an expanded 'principal' object — the Graph APIs + return only principalId by default, so the collectors request $expand=principal. Without + it every displayName/userPrincipalName here is null. + + .FUNCTIONALITY + Internal + #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] @@ -27,8 +70,12 @@ function Get-CippDbRoleMembers { id = $member.principalId displayName = $member.principal.displayName userPrincipalName = $member.principal.userPrincipalName + appId = $member.principal.appId '@odata.type' = $member.principal.'@odata.type' AssignmentType = 'Active' + EndDateTime = $member.endDateTime + # A PIM assignment with no endDateTime never expires. + IsPermanent = ($null -eq $member.endDateTime) } $AllMembers.Add($memberObj) } @@ -39,8 +86,12 @@ function Get-CippDbRoleMembers { id = $member.principalId displayName = $member.principal.displayName userPrincipalName = $member.principal.userPrincipalName + appId = $member.principal.appId '@odata.type' = $member.principal.'@odata.type' AssignmentType = 'Eligible' + # Eligibilities carry their expiry under scheduleInfo, not endDateTime. + EndDateTime = $member.scheduleInfo.expiration.endDateTime + IsPermanent = ($member.scheduleInfo.expiration.type -eq 'noExpiration') } $AllMembers.Add($memberObj) } @@ -52,8 +103,12 @@ function Get-CippDbRoleMembers { id = $member.id displayName = $member.displayName userPrincipalName = $member.userPrincipalName + appId = $member.appId '@odata.type' = $member.'@odata.type' AssignmentType = 'Direct' + # directoryRole membership assigned outside PIM has no expiry. + EndDateTime = $null + IsPermanent = $true } $AllMembers.Add($memberObj) } diff --git a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 index daa7b4a32e143..1d5112b92963f 100644 --- a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 +++ b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 @@ -70,7 +70,7 @@ function Get-CippSandboxData { $Key = if ($Type) { $Type } else { '' } if (-not $Data.ContainsKey($Key)) { - $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type) + $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type -NoProjection) } } diff --git a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 new file mode 100644 index 0000000000000..41905a55f7a71 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 @@ -0,0 +1,165 @@ +function Get-CippTestDataFieldManifest { + <# + .SYNOPSIS + Per-type field manifest for Get-CIPPTestData projection. + + .DESCRIPTION + Returns the union of top-level fields that every consumer of a given CippReportingDB type + reads, or $null for types that must be fetched whole. + + ADDING OR CHANGING A FIELD READ IN A TEST? ADD IT HERE FIRST. Get-CIPPTestData only + materializes what is listed here; reading an unlisted field returns $null with no error, + and the test emits a wrong compliance verdict silently. + + Rules for editing this table: + + * Top-level names only. Projection is record-level: a kept field keeps its ENTIRE subtree. + For `$policy.conditions.users.includeRoles` you add 'conditions' — never 'users'. + * When in doubt, add it. Over-inclusion costs a little memory; omission corrupts results. + * Matching is case-insensitive, and tests rely on it — some read a field using different + casing than the stored JSON. Use the real JSON casing here; do not "fix" the test. + * A type absent from this table is fetched whole — the safe default. A new type, or a + field nobody listed, degrades to pre-projection behaviour rather than corrupting a + verdict. Deliberately absent are types whose tests discover column names at runtime by + reflecting over the record, and types small enough that projecting buys nothing. + * Verify with a verdict diff across more than one tenant — see Get-CIPPTestData .NOTES. + "0 errored" proves nothing; the failure mode is silent. + + CONSUMERS THAT ARE NOT TEST FILES — easy to miss, since no test names these fields: + + Get-CippDbRole ......... Roles + Get-CippDbRoleMembers .. RoleAssignmentScheduleInstances, RoleEligibilitySchedules, + Roles — including the 'principal' subtree, which no test + mentions; omitting it blanks every role member across the + privileged-access tests + Test-E8AsrRule ......... IntuneConfigurationPolicies — the field contract for the E8 ASR + tests, which read nothing themselves + + Any new helper calling Get-CIPPTestData belongs on that list. + + WHY PER-TYPE, NOT PER-CALL-SITE: the field set is part of the Get-CIPPTestData cache key, + so per-caller lists fragment the cache — the same rows parsed once per distinct list, all + alive together for the TTL. Many call sites share few types, and they mostly want the same + large subtrees, so fragmenting measured several times worse than one shared entry. + + .PARAMETER Type + The CippReportingDB data type. + + .OUTPUTS + [string[]] of field names, or $null meaning "no projection — return every field". + + .EXAMPLE + Get-CippTestDataFieldManifest -Type 'Users' + Callers do not normally invoke this — Get-CIPPTestData consults it automatically by type. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $false, Position = 0)] + [string]$Type + ) + + if ([string]::IsNullOrWhiteSpace($Type)) { return $null } + + if (-not $script:CippTestDataFieldManifest) { + # Deliberately ABSENT (=> fetched whole), do not "helpfully" add them: + # CopilotUsageUserDetail, CopilotUserCountSummary, CopilotUserCountTrend — their tests + # discover column names at runtime by reflecting over the record, so no field list + # can be known ahead of time (CopilotReady015/016/017). + # ExoGlobalQuarantinePolicy — ORCA107 annotates the record via `Select-Object *`, and + # the type is tiny, so projecting buys nothing. + $script:CippTestDataFieldManifest = @{ + 'AdminConsentRequestPolicy' = @('isEnabled', 'reviewers', 'notifyReviewers', 'remindersEnabled', 'requestDurationInDays') + 'Apps' = @('id', 'appId', 'displayName', 'keyCredentials', 'passwordCredentials', 'signInAudience', 'servicePrincipalLockConfiguration', 'owners', 'web', 'spa', 'publicClient') + 'AppsAndServices' = @('id', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled') + 'AuthenticationFlowsPolicy' = @('selfServiceSignUp') + 'AuthenticationMethodsPolicy' = @('authenticationMethodConfigurations', 'policyMigrationState', 'reportSuspiciousActivitySettings', 'systemCredentialPreferences') + 'AuthenticationStrengths' = @('id', 'displayName', 'policyType', 'allowedCombinations') + 'AuthorizationPolicy' = @('defaultUserRolePermissions', 'guestUserRoleId', 'allowInvitesFrom', 'allowedToUseSSPR', 'allowedToSignUpEmailBasedSubscriptions', 'allowEmailVerifiedUsersToJoinOrganization', 'permissionGrantPolicyIdsAssignedToDefaultUserRole', 'allowUserConsentForRiskyApps', 'allowedToCreateTenants') + 'B2BManagementPolicy' = @('allowInvitesFrom', 'invitationsAllowedAndBlockedDomainsPolicy', 'definition') + 'CASMailbox' = @('Identity', 'DisplayName', 'SmtpClientAuthenticationDisabled') + 'ConditionalAccessPolicies' = @('id', 'displayName', 'state', 'conditions', 'grantControls', 'sessionControls', 'createdDateTime', 'modifiedDateTime') + 'CopilotReadinessActivity' = @('userPrincipalName', 'usesOutlookEmail', 'usesTeamsMeetings', 'usesTeamsChat', 'usesOfficeDocs', 'onQualifiedUpdateChannel', 'hasCopilotLicenseAssigned') + 'CrossTenantAccessPolicy' = @('id', 'b2bCollaborationOutbound', 'b2bDirectConnectOutbound', 'tenantRestrictions') + 'CsExternalAccessPolicy' = @('EnableFederationAccess', 'EnableTeamsConsumerAccess') + 'CsTeamsAppPermissionPolicy' = @('Identity', 'GlobalCatalogAppsType', 'DefaultCatalogAppsType') + 'CsTeamsClientConfiguration' = @('AllowDropbox', 'AllowBox', 'AllowGoogleDrive', 'AllowShareFile', 'AllowEgnyte', 'AllowEmailIntoChannel') + 'CsTeamsMeetingPolicy' = @('AllowAnonymousUsersToJoinMeeting', 'AllowAnonymousUsersToStartMeeting', 'AutoAdmittedUsers', 'AllowPSTNUsersToBypassLobby', 'MeetingChatEnabledType', 'DesignatedPresenterRoleMode', 'AllowExternalParticipantGiveRequestControl', 'AllowExternalNonTrustedMeetingChat', 'AllowCloudRecording') + 'CsTeamsMessagingPolicy' = @('UseB2BInvitesToAddExternalUsers', 'AllowSecurityEndUserReporting') + 'CsTenantFederationConfiguration' = @('AllowFederatedUsers', 'AllowedDomains', 'AllowTeamsConsumer') + 'DefaultAppManagementPolicy' = @('isEnabled', 'applicationRestrictions', 'servicePrincipalRestrictions') + 'DeviceRegistrationPolicy' = @('azureADJoin', 'userDeviceQuota', 'localAdminPassword', 'multiFactorAuthConfiguration') + 'DeviceSettings' = @('secureByDefault') + 'DirectoryRecommendations' = @('status', 'priority', 'displayName', 'impactType', 'lastModifiedDateTime', 'insights', 'recommendationType', 'applicationDisplayName', 'applicationId') + 'DlpCompliancePolicies' = @('Name', 'DisplayName', 'Mode', 'Enabled', 'TeamsLocation', 'TeamsLocationException', 'Workload', 'EnforcementPlanes') + 'Domains' = @('id', 'passwordValidityPeriodInDays', 'authenticationType') + 'ExoAcceptedDomains' = @('DomainName', 'DomainType', 'SendingFromDomainDisabled') + 'ExoAdminAuditLogConfig' = @('UnifiedAuditLogIngestionEnabled', 'AdminAuditLogEnabled') + 'ExoAntiPhishPolicies' = @('Name', 'Identity', 'Enabled', 'PhishThresholdLevel', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSpoofIntelligence', 'TargetedUserProtectionAction', 'TargetedDomainProtectionAction', 'MailboxIntelligenceProtectionAction', 'AuthenticationFailAction', 'EnableFirstContactSafetyTips', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips', 'EnableUnauthenticatedSender', 'ExcludedSenders', 'ExcludedDomains', 'RecipientDomainIs', 'HonorDmarcPolicy') + 'ExoAtpPolicyForO365' = @('EnableATPForSPOTeamsODB', 'EnableSafeDocs', 'AllowSafeDocsOpen') + 'ExoDkimSigningConfig' = @('Domain', 'Enabled', 'Selector1CNAME', 'Selector2CNAME') + 'ExoHostedContentFilterPolicy' = @('Name', 'Identity', 'AllowedSenders', 'AllowedSenderDomains', 'EnableSafeList', 'SpamAction', 'HighConfidenceSpamAction', 'BulkSpamAction', 'PhishSpamAction', 'HighConfidencePhishAction', 'RecipientDomainIs', 'BulkThreshold', 'MarkAsSpamBulkMail', 'QuarantineRetentionPeriod', 'InlineSafetyTipsEnabled', 'IPAllowList', 'PhishZapEnabled', 'SpamZapEnabled', + 'IncreaseScoreWithImageLinks', 'IncreaseScoreWithNumericIps', 'IncreaseScoreWithRedirectToOtherPort', 'IncreaseScoreWithBizOrInfoUrls', 'MarkAsSpamEmptyMessages', 'MarkAsSpamJavaScriptInHtml', 'MarkAsSpamFramesInHtml', 'MarkAsSpamObjectTagsInHtml', 'MarkAsSpamEmbedTagsInHtml', 'MarkAsSpamFormTagsInHtml', 'MarkAsSpamWebBugsInHtml', 'MarkAsSpamSensitiveWordList', 'MarkAsSpamFromAddressAuthFail', 'MarkAsSpamNdrBackscatter', 'MarkAsSpamSpfRecordHardFail') + 'ExoHostedOutboundSpamFilterPolicy' = @('Identity', 'IsDefault', 'RecipientLimitExternalPerHour', 'RecipientLimitInternalPerHour', 'RecipientLimitPerDay', 'ActionWhenThresholdReached', 'NotifyOutboundSpam', 'NotifyOutboundSpamRecipients', 'BccSuspiciousOutboundMail', 'BccSuspiciousOutboundAdditionalRecipients', 'AutoForwardingMode') + 'ExoInboundConnector' = @('Identity', 'Enabled', 'SenderDomains', 'EFSkipLastIP', 'EFSkipIPs', 'EFTestMode', 'EFUsers') + 'ExoMalwareFilterPolicies' = @('Name', 'Identity', 'IsDefault', 'EnableFileFilter', 'FileTypes', 'EnableInternalSenderAdminNotifications', 'InternalSenderAdminAddress', 'ZapEnabled', 'Action', 'RecipientDomainIs') + 'ExoOrganizationConfig' = @('CustomerLockBoxEnabled', 'BookingsEnabled', 'AuditDisabled', 'ExternalInOutlookEnabled', 'ExternalInOutlook', 'OAuth2ClientProfileEnabled', 'MailTipsAllTipsEnabled', 'MailTipsExternalRecipientsTipsEnabled', 'MailTipsGroupMetricsEnabled', 'MailTipsLargeAudienceThreshold', 'RejectDirectSend') + 'ExoPresetSecurityPolicy' = @('Identity', 'State', 'ImpersonationProtectionState', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips') + 'ExoProtectionAlert' = @('Name', 'Disabled') + 'ExoRemoteDomain' = @('Name', 'DomainName', 'AutoForwardEnabled') + 'ExoSafeAttachmentPolicies' = @('Name', 'Identity', 'Enable', 'Action', 'RecipientDomainIs') + 'ExoSafeLinksPolicies' = @('Name', 'Identity', 'EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', 'TrackUserClicks', 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite', 'RecipientDomainIs', 'IsBuiltInProtection') + 'ExoSharingPolicy' = @('Name', 'Enabled', 'Domains') + 'ExoTenantAllowBlockList' = @('Action', 'ListType', 'Value') + 'ExoTransportConfig' = @('SmtpClientAuthenticationDisabled') + 'ExoTransportRules' = @('Name', 'State', 'Priority', 'SetSCL', 'SetSpamConfidenceLevel', 'SetHeaderName', 'SetHeaderValue', 'SenderDomainIs') + 'FormsSettings' = @('isInOrgFormsPhishingScanEnabled') + 'Groups' = @('id', 'displayName', 'mail', 'visibility', 'groupTypes', 'members', 'isAssignableToRole') + 'Guests' = @('id', 'displayName', 'userPrincipalName', 'accountEnabled', 'signInActivity', 'createdDateTime', 'sponsors') + # URLName is added by the collector (Add-Member), not returned by Graph. It carries the + # Graph resource each policy came from (iosManagedAppProtection / + # androidManagedAppProtection / targetedManagedAppConfiguration) and is the only + # platform discriminator on these records — @odata.type is not preserved in the cache. + 'IntuneAppProtectionManagedAppPolicies' = @('URLName', 'displayName', 'assignments') + 'IntuneConfigurationPolicies' = @('name', 'platforms', 'technologies', 'templateReference', 'settings', 'assignments') + 'IntuneDeviceCompliancePolicies' = @('@odata.type', 'displayName', 'assignments', 'osMinimumVersion', 'bitLockerEnabled', 'storageRequireEncryption') + 'IntuneDeviceConfigurations' = @('@odata.type', 'displayName', 'assignments', 'qualityUpdatesDeferralPeriodInDays', 'fileVaultEnabled', 'wiFiSecurityType') + 'IntuneDeviceEnrollmentConfigurations' = @('@odata.type', 'displayName', 'priority', 'deviceEnrollmentConfigurationType', 'assignments', 'androidForWorkRestriction', 'androidRestriction', 'iosRestriction', 'macOSRestriction', 'windowsRestriction') + 'LicenseOverview' = @('License', 'TotalLicenses', 'CountUsed', 'ServicePlans', 'AssignedUsers', 'TermInfo') + 'Mailboxes' = @('UPN', 'UserPrincipalName', 'displayName', 'recipientTypeDetails', 'ExternalDirectoryObjectId', 'AuditEnabled', 'AuditOwner', 'AuditBypassEnabled', 'WhenSoftDeleted', 'LitigationHoldEnabled', 'LicensedForLitigationHold', 'ComplianceTagHoldApplied', 'RetentionPolicy', 'InPlaceHolds') + 'ManagedDevices' = @('deviceName', 'lastSyncDateTime', 'operatingSystem', 'osVersion') + 'MDEOnboarding' = @('partnerState') + 'MFAState' = @('UPN', 'userPrincipalName', 'DisplayName', 'AccountEnabled', 'UserType', 'IsAdmin', 'isLicensed', 'PerUser', 'PerUserMFAState', 'CoveredByCA', 'CoveredBySD', 'MFARegistration', 'MFACapable', 'MFAMethods') + 'NamedLocations' = @('@odata.type', 'displayName', 'isTrusted') + 'OfficeActivations' = @('userPrincipalName', 'userActivationCounts') + 'Organization' = @('onPremisesSyncEnabled', 'onPremisesLastSyncDateTime') + 'OwaMailboxPolicy' = @('Identity', 'IsDefault', 'PersonalAccountsEnabled', 'PersonalAccountCalendarsEnabled', 'AdditionalStorageProvidersAvailable') + 'ReportSubmissionPolicy' = @('ReportJunkToCustomizedAddress', 'ReportPhishToCustomizedAddress', 'ReportChatMessageEnabled', 'ReportChatMessageToCustomizedAddressEnabled') + 'RiskDetections' = @('riskState', 'riskLevel', 'riskEventType', 'riskDetail', 'userPrincipalName', 'userDisplayName', 'detectedDateTime', 'activityDateTime') + 'RiskyServicePrincipals' = @('id', 'appId', 'displayName', 'servicePrincipalType', 'riskState', 'riskLevel', 'riskLastUpdatedDateTime') + 'RiskyUsers' = @('id', 'userPrincipalName', 'riskState', 'riskLevel', 'riskDetail', 'riskLastUpdatedDateTime') + # 'principal' is NOT read by any test file — Get-CippDbRoleMembers reads + # $member.principal.displayName/.userPrincipalName. Omitting it would silently blank + # every role member across the CIS/E8/ZTNA privileged-access tests. + 'RoleAssignmentScheduleInstances' = @('roleDefinitionId', 'assignmentType', 'memberType', 'endDateTime', 'principalId', 'principal') + 'RoleEligibilitySchedules' = @('roleDefinitionId', 'principalId', 'principal', 'scheduleInfo') + # policyId, not id: this type is sourced from roleManagementPolicyAssignments (only the + # assignment carries roleDefinitionId) and the policy is flattened up one level. + 'RoleManagementPolicies' = @('policyId', 'scopeId', 'scopeType', 'roleDefinitionId', 'rules', 'effectiveRules') + 'Roles' = @('id', 'displayName', 'roleTemplateId', 'members') + 'SecureScore' = @('currentScore', 'maxScore', 'createdDateTime', 'controlScores') + 'SensitivityLabels' = @('name', 'PolicyName', 'IsValid', 'isActive', 'sensitivity', 'parent', 'hasProtection') + 'ServicePrincipalRiskDetections' = @('servicePrincipalId', 'servicePrincipalDisplayName', 'appId', 'activity', 'riskState', 'riskLevel', 'riskEventType', 'detectedDateTime', 'lastUpdatedDateTime') + 'ServicePrincipals' = @('id', 'appId', 'displayName', 'accountEnabled', 'keyCredentials', 'passwordCredentials', 'appOwnerOrganizationId', 'servicePrincipalType', 'replyUrls', 'owners', 'appRoleAssignmentRequired', 'preferredSingleSignOnMode') + 'Settings' = @('id', 'templateId', 'displayName', 'values', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled', 'isInOrgFormsPhishingScanEnabled') + 'SPOTenant' = @('LegacyAuthProtocolsEnabled', 'EnableAzureADB2BIntegration', 'SharingCapability', 'OneDriveSharingCapability', 'PreventExternalUsersFromResharing', 'SharingDomainRestrictionMode', 'SharingAllowedDomainList', 'SharingBlockedDomainList', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'ExternalUserExpirationRequired', 'ExternalUserExpireInDays', 'EmailAttestationRequired', 'EmailAttestationReAuthDays', 'DisallowInfectedFileDownload') + 'UserRegistrationDetails' = @('id', 'userPrincipalName', 'userDisplayName', 'isMfaCapable', 'isMfaRegistered', 'methodsRegistered') + 'Users' = @('id', 'userPrincipalName', 'displayName', 'accountEnabled', 'userType', 'onPremisesSyncEnabled', 'assignedLicenses', 'assignedPlans', 'signInActivity', 'passwordPolicies') + } + } + + return $script:CippTestDataFieldManifest[$Type] +} diff --git a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 index 0a3345e5cbacb..77b17558ce962 100644 --- a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 @@ -4,7 +4,11 @@ function New-CIPPDbRequest { Query the CIPP Reporting database by partition key .DESCRIPTION - Retrieves data from the CippReportingDB table filtered by partition key (tenant) + Retrieves data from the CippReportingDB table filtered by partition key (tenant). + + Rows are parsed by CIPP.CippJson (System.Text.Json), not ConvertFrom-Json — see .NOTES. + Most callers should use Get-CIPPTestData instead, which adds the shared cache and applies + the per-type field manifest automatically. Call this directly only to bypass both. .PARAMETER TenantFilter The tenant domain or GUID to filter by (used as partition key) @@ -12,11 +16,41 @@ function New-CIPPDbRequest { .PARAMETER Type Optional. The data type to filter by (e.g., Users, Groups, Devices) + .PARAMETER Fields + Optional. Keep only these top-level fields on each returned record; everything else is + skipped without being materialized. A retained field keeps its ENTIRE subtree — projection + never reaches inside a kept value, so `$p.conditions.users.includeRoles` only requires + 'conditions'. Omit to return every field. Matching is case-insensitive. + + This is the only memory lever, but its value depends entirely on which fields you keep: it + pays where records are large and the read-set is small, and buys almost nothing where the + retained subtrees are most of the payload. Measure before assuming a win. + + .NOTES + Backed by CIPP.CippJson (System.Text.Json). Output is PSCustomObject with + ConvertFrom-Json's [DateTime] coercion and Int64 number semantics preserved deliberately, + so this is a drop-in replacement for every existing caller. Matching those semantics costs + nothing measurable — do not reintroduce divergence to save a branch. + + Unparseable rows are skipped rather than thrown, matching the -ErrorAction SilentlyContinue + on the ConvertFrom-Json this replaced. A row whose Data is a JSON array returns object[], + which PowerShell unrolls into the output stream — the same shape the old pipeline produced. + + Without -Fields the parser alone is modestly faster and allocates less, but retains the + same live bytes — the parser was never the memory cost. Only -Fields reduces footprint. + + Benchmark on production's runtime (PowerShell 7.4 / .NET 8, Linux container). + System.Text.Json timings differ enough on newer runtimes to invert conclusions; + allocation numbers are runtime-insensitive. + .EXAMPLE New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' .EXAMPLE New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' + + .EXAMPLE + New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' -Fields 'id','displayName' #> [CmdletBinding()] param( @@ -24,7 +58,10 @@ function New-CIPPDbRequest { [string]$TenantFilter, [Parameter(Mandatory = $false)] - [string]$Type + [string]$Type, + + [Parameter(Mandatory = $false)] + [string[]]$Fields ) try { @@ -69,7 +106,20 @@ function New-CIPPDbRequest { $Results = Get-CIPPAzDataTableEntity @Table -Filter $Filter - return ($Results.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) + # CippJson replaces `$Results.Data | ConvertFrom-Json`. A row whose Data is a JSON array + # returns object[], which PowerShell unrolls into the output stream — the same shape the + # pipeline produced before. Bad rows are skipped rather than thrown, matching the + # -ErrorAction SilentlyContinue this replaced. + $Projection = if ($Fields) { [string[]]$Fields } else { $null } + $Output = foreach ($Row in $Results.Data) { + if ([string]::IsNullOrWhiteSpace($Row)) { continue } + try { + [CIPP.CippJson]::ConvertFromJson($Row, $Projection) + } catch { + Write-Information "Skipping unparseable CippReportingDB row for '$Tenant'/'$Type': $($_.Exception.Message)" + } + } + return $Output } catch { Write-LogMessage -API 'CIPPDbRequest' -tenant $TenantFilter -message "Failed to query database: $($_.Exception.Message)" -sev Error throw diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 18abcd1c7c659..ac9db1621c57e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -10,7 +10,7 @@ function Set-CIPPAssignedApplication { $ApplicationId, $TenantFilter, $GroupIds, - $AssignmentMode = 'replace', + $AssignmentMode = 'append', $AssignmentDirection, $APIName = 'Assign Application', $Headers, diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index faff217f016f1..568ee56c4afaa 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -15,7 +15,7 @@ function Set-CIPPAssignedPolicy { $AssignmentFilterType = 'include', $GroupIds, $GroupNames, - $AssignmentMode = 'replace', + $AssignmentMode = 'append', $AssignmentDirection ) diff --git a/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 new file mode 100644 index 0000000000000..52b7e0e29544f --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 @@ -0,0 +1,93 @@ +function Set-CIPPRegistrationCampaign { + <# + .SYNOPSIS + Updates the authentication methods registration campaign (nudge) for a tenant. + .DESCRIPTION + Single writer for the registration campaign, shared by the ExecRegistrationCampaign + endpoint and the NudgeMFA standard. Any parameter left as $null keeps the value + currently configured in the tenant, so callers can update settings independently. + .PARAMETER IncludeTargets + Array of @{ id; targetType } targets. $null keeps the current include targets. The + targeted authentication method is applied to every include target, and a campaign + always ends up with at least one include target (falls back to all_users). + .PARAMETER ExcludeTargets + Array of @{ id; targetType } targets. $null keeps the current exclusions, an empty + array clears them. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)]$Tenant, + $State, + $TargetedAuthenticationMethod, + $SnoozeDurationInDays, + $EnforceRegistrationAfterAllowedSnoozes, + $IncludeTargets, + $ExcludeTargets, + $APIName = 'Set Registration Campaign', + $Headers + ) + + try { + $CurrentPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant + $CurrentCampaign = $CurrentPolicy.registrationEnforcement.authenticationMethodsRegistrationCampaign + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" + } + + $DesiredState = $State ?? $CurrentCampaign.state + $DesiredMethod = $TargetedAuthenticationMethod ?? (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator' + $DesiredSnooze = if ($null -ne $SnoozeDurationInDays) { [int]$SnoozeDurationInDays } else { [int]($CurrentCampaign.snoozeDurationInDays ?? 1) } + $DesiredEnforce = if ($null -ne $EnforceRegistrationAfterAllowedSnoozes) { [bool]$EnforceRegistrationAfterAllowedSnoozes } else { [bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes } + + if ($DesiredState -notin @('default', 'enabled', 'disabled')) { + throw "State must be one of 'default', 'enabled' or 'disabled'" + } + if ($DesiredMethod -notin @('microsoftAuthenticator', 'fido2')) { + throw "TargetedAuthenticationMethod must be 'microsoftAuthenticator' or 'fido2'" + } + if ($DesiredSnooze -lt 0 -or $DesiredSnooze -gt 14) { + throw 'SnoozeDurationInDays must be between 0 and 14' + } + + $DesiredIncludeTargets = if ($null -ne $IncludeTargets) { + @($IncludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)"; targetedAuthenticationMethod = $DesiredMethod } }) + } else { + @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $DesiredMethod } }) + } + if ($DesiredIncludeTargets.Count -eq 0) { + $DesiredIncludeTargets = @(@{ id = 'all_users'; targetType = 'group'; targetedAuthenticationMethod = $DesiredMethod }) + } + + $DesiredExcludeTargets = if ($null -ne $ExcludeTargets) { + @($ExcludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)" } }) + } else { + @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } + + $Body = @{ + registrationEnforcement = @{ + authenticationMethodsRegistrationCampaign = @{ + state = $DesiredState + snoozeDurationInDays = $DesiredSnooze + enforceRegistrationAfterAllowedSnoozes = $DesiredEnforce + includeTargets = @($DesiredIncludeTargets) + excludeTargets = @($DesiredExcludeTargets) + } + } + } | ConvertTo-Json -Depth 10 -Compress + + try { + $Result = "Set the registration campaign state to $DesiredState targeting $DesiredMethod with a snooze duration of $DesiredSnooze day(s), $($DesiredIncludeTargets.Count) include target(s) and $($DesiredExcludeTargets.Count) exclude target(s)" + if ($PSCmdlet.ShouldProcess('Registration campaign', "Set state to $DesiredState")) { + $null = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -Type PATCH -Body $Body -ContentType 'application/json' -AsApp $false + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message $Result -sev Info + } + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 index 691801cb6706e..f3f317103eba0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 @@ -12,6 +12,15 @@ function Set-CIPPUser { $AddToGroups = $UserObj.AddToGroups $RemoveFromGroups = $UserObj.RemoveFromGroups + $UseExchangeForGroup = { + param($AddedFields) + $Calculated = $AddedFields.calculatedGroupType + if ($Calculated) { + return $Calculated -in @('distributionList', 'security') + } + return $AddedFields.groupType -in @('Distribution List', 'Mail-Enabled Security', 'distributionList') + } + #Edit the user try { @@ -189,13 +198,12 @@ function Set-CIPPUser { $AddToGroups | ForEach-Object { $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + if (& $UseExchangeForGroup $_.addedFields) { Write-Host 'Adding to group via Add-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true @@ -222,13 +230,12 @@ function Set-CIPPUser { $RemoveFromGroups | ForEach-Object { $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + if (& $UseExchangeForGroup $_.addedFields) { Write-Host 'Removing From group via Remove-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 index ca5aab39a4bf0..06387748b9dcb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 @@ -18,7 +18,17 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role assignment schedule instances' -sev Debug - New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter -Stream | + # -AsApp is required: the RoleManagement.*.Directory grant is an APPLICATION permission, + # so it only appears in an app-only token. The default delegated path (service account + + # GDAP) fails with "Attempted to perform an unauthorized operation" because PIM reads via + # delegated access additionally need the signed-in user to hold a directory role such as + # Privileged Role Administrator, which GDAP does not grant. + # $expand=principal is required by Get-CippDbRoleMembers, which reads + # $member.principal.displayName/.userPrincipalName/.'@odata.type'. The API returns only + # principalId (a GUID) by default, so without this those all resolve to $null and every + # role member surfaces with a blank name. + $Uri = 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=principal' + New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter -AsApp $true -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role assignment schedule instances successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 index e730bab436fc5..cdef0a857ee3e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 @@ -18,7 +18,12 @@ function Set-CIPPDBCacheRoleEligibilitySchedules { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role eligibility schedules' -sev Debug - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter -Stream | + # -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only + # lands in an app-only token. The default delegated path returns "unauthorized". + # $expand=principal — see the note in Set-CIPPDBCacheRoleAssignmentScheduleInstances; + # Get-CippDbRoleMembers reads principal.displayName off these records too. + $Uri = 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?$expand=principal' + New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role eligibility schedules successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 index 168e8aeedbf3b..3e68308d27a52 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 @@ -1,13 +1,36 @@ function Set-CIPPDBCacheRoleManagementPolicies { <# .SYNOPSIS - Caches role management policies for a tenant + Caches PIM role management policies for a tenant, keyed by the role they apply to .PARAMETER TenantFilter The tenant to cache role management policies for .PARAMETER QueueId The queue ID to update with total tasks (optional) + + .NOTES + Reads roleManagementPolicyAssignments, NOT roleManagementPolicies, because only the + assignment carries roleDefinitionId — the policy record does not identify the role it + applies to, and the role id is not derivable from it (its id embeds a policy GUID that + matches no roleTemplateId). Consumers need to find "the policy for role X", so the + assignment is the correct entity. + https://learn.microsoft.com/graph/api/policyroot-list-rolemanagementpolicyassignments + + $filter is REQUIRED by this API — it must be scoped to a scopeId and scopeType, and the + request errors without it. rules/effectiveRules are navigation properties on the policy, + so they need a nested $expand; they are absent from the default response and consumers + read both. + + The policy is flattened up one level so cached records expose roleDefinitionId alongside + rules/effectiveRules, which is the shape the tests consume. This costs -Stream (the + Select-Object has to materialize), but the result set is small (~144 records/tenant). + + -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only + lands in an app-only token. The default delegated path returns "unauthorized". + + A tenant that has never onboarded PIM returns "MissingProvider: The provider is missing" + regardless of query or permissions. That is tenant state, not a bug in this call. #> [CmdletBinding()] param( @@ -18,7 +41,12 @@ function Set-CIPPDBCacheRoleManagementPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role management policies' -sev Debug - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter -Stream | + + $Uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'&`$expand=policy(`$expand=rules,effectiveRules)" + New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true | + Select-Object -Property policyId, roleDefinitionId, scopeId, scopeType, + @{ Name = 'rules'; Expression = { $_.policy.rules } }, + @{ Name = 'effectiveRules'; Expression = { $_.policy.effectiveRules } } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role management policies successfully' -sev Debug diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 1f6e1fcb6edef..dac3e6bba060c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -31,7 +31,7 @@ function Invoke-ExecAssignApp { $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { - $AssignmentMode = 'replace' + $AssignmentMode = 'append' } else { $AssignmentMode = $AssignmentMode.ToLower() if ($AssignmentMode -notin @('replace', 'append')) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index 722a5f37cdc04..147f670e64fd5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -49,7 +49,7 @@ function Invoke-ExecAssignPolicy { # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { - $AssignmentMode = 'replace' + $AssignmentMode = 'append' } $AssignTo = if ($AssignTo -ne 'on') { $AssignTo } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 new file mode 100644 index 0000000000000..ce03c60af94d2 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 @@ -0,0 +1,80 @@ +function Invoke-ExecRegistrationCampaign { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Administration.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter + + if (-not $TenantFilter) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: tenantFilter is required' } + } + } + + if ($null -ne $Request.Body.snoozeDurationInDays -and ([int]$Request.Body.snoozeDurationInDays -lt 0 -or [int]$Request.Body.snoozeDurationInDays -gt 14)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: snoozeDurationInDays must be between 0 and 14' } + } + } + + try { + # Build include/exclude target lists; $null means "keep what is currently configured" + $IncludeSpecified = ($null -ne $Request.Body.includeAllUsers) -or ($null -ne $Request.Body.includeGroups) -or ($null -ne $Request.Body.includeUsers) + $IncludeTargets = if ($IncludeSpecified) { + if ([bool]$Request.Body.includeAllUsers) { + @(@{ id = 'all_users'; targetType = 'group' }) + } else { + $Targets = [System.Collections.Generic.List[hashtable]]::new() + foreach ($GroupId in @($Request.Body.includeGroups)) { + if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) } + } + foreach ($UserId in @($Request.Body.includeUsers)) { + if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) } + } + @($Targets) + } + } else { $null } + + $ExcludeSpecified = ($null -ne $Request.Body.excludeGroups) -or ($null -ne $Request.Body.excludeUsers) + $ExcludeTargets = if ($ExcludeSpecified) { + $Targets = [System.Collections.Generic.List[hashtable]]::new() + foreach ($GroupId in @($Request.Body.excludeGroups)) { + if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) } + } + foreach ($UserId in @($Request.Body.excludeUsers)) { + if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) } + } + @($Targets) + } else { $null } + + $CampaignParams = @{ + Tenant = $TenantFilter + State = $Request.Body.state.value ?? $Request.Body.state + TargetedAuthenticationMethod = $Request.Body.targetedAuthenticationMethod.value ?? $Request.Body.targetedAuthenticationMethod + SnoozeDurationInDays = $Request.Body.snoozeDurationInDays + EnforceRegistrationAfterAllowedSnoozes = $Request.Body.enforceRegistrationAfterAllowedSnoozes + IncludeTargets = $IncludeTargets + ExcludeTargets = $ExcludeTargets + APIName = $APIName + Headers = $Headers + } + $Result = Set-CIPPRegistrationCampaign @CampaignParams + } catch { + $Result = $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode ?? [HttpStatusCode]::OK + Body = @{ Results = $Result } + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 index 17921a7878a31..afac49b65a297 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardNudgeMFA { .SYNOPSIS (Label) Sets the state for the request to setup Authenticator .DESCRIPTION - (Helptext) Sets the state of the registration campaign for the tenant - (DocsDescription) Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in. + (Helptext) Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users. + (DocsDescription) Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name). .NOTES CAT Entra (AAD) Standards @@ -17,8 +17,12 @@ function Invoke-CIPPStandardNudgeMFA { EXECUTIVETEXT Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes. ADDEDCOMPONENT - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Registration campaign state","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"label":"Authentication method to nudge users to register (default is Microsoft Authenticator)","name":"standards.NudgeMFA.targetedAuthenticationMethod","options":[{"label":"Microsoft Authenticator","value":"microsoftAuthenticator"},{"label":"Passkey (FIDO2)","value":"fido2"}],"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} {"type":"number","name":"standards.NudgeMFA.snoozeDurationInDays","label":"Number of days to allow users to skip registering Authenticator (0-14, default is 1)","defaultValue":1,"validators":{"min":{"value":0,"message":"Minimum value is 0"},"max":{"value":14,"message":"Maximum value is 14"}}} + {"type":"switch","name":"standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes","label":"Limited number of snoozes (require registration after 3 snoozes)","defaultValue":true,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} + {"type":"textField","name":"standards.NudgeMFA.includeTargets","label":"Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)","required":false,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} + {"type":"textField","name":"standards.NudgeMFA.excludeTargets","label":"Exclude groups (comma separated group names, blank = keep current exclusions)","required":false,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} IMPACT Low Impact ADDEDDATE @@ -34,45 +38,138 @@ function Invoke-CIPPStandardNudgeMFA { param($Tenant, $Settings) + # NOTE: The ADDEDCOMPONENT conditions above use compareType 'valueEq' rather than the usual 'is'. + # The state field is an autoComplete which stores a {label, value} object, so 'is' (deep equality + # against the raw string) never matches; 'valueEq' compares against the object's .value property + # and is supported by CippFormCondition. Changing state to a 'select' field would allow 'is', but + # would break existing saved NudgeMFA templates that already store the object shape. + + # Resolves comma separated group name entries to registration campaign targets + function Resolve-NudgeMFATarget { + param($Entries, $TenantFilter) + $Resolved = [System.Collections.Generic.List[hashtable]]::new() + $Failed = $false + foreach ($Entry in $Entries) { + try { + if ($Entry -match '^(all_users|allusers|all users)$') { + $Resolved.Add(@{ id = 'all_users'; targetType = 'group' }) + } else { + $EscapedName = $Entry -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName')") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $TenantFilter) + if ($MatchedGroups.Count -gt 0) { + foreach ($Group in $MatchedGroups) { $Resolved.Add(@{ id = $Group.id; targetType = 'group' }) } + if ($MatchedGroups.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: Multiple groups matched '$Entry': $($MatchedGroups.displayName -join ', ')" -sev Info + } + } else { + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: No group found matching '$Entry'" -sev Warning + $Failed = $true + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: Failed to resolve target '$Entry'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Failed = $true + } + } + return [PSCustomObject]@{ Targets = $Resolved; Failed = $Failed } + } + # Get state value using null-coalescing operator $State = $Settings.state.value ?? $Settings.state try { $CurrentState = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant - $StateIsCorrect = ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.state -eq $State) -and - ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays -eq $Settings.snoozeDurationInDays) -and - ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.enforceRegistrationAfterAllowedSnoozes -eq $true) + $CurrentCampaign = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Failed to get Authenticator App Nudge state, check your permissions and try again' -sev Error -LogData (Get-CippException -Exception $_) return } + $SnoozeDuration = [int]($Settings.snoozeDurationInDays ?? 1) + $EnforceAfterSnoozes = if ($null -eq $Settings.enforceRegistrationAfterAllowedSnoozes) { $true } else { [bool]$Settings.enforceRegistrationAfterAllowedSnoozes } + # Fall back to the method already targeted in the tenant so existing templates keep their current campaign type + $TargetedMethod = $Settings.targetedAuthenticationMethod.value ?? $Settings.targetedAuthenticationMethod ?? (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator' + + # NOTE: Unlike the AuthenticationMethods standard (where a blank group field means "All Users"), + # blank include/exclude here means "keep the targets currently configured in the tenant". This is + # deliberate: NudgeMFA predates these fields and existing deployments would otherwise have their + # portal-configured targeting overwritten to all_users on the next run. Use the literal 'AllUsers' + # entry to target everyone explicitly. + $IncludeEntries = @(([string]($Settings.includeTargets ?? '')) -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $ExcludeEntries = @(([string]($Settings.excludeTargets ?? '')) -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + # $Remediation*Targets are passed to Set-CIPPRegistrationCampaign ($null = keep current targets); + # $Desired*Targets are the fully resolved lists used for the compliance comparison below. + if ($IncludeEntries.Count -gt 0) { + $IncludeResolution = Resolve-NudgeMFATarget -Entries $IncludeEntries -TenantFilter $Tenant + if ($IncludeResolution.Failed -or $IncludeResolution.Targets.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'NudgeMFA: Could not resolve all include groups, skipping to avoid removing intended targets.' -sev Error + return + } + $RemediationIncludeTargets = @($IncludeResolution.Targets) + $DesiredIncludeTargets = @($RemediationIncludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $TargetedMethod } }) + } else { + $RemediationIncludeTargets = $null + $DesiredIncludeTargets = @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $TargetedMethod } }) + if ($DesiredIncludeTargets.Count -eq 0) { + $DesiredIncludeTargets = @(@{ id = 'all_users'; targetType = 'group'; targetedAuthenticationMethod = $TargetedMethod }) + } + } + + $ManageExcludeTargets = $ExcludeEntries.Count -gt 0 + if ($ManageExcludeTargets) { + $ExcludeResolution = Resolve-NudgeMFATarget -Entries $ExcludeEntries -TenantFilter $Tenant + if ($ExcludeResolution.Failed) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'NudgeMFA: Could not resolve all exclude groups, skipping to avoid excluding the wrong groups.' -sev Error + return + } + $RemediationExcludeTargets = @($ExcludeResolution.Targets) + $DesiredExcludeTargets = @($RemediationExcludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } else { + $RemediationExcludeTargets = $null + $DesiredExcludeTargets = @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } + + $CurrentIncludeIds = @($CurrentCampaign.includeTargets.id) + $DesiredIncludeIds = @($DesiredIncludeTargets.id) + $IncludeIsCorrect = ($CurrentIncludeIds.Count -eq $DesiredIncludeIds.Count) -and + (-not (Compare-Object -ReferenceObject @($DesiredIncludeIds | Sort-Object) -DifferenceObject @($CurrentIncludeIds | Sort-Object) -ErrorAction SilentlyContinue)) + $MethodIsCorrect = @($CurrentCampaign.includeTargets | Where-Object { $_.targetedAuthenticationMethod -ne $TargetedMethod }).Count -eq 0 + + if ($ManageExcludeTargets) { + $CurrentExcludeIds = @($CurrentCampaign.excludeTargets.id) + $DesiredExcludeIds = @($DesiredExcludeTargets.id) + $ExcludeIsCorrect = ($CurrentExcludeIds.Count -eq $DesiredExcludeIds.Count) -and + (-not (Compare-Object -ReferenceObject @($DesiredExcludeIds | Sort-Object) -DifferenceObject @($CurrentExcludeIds | Sort-Object) -ErrorAction SilentlyContinue)) + } else { + $ExcludeIsCorrect = $true + } + + $StateIsCorrect = ($CurrentCampaign.state -eq $State) -and + ([int]$CurrentCampaign.snoozeDurationInDays -eq $SnoozeDuration) -and + ([bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes -eq $EnforceAfterSnoozes) -and + $IncludeIsCorrect -and $MethodIsCorrect -and $ExcludeIsCorrect + if ($Settings.remediate -eq $true) { $StateName = $State.Substring(0, 1).ToUpper() + $State.Substring(1) if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is already set to $State with a snooze duration of $($Settings.snoozeDurationInDays)." -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is already set to $State targeting $TargetedMethod with a snooze duration of $SnoozeDuration." -sev Info } else { try { - $GraphRequest = @{ - tenantid = $Tenant - uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' - AsApp = $false - Type = 'PATCH' - ContentType = 'application/json' - Body = @{ - registrationEnforcement = @{ - authenticationMethodsRegistrationCampaign = @{ - state = $State - snoozeDurationInDays = $Settings.snoozeDurationInDays - enforceRegistrationAfterAllowedSnoozes = $true - includeTargets = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.includeTargets - excludeTargets = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.excludeTargets - } - } - } | ConvertTo-Json -Depth 10 -Compress + $CampaignParams = @{ + Tenant = $Tenant + State = $State + TargetedAuthenticationMethod = $TargetedMethod + SnoozeDurationInDays = $SnoozeDuration + EnforceRegistrationAfterAllowedSnoozes = $EnforceAfterSnoozes + IncludeTargets = $RemediationIncludeTargets + ExcludeTargets = $RemediationExcludeTargets + APIName = 'Standards' } - New-GraphPostRequest @GraphRequest - Write-LogMessage -API 'Standards' -tenant $Tenant -message "$StateName Authenticator App Nudge with a snooze duration of $($Settings.snoozeDurationInDays)" -sev Info + $null = Set-CIPPRegistrationCampaign @CampaignParams + Write-LogMessage -API 'Standards' -tenant $Tenant -message "$StateName Authenticator App Nudge targeting $TargetedMethod with a snooze duration of $SnoozeDuration" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set Authenticator App Nudge to $State. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage @@ -82,21 +179,29 @@ function Invoke-CIPPStandardNudgeMFA { if ($Settings.alert -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is configured correctly: $($CurrentCampaign.state) targeting $TargetedMethod with a snooze duration of $($CurrentCampaign.snoozeDurationInDays)" -sev Info } else { - Write-StandardsAlert -message "Authenticator App Nudge is not enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -object ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign | Select-Object snoozeDurationInDays, state) -tenant $Tenant -standardName 'NudgeMFA' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is not enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -sev Info + Write-StandardsAlert -message "Authenticator App Nudge is not configured as expected: state $($CurrentCampaign.state), snooze duration $($CurrentCampaign.snoozeDurationInDays)" -object ($CurrentCampaign | Select-Object state, snoozeDurationInDays, enforceRegistrationAfterAllowedSnoozes, includeTargets, excludeTargets) -tenant $Tenant -standardName 'NudgeMFA' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is not configured as expected: state $($CurrentCampaign.state), snooze duration $($CurrentCampaign.snoozeDurationInDays)" -sev Info } } if ($Settings.report -eq $true) { $CurrentValue = @{ - snoozeDurationInDays = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays - state = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.state + state = $CurrentCampaign.state + snoozeDurationInDays = $CurrentCampaign.snoozeDurationInDays + enforceRegistrationAfterAllowedSnoozes = [bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes + targetedAuthenticationMethod = (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) + includeTargets = @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + excludeTargets = @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) } $ExpectedValue = @{ - snoozeDurationInDays = $Settings.snoozeDurationInDays - state = $State + state = $State + snoozeDurationInDays = $SnoozeDuration + enforceRegistrationAfterAllowedSnoozes = $EnforceAfterSnoozes + targetedAuthenticationMethod = $TargetedMethod + includeTargets = @($DesiredIncludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + excludeTargets = $DesiredExcludeTargets } Set-CIPPStandardsCompareField -FieldName 'standards.NudgeMFA' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'NudgeMFA' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 index aa9ede0c285b8..d38c25f56144a 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 @@ -59,7 +59,7 @@ function Invoke-CIPPStandardRetentionPolicyTag { } $CurrentAgeLimitForRetention = if ($CurrentState.AgeLimitForRetention) { - ([timespan]$CurrentState.AgeLimitForRetention).TotalDays + [int]([timespan]$CurrentState.AgeLimitForRetention).TotalDays } $StateIsCorrect = ($CurrentState.Name -eq $PolicyName) -and diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 index 8c6237bcc290c..ba687cf031ea5 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 @@ -8,7 +8,7 @@ function Invoke-CIPPStandardSmartLockout { (Label) Configure Entra ID Smart Lockout .DESCRIPTION (Helptext) **Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode. - (DocsDescription) Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced). + (DocsDescription) Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforce). .NOTES CAT Entra (AAD) Standards @@ -19,7 +19,7 @@ function Invoke-CIPPStandardSmartLockout { {"type":"number","name":"standards.SmartLockout.LockoutDurationInSeconds","label":"Lockout Duration (seconds)","default":60,"required":true} {"type":"number","name":"standards.SmartLockout.LockoutThreshold","label":"Lockout Threshold (failed attempts)","default":10,"required":true} {"type":"switch","name":"standards.SmartLockout.EnableBannedPasswordCheckOnPremises","label":"Enable On-Premises Password Protection"} - {"type":"radio","name":"standards.SmartLockout.BannedPasswordCheckOnPremisesMode","label":"On-Premises Mode","options":[{"label":"Audit","value":"Audit"},{"label":"Enforced","value":"Enforced"}]} + {"type":"radio","name":"standards.SmartLockout.BannedPasswordCheckOnPremisesMode","label":"On-Premises Mode","options":[{"label":"Audit","value":"Audit"},{"label":"Enforce","value":"Enforce"}]} IMPACT Medium Impact ADDEDDATE @@ -51,7 +51,13 @@ function Invoke-CIPPStandardSmartLockout { $DesiredLockoutDuration = [string]($Settings.LockoutDurationInSeconds.value ?? $Settings.LockoutDurationInSeconds ?? '60') $DesiredLockoutThreshold = [string]($Settings.LockoutThreshold.value ?? $Settings.LockoutThreshold ?? '10') $DesiredEnableOnPrem = [string]($Settings.EnableBannedPasswordCheckOnPremises.value ?? $Settings.EnableBannedPasswordCheckOnPremises ?? 'False') - $DesiredOnPremMode = $Settings.BannedPasswordCheckOnPremisesMode.value ?? $Settings.BannedPasswordCheckOnPremisesMode ?? 'Audit' + $DesiredOnPremMode = [string]($Settings.BannedPasswordCheckOnPremisesMode.value ?? $Settings.BannedPasswordCheckOnPremisesMode ?? 'Audit') + + # Graph only accepts 'Audit' or 'Enforce'. Templates saved before the option value was + # corrected hold 'Enforced', which never matches the tenant and reports as non-compliant. + if ($DesiredOnPremMode -eq 'Enforced') { + $DesiredOnPremMode = 'Enforce' + } # Normalize boolean switch to string if ($DesiredEnableOnPrem -eq $true -or $DesiredEnableOnPrem -eq 'true' -or $DesiredEnableOnPrem -eq 'True') { diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 index 4684f72618f06..6ba82dcf1b788 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 @@ -33,8 +33,11 @@ function Invoke-CippTestCIS_1_1_2 { } } + # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so + # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here — + # only the direct members above. foreach ($Assignment in @($RoleAssignmentScheduleInstances)) { - if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { + if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { [void]$GAUserIds.Add([string]$Assignment.principalId) } } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 index fe93d5b980b06..441f577a97d25 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 @@ -28,8 +28,11 @@ function Invoke-CippTestCIS_1_1_3 { } } + # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so + # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here — + # only the direct members above. foreach ($Assignment in @($RoleAssignmentScheduleInstances)) { - if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { + if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { [void]$GAMembers.Add([string]$Assignment.principalId) } } diff --git a/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 b/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 index b4968c25a793a..e8117ad8db535 100644 --- a/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 +++ b/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 @@ -33,7 +33,7 @@ function Invoke-CippTestCISAMSEXO141 { $null = $Result.Append("| Policy Name | Current Action | Expected |`n") $null = $Result.Append("| :---------- | :------------- | :------- |`n") foreach ($Policy in $FailedPolicies) { - $null = $Result.Append("| $($Policy.'Policy Name') | $($Policy.'Current Action') | $($Policy.Expected) |`n") + $null = $Result.Append("| $($Policy.Name) | $($Policy.HighConfidenceSpamAction) | Quarantine |`n") } $Status = 'Failed' } diff --git a/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 b/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 index d18f29b16a7c0..e95e5c5f9a378 100644 --- a/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 +++ b/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 @@ -2,20 +2,62 @@ function Invoke-CippTestORCA121 { <# .SYNOPSIS Supported filter policy action used + + .DESCRIPTION + ORCA121 (area: Zero Hour Autopurge). ZAP can only act on a message that was delivered to the + Junk Email folder or quarantined, so it is inert when a policy's spam or phish action is one + that leaves the message in place (e.g. AddXHeader, ModifySubject, NoAction). This checks that + SpamAction and PhishSpamAction on each anti-spam policy are actions ZAP supports. + + .FUNCTIONALITY + Internal #> param($Tenant) try { - $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoQuarantinePolicy' + $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedContentFilterPolicy' if (-not $Policies) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ORCA121' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Low' -Name 'Supported filter policy action used' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Quarantine' return } - $Status = 'Passed' - $Result = [System.Text.StringBuilder]::new("Quarantine policies are configured to support Zero Hour Auto Purge.`n`n") - $null = $Result.Append("**Total Policies:** $($Policies.Count)") + # Actions ZAP can act on, per ORCA121. Anything else leaves the message in the inbox, + # where ZAP has nothing to purge. + $SupportedActions = @('MoveToJmf', 'Redirect', 'Delete', 'Quarantine') + + $Failures = [System.Collections.Generic.List[object]]::new() + $PolicyCount = 0 + + foreach ($Policy in $Policies) { + $PolicyCount++ + # ORCA evaluates the two actions as separate config items, so a policy can fail on + # one and pass on the other; report them independently rather than per-policy. + foreach ($Setting in @('SpamAction', 'PhishSpamAction')) { + $Value = $Policy.$Setting + if ($Value -notin $SupportedActions) { + $Failures.Add([PSCustomObject]@{ + Policy = $Policy.Identity ?? $Policy.Name + Setting = $Setting + Value = if ($null -eq $Value -or $Value -eq '') { 'Not set' } else { $Value } + }) | Out-Null + } + } + } + + if ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = [System.Text.StringBuilder]::new("✅ **Pass**: All $PolicyCount anti-spam policy/policies use a filter action that Zero Hour Auto Purge supports.`n`n") + $null = $Result.Append("Supported actions: $($SupportedActions -join ', ').") + } else { + $Status = 'Failed' + $Result = [System.Text.StringBuilder]::new("❌ **Fail**: $($Failures.Count) setting(s) across $PolicyCount anti-spam policy/policies use an action that Zero Hour Auto Purge cannot act on:`n`n") + $null = $Result.Append("| Policy | Setting | Current Action | Supported |`n") + $null = $Result.Append("| :----- | :------ | :------------- | :-------- |`n") + foreach ($Failure in $Failures) { + $null = $Result.Append("| $($Failure.Policy) | $($Failure.Setting) | $($Failure.Value) | $($SupportedActions -join ', ') |`n") + } + } Add-CippTestResult -TenantFilter $Tenant -TestId 'ORCA121' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Low' -Name 'Supported filter policy action used' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Quarantine' diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 index 0809843b69697..8196f6053aadd 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 @@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24548 { #Tested - Device try { - $IosPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneIosAppProtectionPolicies' + # App protection policies for every platform live under one type; URLName carries the + # Graph resource they came from and is the platform discriminator. This previously read + # 'IntuneIosAppProtectionPolicies', a type no collector writes, so the test always skipped. + $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies') - if (-not $IosPolicies) { + # Only skip when the type itself is absent (no Intune licence, or collection has not run). + if ($AllPolicies.Count -eq 0) { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' return } + $IosPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'iosManagedAppProtection' }) + + # Data exists but no iOS policy at all — that is a genuine failure of this control, not a + # missing-data skip. + if ($IosPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No iOS/iPadOS app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no iOS policy." -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' + return + } + $AssignedPolicies = @($IosPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) $Passed = $AssignedPolicies.Count -gt 0 if ($Passed) { $ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one iOS app protection policy exists and is assigned.`n`n") } else { - $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No iOS app protection policy exists or none are assigned.`n`n") + $ResultMarkdown = [System.Text.StringBuilder]::new("❌ iOS app protection policies exist but none are assigned.`n`n") } $null = $ResultMarkdown.Append("## iOS App Protection Policies`n`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 index 2837b609f04d7..b2ef4a25e3367 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 @@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24549 { #Tested - Device try { - $AndroidPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAndroidAppProtectionPolicies' + # App protection policies for every platform live under one type; URLName carries the + # Graph resource they came from and is the platform discriminator. This previously read + # 'IntuneAndroidAppProtectionPolicies', a type no collector writes, so the test always skipped. + $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies') - if (-not $AndroidPolicies) { + # Only skip when the type itself is absent (no Intune licence, or collection has not run). + if ($AllPolicies.Count -eq 0) { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' return } + $AndroidPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'androidManagedAppProtection' }) + + # Data exists but no Android policy at all — that is a genuine failure of this control, not + # a missing-data skip. + if ($AndroidPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No Android app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no Android policy." -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' + return + } + $AssignedPolicies = @($AndroidPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) $Passed = $AssignedPolicies.Count -gt 0 if ($Passed) { $ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one Android app protection policy exists and is assigned.`n`n") } else { - $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No Android app protection policy exists or none are assigned.`n`n") + $ResultMarkdown = [System.Text.StringBuilder]::new("❌ Android app protection policies exist but none are assigned.`n`n") } $null = $ResultMarkdown.Append("## Android App Protection Policies`n`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 index 9f3f82f2e05ae..c43db9e83435a 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 @@ -24,11 +24,14 @@ function Invoke-CippTestZTNA21813 { $UserRoleMap = @{} foreach ($Role in $PrivilegedRoles) { + # 'roleTemplateId', not 'templateId' — the Roles cache has no templateId field at all + # (description, displayName, id, memberCount, members, roleTemplateId), so both filters + # below compared against $null and matched nothing. $ActiveAssignments = $RoleAssignmentScheduleInstances | Where-Object { - $_.roleDefinitionId -eq $Role.templateId -and $_.assignmentType -eq 'Assigned' + $_.roleDefinitionId -eq $Role.roleTemplateId -and $_.assignmentType -eq 'Assigned' } $EligibleAssignments = $RoleEligibilitySchedules | Where-Object { - $_.roleDefinitionId -eq $Role.templateId + $_.roleDefinitionId -eq $Role.roleTemplateId } $AllAssignments = @($ActiveAssignments) + @($EligibleAssignments) @@ -38,7 +41,9 @@ function Invoke-CippTestZTNA21813 { if (-not $User) { continue } $UserId = $User.id - $IsGARole = $Role.templateId -eq $GlobalAdminRoleId + # roleTemplateId — $Role.templateId does not exist, so this was always false and + # no user was ever classed as a Global Administrator. + $IsGARole = $Role.roleTemplateId -eq $GlobalAdminRoleId if ($IsGARole) { $AllGAUsers[$UserId] = $User diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 index 3705a2cb776e5..23ac636141a5a 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 @@ -55,7 +55,9 @@ function Invoke-CippTestZTNA21816 { } foreach ($Role in $PrivilegedRoles) { - if ($Role.templateId -eq $GlobalAdminRoleId) { continue } + # roleTemplateId — $Role.templateId does not exist, so this guard never fired and the + # Global Administrator role was processed here despite being handled separately below. + if ($Role.roleTemplateId -eq $GlobalAdminRoleId) { continue } $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.RoletemplateId diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 index 1ebe2fdddbc7c..1830d7443462c 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 @@ -68,8 +68,10 @@ function Invoke-CippTestZTNA21818 { $ExitLoop = $false foreach ($Role in $PrivilegedRoles) { + # roleDefinitionId carries the role's TEMPLATE id — matching it against $Role.id (the + # directoryRole instance id) never succeeded. $Policy = $RoleManagementPolicies | Where-Object { - $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.id + $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.roleTemplateId } | Select-Object -First 1 if (-not $Policy) { continue } diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 index af4ca97404ecc..30e4ff7adcfb9 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 @@ -17,11 +17,15 @@ function Invoke-CippTestZTNA21819 { return } - # Get role management policy for Global Admin + # Get role management policy for Global Admin. + # This previously matched on effectiveRules.target.targetObjects.id — a property that does + # not exist on this response (target is {caller, operations, level, inheritableSettings, + # enforcedSettings}), so the policy was never found. roleDefinitionId carries the role's + # TEMPLATE id, which is the correct join. $RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' $GlobalAdminPolicy = $RoleManagementPolicies | Where-Object { - $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.effectiveRules.target.targetObjects.id -contains $GlobalAdminRole.id - } + $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $GlobalAdminRole.roleTemplateId + } | Select-Object -First 1 $Passed = 'Failed' $IsDefaultRecipientsEnabled = 'N/A' diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 index e4afd33d43926..ecb621ade66ca 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 @@ -19,15 +19,15 @@ function Invoke-CippTestZTNA21820 { # Get all role management policies $RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' - # Build hashtable for quick policy lookup by role ID + # Build hashtable for quick policy lookup by role template ID. + # This previously keyed on effectiveRules.target.targetObjects.id — a property that does + # not exist on this response (target is {caller, operations, level, inheritableSettings, + # enforcedSettings}), so the table was always empty and no policy was ever found. + # roleDefinitionId carries the role's TEMPLATE id, which is the correct key. $PolicyByRoleId = @{} foreach ($Policy in $RoleManagementPolicies) { - if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole') { - foreach ($RoleId in $Policy.effectiveRules.target.targetObjects.id) { - if ($RoleId) { - $PolicyByRoleId[$RoleId] = $Policy - } - } + if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole' -and $Policy.roleDefinitionId) { + $PolicyByRoleId[$Policy.roleDefinitionId] = $Policy } } @@ -35,7 +35,8 @@ function Invoke-CippTestZTNA21820 { $Passed = 'Passed' foreach ($Role in $PrivilegedRoles) { - $Policy = $PolicyByRoleId[$Role.id] + # Template id, not the directoryRole instance id — see the note above. + $Policy = $PolicyByRoleId[$Role.roleTemplateId] if (-not $Policy) { $RolesWithIssues.Add(@{ diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 index 0fa5d363f3335..c5f53095d4fe4 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 @@ -17,9 +17,12 @@ function Invoke-CippTestZTNA21835 { return } - # Get permanent Global Administrator members + # Get permanent Global Administrator members. + # This previously filtered on AssignmentType -eq 'Permanent', a value Get-CippDbRoleMembers + # never emits (it returns Active/Eligible/Direct), so the set was always empty and the test + # silently reported no permanent GAs on every tenant. Permanence is IsPermanent. $PermanentGAMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId '62e90394-69f5-4237-9190-012177145e10' | Where-Object { - $_.AssignmentType -eq 'Permanent' -and $_.'@odata.type' -eq '#microsoft.graph.user' + $_.IsPermanent -and $_.'@odata.type' -eq '#microsoft.graph.user' } # Get Users data to check sync status @@ -28,7 +31,8 @@ function Invoke-CippTestZTNA21835 { $EmergencyAccountCandidates = [System.Collections.Generic.List[object]]::new() foreach ($Member in $PermanentGAMembers) { - $User = $Users | Where-Object { $_.id -eq $Member.principalId } + # Get-CippDbRoleMembers returns the principal object id as 'id', not 'principalId'. + $User = $Users | Where-Object { $_.id -eq $Member.id } # Only process cloud-only accounts if ($User -and $User.onPremisesSyncEnabled -ne $true) { @@ -165,7 +169,9 @@ function Invoke-CippTestZTNA21835 { $UserSummary = [System.Collections.Generic.List[object]]::new() foreach ($Member in $PermanentGAMembers) { - $User = $Users | Where-Object { $_.id -eq $Member.principalId } + # 'id', not 'principalId' — see the note above; this lookup silently matched + # nothing and every row was skipped by the -not $User guard below. + $User = $Users | Where-Object { $_.id -eq $Member.id } if (-not $User) { continue } $PortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($User.id)" diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 index f7c37115662a8..aef4e53a6ce40 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 @@ -20,16 +20,21 @@ function Invoke-CippTestZTNA21836 { $WorkloadIdentitiesWithPrivilegedRoles = [System.Collections.Generic.List[object]]::new() foreach ($Role in $PrivilegedRoles) { - $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.id + # Must be the role's TEMPLATE id: PIM's roleDefinitionId carries template ids, so + # passing $Role.id (the directoryRole instance id) matched nothing and this test found + # no workload identities on any tenant. + $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.roleTemplateId foreach ($Member in $RoleMembers) { if ($Member.'@odata.type' -eq '#microsoft.graph.servicePrincipal') { + # Get-CippDbRoleMembers returns id/displayName/appId — not principalId or + # principalDisplayName, which rendered blank here. $WorkloadIdentitiesWithPrivilegedRoles.Add([PSCustomObject]@{ - PrincipalId = $Member.principalId - PrincipalDisplayName = $Member.principalDisplayName + PrincipalId = $Member.id + PrincipalDisplayName = $Member.displayName AppId = $Member.appId RoleDisplayName = $Role.displayName - RoleDefinitionId = $Role.id + RoleDefinitionId = $Role.roleTemplateId AssignmentType = $Member.AssignmentType }) } @@ -48,7 +53,7 @@ function Invoke-CippTestZTNA21836 { $SortedAssignments = $WorkloadIdentitiesWithPrivilegedRoles | Sort-Object -Property PrincipalDisplayName foreach ($Assignment in $SortedAssignments) { - $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)/preferredSingleSignOnMode~/null/servicePrincipalType/Application/fromNav/" + $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)" $null = $ResultMarkdown.Append("| [$($Assignment.PrincipalDisplayName)]($SPLink) | $($Assignment.RoleDisplayName) | $($Assignment.AssignmentType) |`n") } $null = $ResultMarkdown.Append("`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 index 0d94a2fb06c43..0a7b4c3c6a755 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 @@ -25,7 +25,9 @@ function Invoke-CippTestZTNA21899 { foreach ($R in $NotifRules) { if (-not $R.notificationRecipients -or $R.notificationRecipients.Count -eq 0) { $MissingRecipients.Add([PSCustomObject]@{ - PolicyId = $Policy.id + # 'policyId' — this type is sourced from roleManagementPolicyAssignments + # and carries the policy's id as policyId, not id. + PolicyId = $Policy.policyId ScopeId = $Policy.scopeId ScopeType = $Policy.scopeType RuleId = $R.id diff --git a/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 new file mode 100644 index 0000000000000..9578039306621 --- /dev/null +++ b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 @@ -0,0 +1,63 @@ +function Resolve-NinjaOneCveScanGroup { + <# + .SYNOPSIS + Resolves the NinjaOne vulnerability scan group used for CVE sync, creating it if it does not exist. + .DESCRIPTION + Looks up the CVE sync scan group by name via the NinjaOne API. If the lookup fails, or if no matching + scan group is found and the subsequent create attempt also fails, logs the error and returns $null so + the caller can skip CVE sync for this tenant without failing the overall tenant sync. + .PARAMETER Configuration + The NinjaOne configuration object. Must expose CveSyncDeviceIdHeader / CveSyncCveIdHeader (both optional). + .PARAMETER TenantFilter + The tenant identifier used for logging context. + .PARAMETER ScanGroupName + The resolved scan group name to look up or create. + .PARAMETER NinjaBaseUrl + The base NinjaOne API URL (e.g. https://instance/api/v2). + .PARAMETER Token + The NinjaOne OAuth token object. Must expose access_token. + .OUTPUTS + The resolved (existing or newly-created) scan group object, or $null if it could not be resolved. + #> + [CmdletBinding()] + param ( + $Configuration, + [string]$TenantFilter, + [string]$ScanGroupName, + [string]$NinjaBaseUrl, + $Token + ) + + try { + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — could not look up scan group '$ScanGroupName': $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return $null + } + + # Where-Object returns an array when multiple scan groups share the same name; take the + # first match so callers always receive a single object as documented, not a collection. + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } | Select-Object -First 1 + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — scan group '$ScanGroupName' not found, attempting to create it" -sev 'Info' + + try { + $NewScanGroupBody = @{ + groupName = $ScanGroupName + deviceIdHeader = $Configuration.CveSyncDeviceIdHeader ?? 'deviceName' + cveIdHeader = $Configuration.CveSyncCveIdHeader ?? 'cveId' + } | ConvertTo-Json -Depth 5 + + $ResolvedScanGroup = Invoke-RestMethod -Method Post -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)"; 'Content-Type' = 'application/json' } -Body $NewScanGroupBody -TimeoutSec 30 -ErrorAction Stop + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — created scan group '$ScanGroupName'" -sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found and could not be created: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + $ResolvedScanGroup = $null + } + } + + return $ResolvedScanGroup +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index e415d4cf580c1..d3a99fcc51fa7 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2198,12 +2198,9 @@ function Invoke-NinjaOneTenantSync { $ScanGroupName = "$ScanGroupPrefix$TenantFilter" $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" - $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop - $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + $ResolvedScanGroup = Resolve-NinjaOneCveScanGroup -Configuration $Configuration -TenantFilter $TenantFilter -ScanGroupName $ScanGroupName -NinjaBaseUrl $NinjaBaseUrl -Token $Token - if (-not $ResolvedScanGroup) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' - } else { + if ($ResolvedScanGroup) { $ResolvedScanGroupId = $ResolvedScanGroup.id $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader $CveIdHeader = $ResolvedScanGroup.cveIdHeader diff --git a/Shared/CIPPSharp/CIPPSharp.csproj b/Shared/CIPPSharp/CIPPSharp.csproj index 9bd6e19ccacde..6c7a3f192bdb0 100644 --- a/Shared/CIPPSharp/CIPPSharp.csproj +++ b/Shared/CIPPSharp/CIPPSharp.csproj @@ -14,4 +14,7 @@ bin\ false + + + diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs index da2538ec5ba2d..2b15d21c07da8 100644 --- a/Shared/CIPPSharp/CIPPTestDataCache.cs +++ b/Shared/CIPPSharp/CIPPTestDataCache.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Reflection; +using System.Management.Automation; using System.Threading; using System.Threading.Tasks; @@ -266,13 +266,6 @@ private static void TryFireBackgroundSweep() public static double HitRate => (_hits + _misses) > 0 ? Math.Round(_hits * 100.0 / (_hits + _misses), 1) : 0; - // ── PSObject reflection (cached, resolved once at first use) ── - private static readonly object s_reflectLock = new(); - private static bool s_psResolved; - private static Type? s_psObjectType; - private static PropertyInfo? s_psPropsProp; // PSObject.Properties - private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name - private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value // ── Managed-heap size model (x64 CoreCLR) ── // The cache stores *parsed PSObject graphs*, whose live heap footprint is // many times their JSON text size (measured ≈8× on real Graph data). Sizing @@ -293,40 +286,12 @@ private static void TryFireBackgroundSweep() // Bound the walk on pathological payloads: full-walk small collections for // accuracy, but stride-sample very large ones so a 100k-item array can't - // turn a single Set() into a multi-second reflection storm. + // turn a single Set() into a multi-second graph walk. private const int LargeCollectionThreshold = 512; private const int LargeCollectionSamples = 256; private const int MaxDepth = 32; private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set() - private static void EnsurePSResolved() - { - if (s_psResolved) return; - lock (s_reflectLock) - { - if (s_psResolved) return; - try - { - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - var t = asm.GetType("System.Management.Automation.PSObject"); - if (t == null) continue; - s_psObjectType = t; - s_psPropsProp = t.GetProperty("Properties"); - var piType = asm.GetType("System.Management.Automation.PSPropertyInfo"); - if (piType != null) - { - s_psPropName = piType.GetProperty("Name"); - s_psPropValue = piType.GetProperty("Value"); - } - break; - } - } - catch { /* SMA not loaded */ } - s_psResolved = true; - } - } - private static long Align8(long bytes) => (bytes + 7) & ~7L; /// @@ -340,7 +305,6 @@ private static void EnsurePSResolved() private static long EstimateValueSize(object? value, int itemCount) { if (value == null) return 0; - EnsurePSResolved(); try { long budget = NodeBudget; @@ -375,8 +339,8 @@ private static long SizeOf(object? value, int depth, ref long budget) } // PSObject → wrapper cost + each NoteProperty (name string + value + overhead) - if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value)) - return SizeOfPSObject(value, depth, ref budget); + if (value is PSObject pso) + return SizeOfPSObject(pso, depth, ref budget); // Dictionary / Hashtable → base + per-entry overhead + keys + values if (value is IDictionary dict) @@ -428,22 +392,20 @@ private static long SizeOf(object? value, int depth, ref long budget) return ObjectHeader + 32; } - private static long SizeOfPSObject(object psObj, int depth, ref long budget) + private static long SizeOfPSObject(PSObject psObj, int depth, ref long budget) { long total = PSObjectBase; - if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null) - return total; - if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total; - foreach (var prop in props) + foreach (var prop in psObj.Properties) { if (--budget <= 0) break; total += PSNotePropertyOverhead; try { - if (s_psPropName.GetValue(prop) is string name) - total += Align8(StringBaseOverhead + 2L * name.Length); - total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget); + var name = prop.Name; + if (name != null) total += Align8(StringBaseOverhead + 2L * name.Length); + // .Value can throw on script/code properties that evaluate on access. + total += SizeOf(prop.Value, depth + 1, ref budget); } catch { /* skip properties that throw on access */ } } diff --git a/Shared/CIPPSharp/CippJsonConverter.cs b/Shared/CIPPSharp/CippJsonConverter.cs new file mode 100644 index 0000000000000..a94da1da4ab9e --- /dev/null +++ b/Shared/CIPPSharp/CippJsonConverter.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Text.Json; + +namespace CIPP +{ + /// + /// Fast JSON -> PowerShell converter for the CippReportingDB read path, replacing + /// `ConvertFrom-Json` in New-CIPPDbRequest. + /// + /// COMPATIBILITY: reproduces ConvertFrom-Json's observable semantics, because every caller + /// depends on them and any divergence fails *silently*: + /// - PSCustomObject records, so `$x[0]`, `$x.Count` and `$x.PSObject.Properties` all keep + /// the scalar semantics callers rely on. Hashtable output is cheaper but changes those, + /// and only when a pipeline yields exactly one record — do not switch. + /// - ISO-8601-looking strings become [DateTime] (tests compare these against [datetime]). + /// - Every integer becomes Int64 regardless of magnitude (never Int32). + /// Matching these costs nothing measurable, so there is no reason to ship divergence. + /// + /// PERFORMANCE: without a field list this is modestly faster and allocates less than + /// ConvertFrom-Json, but retains the SAME live bytes — the parser was never the memory cost. + /// The memory win comes from : not materializing unread fields. + /// + public static class CippJson + { + private static readonly JsonDocumentOptions Opts = new JsonDocumentOptions { MaxDepth = 1024 }; + + /// Convert a JSON document, materializing every field. + public static object? ConvertFromJson(string json) => ConvertFromJson(json, null); + + /// + /// Convert a JSON document, keeping only on each RECORD. + /// + /// Projection applies at the record level only: for an object root, the root's own fields; + /// for an array root, each element's own fields. A field that is kept keeps its ENTIRE + /// subtree — projection never reaches inside a retained value. Null/empty keeps everything. + /// + /// The saving therefore scales with how much of the record is dropped: large for + /// scalar-only field sets, small when a kept subtree is most of the payload. + /// + public static object? ConvertFromJson(string json, string[]? fields) + { + if (string.IsNullOrEmpty(json)) return null; + + HashSet? keep = (fields != null && fields.Length > 0) + ? new HashSet(fields, StringComparer.OrdinalIgnoreCase) + : null; + + using var doc = JsonDocument.Parse(json, Opts); + var root = doc.RootElement; + + // Records live at the root, or one level down if the root is an array. + if (root.ValueKind == JsonValueKind.Array) + { + var rows = new List(); + foreach (var item in root.EnumerateArray()) rows.Add(ReadRecord(item, keep)); + return rows.ToArray(); + } + + return ReadRecord(root, keep); + } + + /// A record: the one level at which projection applies. + private static object? ReadRecord(JsonElement el, HashSet? keep) + { + if (el.ValueKind != JsonValueKind.Object) return ReadValue(el); + + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + { + // Skipped fields are never materialized — this is the whole point of projection. + if (keep != null && !keep.Contains(p.Name)) continue; + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); // kept => whole subtree + } + return pso; + } + + /// Everything below the record level: materialized in full. + private static object? ReadValue(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); + return pso; + + case JsonValueKind.Array: + var list = new List(); + foreach (var item in el.EnumerateArray()) list.Add(ReadValue(item)); + return list.ToArray(); + + case JsonValueKind.String: + // ConvertFrom-Json coerces ISO-8601 strings to DateTime; matching that keeps + // date comparisons in tests behaving as they do today. + return el.TryGetDateTime(out var dt) ? dt : (object?)el.GetString(); + + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.Null: return null; + + case JsonValueKind.Number: + // ConvertFrom-Json yields Int64 for all integers — never narrow to Int32. + if (el.TryGetInt64(out long l)) return l; + return el.GetDouble(); + + default: return null; + } + } + } +} diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index c242eca31f457..b2cd18ede86a7 100644 Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ diff --git a/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 new file mode 100644 index 0000000000000..306807a4565ca --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 @@ -0,0 +1,66 @@ +# Pester tests for Invoke-ExecAssignApp assignment mode defaults. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAssignApp.ps1' -File | + Select-Object -First 1 -ExpandProperty FullName + + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Set-CIPPAssignedApplication { param($ApplicationId, $TenantFilter, $Intent, $APIName, $Headers, $GroupName, $AssignmentMode) } + + . $FunctionPath +} + +Describe 'Invoke-ExecAssignApp assignment mode' { + BeforeEach { + $script:assignmentMode = $null + Mock -CommandName Set-CIPPAssignedApplication -MockWith { + $script:assignmentMode = $AssignmentMode + } + } + + It 'defaults omitted assignment mode to append' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignApp' } + Headers = @{} + Query = [pscustomobject]@{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'app-1' + AppType = 'Win32Lob' + AssignTo = 'AllDevices' + } + } + + $response = Invoke-ExecAssignApp -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:assignmentMode | Should -Be 'append' + } + + It 'preserves explicit replace mode' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignApp' } + Headers = @{} + Query = [pscustomobject]@{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'app-1' + AppType = 'Win32Lob' + AssignTo = 'AllDevices' + assignmentMode = 'replace' + } + } + + $null = Invoke-ExecAssignApp -Request $request -TriggerMetadata $null + + $script:assignmentMode | Should -Be 'replace' + } +} diff --git a/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 new file mode 100644 index 0000000000000..e600ac0dd7825 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 @@ -0,0 +1,64 @@ +# Pester tests for Invoke-ExecAssignPolicy assignment mode defaults. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAssignPolicy.ps1' -File | + Select-Object -First 1 -ExpandProperty FullName + + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Set-CIPPAssignedPolicy { param($PolicyId, $TenantFilter, $GroupName, $Type, $Headers, $AssignmentMode) } + + . $FunctionPath +} + +Describe 'Invoke-ExecAssignPolicy assignment mode' { + BeforeEach { + $script:assignmentMode = $null + Mock -CommandName Set-CIPPAssignedPolicy -MockWith { + $script:assignmentMode = $AssignmentMode + } + } + + It 'defaults omitted assignment mode to append' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignPolicy' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'policy-1' + Type = 'configurationPolicies' + AssignTo = 'AllDevices' + } + } + + $response = Invoke-ExecAssignPolicy -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:assignmentMode | Should -Be 'append' + } + + It 'preserves explicit replace mode' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignPolicy' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'policy-1' + Type = 'configurationPolicies' + AssignTo = 'AllDevices' + assignmentMode = 'replace' + } + } + + $null = Invoke-ExecAssignPolicy -Request $request -TriggerMetadata $null + + $script:assignmentMode | Should -Be 'replace' + } +} diff --git a/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 new file mode 100644 index 0000000000000..b2197f467cdd9 --- /dev/null +++ b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 @@ -0,0 +1,480 @@ +# Pester tests for Invoke-NinjaOneTenantSync +# +# Scope (per "solid coverage" agreement): happy-path end-to-end plus key +# error/edge branches per major section. Deep internals of the optional +# UserDocuments/LicenseDocuments related-items linking flows are exercised +# only at the "does it run without throwing / batch call happens" level — +# not every one of the 17 Invoke-WebRequest call sites is asserted +# individually, since that would require disproportionate fixture effort +# relative to this file's actual bug surface (CVE scan-group auto-create, +# GH issue #6349). +# +# Strategy: Get-CIPPTable is stubbed (not Mocked) to tag its returned +# Context with the requested table name, so downstream +# Get-CIPPAzDataTableEntity / Add-CIPPAzDataTableEntity mocks can +# distinguish table + filter combinations via -ParameterFilter. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1' + + # Minimal stubs so Mock has commands to replace during tests. + function Get-CIPPTable { param($tablename) @{ Context = $tablename } } + function Get-CippTable { param($tablename) @{ Context = $tablename } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Remove-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($tenant, $API, $message, $Sev, $LogData) } + function Get-NinjaOneToken { param($configuration) } + function Invoke-WebRequest { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Get-CippExtensionReportingData { param($TenantFilter, [switch]$IncludeMailboxes) } + function Get-NinjaOneLinks { param($Data, $Title, $SmallCols, $MedCols, $LargeCols, $XLCols) } + function Get-NinjaOneInfoCard { param($Title, $Data, $Icon) } + function Get-NinjaOneCard { param($Title, $Body, $Icon, $TitleLink) } + function Get-NinjaOneWidgetCard { param($Title, $Data, $Icon) } + function Get-NinjaInLineBarGraph { param($Title, $Data, [switch]$KeyInLine) } + function Get-SharePointAdminLink { param($TenantFilter) } + function Get-CIPPStandards { param($TenantFilter) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + function Get-NormalizedError { param($Message) $Message } + function New-CIPPGraphSubscription { param($TenantFilter, $Type) } + function New-VulnCsvBytes { param($Rows, $Headers) } + function Invoke-NinjaOneDocumentTemplate { param($TenantFilter, $TemplateName) } + function Invoke-NinjaOneVulnCsvUpload { param($Uri, $PollUri, $CsvBytes, $Headers) } + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Resolve-NinjaOneCveScanGroup { param($Configuration, $TenantFilter, $ScanGroupName, $NinjaBaseUrl, $Token) } + function convert-skuname { param($skuname) $skuname } + + . $FunctionPath + + function New-DefaultConfiguration { + [pscustomobject]@{ + Instance = 'app.ninjarmm.com' + UserDocumentsEnabled = $false + LicenseDocumentsEnabled = $false + LicensedOnly = $false + CveSyncEnabled = $true + CveSyncPrefix = 'CIPP-' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + } + + function New-DefaultQueueItem { + [pscustomobject]@{ + MappedTenant = [pscustomobject]@{ + RowKey = 'contoso-tenant-id' + IntegrationId = '918273' + } + } + } +} + +Describe 'Invoke-NinjaOneTenantSync' { + + BeforeEach { + # --- Common baseline mocks used by (almost) every test --- + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-NinjaOneToken -MockWith { [pscustomobject]@{ access_token = 'fake-token' } } + + Mock -CommandName Get-Tenants -MockWith { + @([pscustomobject]@{ + customerId = 'contoso-tenant-id' + defaultDomainName = 'contoso.onmicrosoft.com' + displayName = 'Contoso Ltd' + initialDomainName = 'contoso.onmicrosoft.com' + }) + } + + # CippMapping table: two different partitions are read via the same + # table/context — the sync-lock lookup (NinjaOneMapping) and the + # field-mapping lookup (NinjaOneFieldMapping). Default: no active + # lock, no field mappings (keeps the HTML/summary-card machinery + # entirely skipped for the core happy path). + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $null + lastEndTime = $null + }) + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneFieldMapping'" + } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Config' } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + [pscustomobject]@{ config = (@{ NinjaOne = (New-DefaultConfiguration) } | ConvertTo-Json -Depth 10) } + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedDevices' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneDeviceMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedUsers' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneUsersUpdate' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneUserMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { @() } + + # $CurrentItem is a single mutable object that the function under test + # mutates in place (via Add-Member -Force) and re-passes to + # Add-CIPPAzDataTableEntity multiple times (Running, then + # Completed/Failed). Pester's Should -Invoke -ParameterFilter + # re-evaluates filters against the *current* state of each recorded + # call's arguments, not a snapshot taken at call time - so once the + # shared object is mutated to 'Completed', every historical call + # appears to match. Capture an immutable clone of $Entity on every + # call so assertions can check actual per-call state. + $script:RecordedEntities = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + if ($null -ne $Entity) { + $script:RecordedEntities.Add(($Entity | ConvertTo-Json -Depth 10 | ConvertFrom-Json)) + } + } + Mock -CommandName Remove-AzDataTableEntity -MockWith { } + + # Empty extension-cache data by default — this keeps the device/user + # processing loops, tenant-summary cards, and doc-toggle blocks from + # ever executing their bodies (empty collections => no iterations). + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + + # Device fetch — one page, below PageSize, so the pagination loop + # terminates after a single call. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json -Depth 10) } + } + + # Final org-level custom-fields PATCH — succeeds by default. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + [pscustomobject]@{ content = (@{ success = $true } | ConvertTo-Json) } + } + + # CVE sync — scan group resolves, no vulnerability data by default. + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { + [pscustomobject]@{ id = 'scan-group-1'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { @() } + } + + Context 'Happy path (docs disabled, CVE sync enabled)' { + It 'completes successfully and records lastStatus Completed' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Running' }).Count | Should -Be 1 + } + + It 'fetches devices and posts the final org custom-fields update' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 1 -Exactly + Should -Invoke Invoke-WebRequest -ParameterFilter { $Method -eq 'PATCH' -and $Uri -like '*/organization/*/custom-fields' } -Times 1 -Exactly + } + + It 'does not call UserDocuments or LicenseDocuments endpoints when both toggles are disabled' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' } -Times 0 -Exactly + } + } + + Context 'Concurrency guard' { + It 'throws and does not process when a sync is already running for the tenant' { + # NOTE: the source parses `lastStartTime` with `Get-Date($string)`, + # which returns a Kind=Local DateTime, then compares it directly + # against `(Get-Date).ToUniversalTime()` (Kind=Utc). .NET's + # comparison operators ignore Kind and compare raw ticks, so this + # comparison is off by the host's UTC offset (tracked upstream as + # KelvinTegelaar/CIPP#6351 - not fixed here, out of scope for #6349). + # To keep this test deterministic across hosts in any timezone, + # build the mock timestamp the same way the guard will actually + # read it back: pick a UTC instant that, once shifted by the + # local offset (as the buggy parse does), reads as "now" - i.e. + # comfortably within the "still running" window on this host. + $LocalOffset = [System.TimeZoneInfo]::Local.GetUtcOffset((Get-Date)) + $MockStartTime = ((Get-Date).ToUniversalTime() - $LocalOffset).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $MockStartTime + lastEndTime = $null + }) + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + # The function's try/catch always returns $true — failure is + # observable only via the logged 'Failed' status and the fact + # that no device fetch happened. + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*still running*' } -Times 1 -Exactly + } + } + + Context 'Tenant match validation' { + It 'fails when the tenant cannot be uniquely matched' { + Mock -CommandName Get-Tenants -MockWith { @() } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + } + } + + Context 'Hostname validation' { + It 'fails when the configured NinjaOne instance is not an allow-listed hostname' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $BadConfig = New-DefaultConfiguration + $BadConfig.Instance = 'evil.example.com' + [pscustomobject]@{ config = (@{ NinjaOne = $BadConfig } | ConvertTo-Json -Depth 10) } + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*NinjaOne URL is invalid*' } -Times 1 -Exactly + } + } + + Context 'CVE sync' { + It 'uploads CVE rows built from vulnerability data when vulnerabilities exist' { + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1, 2, 3) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { + [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 1 } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 1 -Exactly + Should -Invoke Invoke-NinjaOneVulnCsvUpload -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Info' -and $message -like '*CVE sync complete*' } -Times 1 -Exactly + } + + It 'logs a warning and uploads a placeholder row when no vulnerability data is returned' { + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*no vulnerability data returned*' } -Times 1 -Exactly + } + + It 'excludes CVEs covered by a tenant or ALL exception before uploading' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { + @([pscustomobject]@{ RowKey = 'ALL'; cveId = 'CVE-2024-0001' }) + } + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $message -like '*filtered 1 excepted CVEs, 0 remaining*' } -Times 1 -Exactly + } + + It 'isolates a CVE sync failure so the overall tenant sync still completes' { + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { throw 'NinjaOne API unavailable' } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*CVE sync failed*' } -Times 1 -Exactly + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not attempt CVE sync when CveSyncEnabled is disabled' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $NoConfig = New-DefaultConfiguration + $NoConfig.CveSyncEnabled = $false + [pscustomobject]@{ config = (@{ NinjaOne = $NoConfig } | ConvertTo-Json -Depth 10) } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'Final org custom-fields PATCH failure' { + It 'propagates the failure to the outer catch and records lastStatus Failed' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + throw 'NinjaOne API returned 500' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Failed' }).Count | Should -Be 1 + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + # CVE sync runs after the final PATCH in the source, so a PATCH + # failure should prevent CVE sync from ever being attempted. + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'UserDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.UserDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'user1@contoso.onmicrosoft.com'; displayName = 'User One'; accountEnabled = $true; assignedLicenses = @() }) + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4001' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating user documents for new users' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the user-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } + + Context 'LicenseDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.LicenseDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @([pscustomobject]@{ skuId = 'sku-1'; skuPartNumber = 'ENTERPRISEPACK'; prepaidUnits = @{ enabled = 10 }; consumedUnits = 5 }) + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4002' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating license documents, using convert-skuname for friendly names' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the license-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } +} diff --git a/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 new file mode 100644 index 0000000000000..26c58261d2b52 --- /dev/null +++ b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 @@ -0,0 +1,140 @@ +# Pester tests for Resolve-NinjaOneCveScanGroup +# Verifies the CVE sync scan-group lookup/auto-create behavior (GH issue #6349): +# - reuses an existing scan group when the name matches +# - creates a new scan group via the NinjaOne API when none exists +# - logs and returns $null (without throwing) when creation also fails, so the +# overall tenant sync is not interrupted by CVE sync failures + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Resolve-NinjaOneCveScanGroup' { + BeforeEach { + $script:Configuration = [pscustomobject]@{ + Instance = 'contoso.rmmservice.com' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + $script:Token = [pscustomobject]@{ access_token = 'fake-token-value' } + $script:TenantFilter = 'contoso.onmicrosoft.com' + $script:ScanGroupName = 'CIPP-contoso.onmicrosoft.com' + $script:NinjaBaseUrl = 'https://contoso.rmmservice.com/api/v2' + + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns the existing scan group without attempting to create one' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'existing-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'existing-id' + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + } + + It 'creates a new scan group when no matching group exists' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'other-id'; groupName = 'SomeOtherGroup'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'new-id' + $Result.groupName | Should -Be $script:ScanGroupName + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Info' -and $message -like '*created scan group*' } -Times 1 -Exactly + } + + It 'sends the configured header names and a bearer token when creating a scan group' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq "$($script:NinjaBaseUrl)/vulnerability/scan-groups" -and + $Headers.Authorization -eq "Bearer $($script:Token.access_token)" -and + $Headers.'Content-Type' -eq 'application/json' -and + ($Body | ConvertFrom-Json).groupName -eq $script:ScanGroupName -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } + + It 'returns $null and logs an error when the scan group cannot be found or created' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'NinjaOne API unavailable' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not be created*' } -Times 1 -Exactly + } + + It 'returns $null and logs an error without throwing when the initial lookup GET fails' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { throw 'NinjaOne API unreachable' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not look up scan group*' } -Times 1 -Exactly + } + + It 'returns a single scan group object when multiple groups share the same name' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'dup-1'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + [pscustomobject]@{ id = 'dup-2'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.GetType().IsArray | Should -Be $false + $Result.id | Should -Be 'dup-1' + } + + It 'falls back to default header names when CveSyncDeviceIdHeader/CveSyncCveIdHeader are not configured' { + $script:Configuration = [pscustomobject]@{ Instance = 'contoso.rmmservice.com' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } +} diff --git a/Tests/Reports/Get-CIPPDrift.Tests.ps1 b/Tests/Reports/Get-CIPPDrift.Tests.ps1 new file mode 100644 index 0000000000000..b69b241a59f33 --- /dev/null +++ b/Tests/Reports/Get-CIPPDrift.Tests.ps1 @@ -0,0 +1,568 @@ +# Pester tests for Get-CIPPDrift +# +# Covers: +# - The #6347 bug: raw .name comparison used to match $null -eq $null for policy types that have +# no .name property at all, silently suppressing tenant-only Intune drift detection. +# - A second regression discovered while validating the #6347 fix: collapsing template/tenant +# names to a single "effective name" (preferring displayName) breaks matching for Settings +# Catalog-style policies, whose templates always get a CIPP-forced .displayName but whose real +# Graph identity (and the only property tenant policies of that type actually have) is .name. +# - Conditional Access extra-policy matching (unaffected by either bug, used as a control). +# - Standards-deviation display name/description resolution (Intune/CA/ReusableSettings/Quarantine). +# - Stale tenantDrift row pruning, gated on whether the relevant Graph collection succeeded. +# +# Get-CIPPDrift talks to several CIPPCore/Az helpers; all are stubbed here and mocked per-scenario +# so the function under test can run standalone, following the convention in +# Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDrift.ps1' + + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset) } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Filter, $TableName) } + function Get-CIPPTenantAlignment { param($TenantFilter, $TemplateId) } + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force, $TableName) } + function Remove-AzDataTableEntity { param($Entity, $TableName) } + + # Builds an IntuneTemplate row the way the templates table stores it: JSON is a wrapper object + # (Displayname/Description/Type/RAWJson) where RAWJson is itself a JSON string of the captured + # policy body. RawPolicy is a hashtable/PSCustomObject for the *actual* Graph-shaped body (the + # thing that may or may not have .name / .displayName depending on policy type). + function New-IntuneTemplateRow { + param($RowKey, $DisplayName, $RawPolicy, $Description = 'desc', $Type = 'deviceCompliancePolicy', $Package) + $Wrapper = @{ + Displayname = $DisplayName + Description = $Description + Type = $Type + RAWJson = ($RawPolicy | ConvertTo-Json -Compress -Depth 10) + } + [pscustomobject]@{ + PartitionKey = 'IntuneTemplate' + RowKey = $RowKey + Package = $Package + JSON = ($Wrapper | ConvertTo-Json -Compress -Depth 10) + } + } + + function New-CATemplateRow { + param($RowKey, $Policy, $Package) + [pscustomobject]@{ + PartitionKey = 'CATemplate' + RowKey = $RowKey + Package = $Package + JSON = ($Policy | ConvertTo-Json -Compress -Depth 10) + } + } + + function New-DriftEntity { + param($StandardName, $Status = 'New', $Reason = $null, $User = $null) + [pscustomobject]@{ + StandardName = $StandardName + Status = $Status + Reason = $Reason + User = $User + } + } + + # NOTE: These two builders must live in this top-level BeforeAll (not directly inside a + # Describe block) because Pester v5 only re-executes BeforeAll/It bodies during the Run + # phase - plain `function` statements written directly in a Describe body only exist during + # the Discovery phase and are gone by the time Mock -MockWith scriptblocks actually invoke them. + function New-BaseAlignment { + param($TemplateGuids) + [pscustomobject]@{ + standardType = 'drift' + StandardName = 'Test Standard' + StandardId = 'sid-1' + AlignmentScore = 100 + CompliantStandards = 0 + ComparisonDetails = @() + LatestDataCollection = (Get-Date) + standardSettings = @{ + # Get-CIPPDrift iterates standardSettings.IntuneTemplate as an array and reads a + # single TemplateList.value per entry - it is one entry per selected template, not + # one entry with a comma-joined value. + IntuneTemplate = @($TemplateGuids | ForEach-Object { @{ TemplateList = @{ value = $_ } } }) + } + } + } + + function New-CABaseAlignment { + param($TemplateGuids) + [pscustomobject]@{ + standardType = 'drift' + StandardName = 'CA Standard' + StandardId = 'sid-2' + AlignmentScore = 100 + CompliantStandards = 0 + ComparisonDetails = @() + LatestDataCollection = (Get-Date) + standardSettings = @{ + ConditionalAccessTemplate = @($TemplateGuids | ForEach-Object { @{ TemplateList = @{ value = $_ } } }) + } + } + } + + . $FunctionPath +} + +Describe 'Get-CIPPDrift - Intune extra-policy matching (#6347 and Settings Catalog regression)' { + BeforeEach { + $script:IntuneCapable = $true + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + $script:AddedDriftEntities = [System.Collections.Generic.List[object]]::new() + $script:RemovedDriftEntities = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { + param($StandardName, $TenantFilter, $Preset) + if ($Preset -eq 'Intune') { $script:IntuneCapable } else { $script:ConditionalAccessCapable } + } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { + [pscustomobject]@{ + id = $r.id + body = @{ value = @($script:GraphResponses[$r.id]) } + } + } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + param($Entity, [switch]$Force, $TableName) + foreach ($e in @($Entity)) { $script:AddedDriftEntities.Add($e) } + } + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($Entity, $TableName) + $script:RemovedDriftEntities.Add($Entity) + } + } + + It 'does NOT match on the original null-eq-null bug (template and tenant both lack .name)' { + # Both sides are of a displayName-only Graph type (e.g. compliance policy): the raw captured + # body has no .name property at all, so it is $null on both sides. Names genuinely differ. + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template A' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Totally Different Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].standardName | Should -Be 'IntuneTemplates.tenant-1' + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Intune - Totally Different Policy' + } + + It 'matches on displayName-to-displayName when both sides have equal, non-null displayName' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Same Policy' -RawPolicy @{ id = 'tpl-1'; displayName = 'Same Policy' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Same Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches Settings Catalog policies on raw name-to-name even though the template also has a forced displayName' { + # Settings Catalog (deviceManagement/configurationPolicies) templates get a CIPP-friendly + # displayName forced onto them (Add-Member -Force) even though the underlying Graph object + # only ever has .name. The tenant-side policy, captured straight from Graph, has ONLY .name. + # A "collapsed effective name" comparison (prefer displayName if present) would compare the + # CIPP-friendly template name against the raw tenant .name and never match - that was the + # regression introduced by the first fix attempt for #6347. + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'My Settings Catalog Template' -Type 'configurationPolicy' -RawPolicy @{ id = 'tpl-1'; name = 'RawSettingsCatalogName123' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/configurationPolicies' = @( + @{ id = 'tenant-1'; name = 'RawSettingsCatalogName123' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches on the displayName-to-name cross pairing' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Cross Match' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/configurationPolicies' = @( + @{ id = 'tenant-1'; name = 'Cross Match' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches on the name-to-displayName cross pairing' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Some Friendly Name' -Type 'configurationPolicy' -RawPolicy @{ id = 'tpl-1'; name = 'Cross Match 2' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Cross Match 2' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'reports a deviation when no pairing matches any template' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template One' -RawPolicy @{ id = 'tpl-1'; name = 'template-one-raw' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Unrelated Tenant Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].expectedValue | Should -Match 'only exists in the tenant' + } + + It 'matches a later template when earlier templates in the loop do not match' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Not It' -RawPolicy @{ id = 'tpl-1' }) + (New-IntuneTemplateRow -RowKey 'guid-2' -DisplayName 'This One' -RawPolicy @{ id = 'tpl-2' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'This One' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1', 'guid-2')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'preserves an Accepted status across re-detection of the same extra policy' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template A' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Unrelated Tenant Policy' } + ) + } + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.tenant-1' -Status 'Accepted') + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + $Result[0].acceptedDeviationsCount | Should -Be 1 + $Result[0].acceptedDeviations[0].Status | Should -Be 'Accepted' + } +} + +Describe 'Get-CIPPDrift - Conditional Access extra-policy matching' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $true + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + + Mock -CommandName Test-CIPPStandardLicense -MockWith { + param($StandardName, $TenantFilter, $Preset) + if ($Preset -eq 'Intune') { $script:IntuneCapable } else { $script:ConditionalAccessCapable } + } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { + [pscustomobject]@{ id = $r.id; body = @{ value = @($script:GraphResponses[$r.id]) } } + } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { param($Entity, $TableName) } + } + + It 'does not report a deviation when displayName matches exactly' { + $script:CATemplateRows = @(New-CATemplateRow -RowKey 'ca-guid-1' -Policy @{ id = 'ca-tpl-1'; displayName = 'Block Legacy Auth' }) + $script:GraphResponses = @{ 'policies' = @(@{ id = 'ca-tenant-1'; displayName = 'Block Legacy Auth' }) } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-CABaseAlignment -TemplateGuids @('ca-guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'reports a deviation with a Conditional Access label when displayName does not match any template' { + $script:CATemplateRows = @(New-CATemplateRow -RowKey 'ca-guid-1' -Policy @{ id = 'ca-tpl-1'; displayName = 'Block Legacy Auth' }) + $script:GraphResponses = @{ 'policies' = @(@{ id = 'ca-tenant-1'; displayName = 'Some Other CA Policy' }) } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-CABaseAlignment -TemplateGuids @('ca-guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].standardName | Should -Be 'ConditionalAccessTemplates.ca-tenant-1' + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Conditional Access - Some Other CA Policy' + } +} + +Describe 'Get-CIPPDrift - standards deviation display name resolution' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { param($Entity, $TableName) } + } + + It 'resolves the Intune template display name and description for standards.IntuneTemplate deviations' { + $script:IntuneTemplateRows = @(New-IntuneTemplateRow -RowKey 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -DisplayName 'Compliance Baseline' -Description 'Baseline description' -RawPolicy @{}) + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-3' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.IntuneTemplate.aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.somesetting' + Compliant = $false + StandardValue = $true + ComplianceStatus = 'Non-Compliant' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Deviation = $Result[0].currentDeviations[0] + $Deviation.standardDisplayName | Should -Be 'Compliance Baseline' + $Deviation.standardDescription | Should -Be 'Baseline description' + } + + It 'decodes the hex-encoded name for standards.QuarantineTemplate deviations' { + $HexName = -join ([byte[]][System.Text.Encoding]::UTF8.GetBytes('Bad Policy') | ForEach-Object { $_.ToString('x2') }) + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-4' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = "standards.QuarantineTemplate.$HexName" + Compliant = $false + StandardValue = $true + ComplianceStatus = 'Non-Compliant' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Quarantine Policy: Bad Policy' + } + + It 'separates License Missing deviations into their own bucket, not currentDeviations' { + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-5' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.SomeStandard' + Compliant = $false + StandardValue = $true + ComplianceStatus = 'License Missing' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + $Result[0].licenseMissingDeviationsCount | Should -Be 1 + } +} + +Describe 'Get-CIPPDrift - stale drift entity pruning' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:GraphResponses = @{} + $script:RemovedDriftEntities = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($Entity, $TableName) + $script:RemovedDriftEntities.Add($Entity) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-6' + AlignmentScore = 100 + CompliantStandards = 0 + LatestDataCollection = (Get-Date) + ComparisonDetails = @() + }) + } + } + + It 'removes a stale plain standards drift row that no longer appears in the alignment' { + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'standards.LongGoneStandard') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 1 + $script:RemovedDriftEntities[0].StandardName | Should -Be 'standards.LongGoneStandard' + } + + It 'does not remove a stale IntuneTemplates row when the Intune policy collection did not run' { + # IntuneCapable is $false in this Describe block, so IntunePoliciesCollected never becomes + # $true; a stale IntuneTemplates.* row must be protected from deletion in that case, since we + # cannot prove the tenant policy is actually gone without a successful Graph collection. + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.some-tenant-policy-id') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 0 + } + + It 'does not remove a drift row that is still referenced by the current alignment' { + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'standards.StillRelevant') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-7' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.StillRelevant' + Compliant = $true + StandardValue = $true + ComplianceStatus = 'Compliant' + } + ) + }) + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 0 + } +} diff --git a/version_latest.txt b/version_latest.txt index 6842906c6fc24..15c6f3e7f724a 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.6.2 +10.6.3