diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index c64985793b36..b9899dec6d4b 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -78,6 +78,7 @@ "Cron": "0 0 */12 * * *", "Priority": 4, "RunOnProcessor": true, + "TZOffset": true, "PreferredProcessor": "standards" }, { @@ -87,6 +88,7 @@ "Cron": "0 15 */12 * * *", "Priority": 5, "RunOnProcessor": true, + "TZOffset": true, "PreferredProcessor": "standards" }, { @@ -120,6 +122,7 @@ "Cron": "0 0 0 * * 0", "Priority": 7, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -137,6 +140,7 @@ "Description": "Orchestrator to process domains", "Cron": "0 30 5 * * *", "Priority": 22, + "TZOffset": true, "RunOnProcessor": true }, { @@ -149,6 +153,7 @@ "Cron": "0 0 23 * * *", "Priority": 10, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -158,6 +163,7 @@ "Cron": "0 0 0 * * *", "Priority": 10, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -166,6 +172,7 @@ "Description": "Timer to process billing", "Cron": "0 0 0 * * *", "Priority": 12, + "TZOffset": true, "RunOnProcessor": true }, { @@ -174,6 +181,7 @@ "Description": "Orchestrator to process BPA reports", "Cron": "0 0 3 * * *", "Priority": 10, + "TZOffset": true, "RunOnProcessor": true }, { @@ -191,6 +199,7 @@ "Cron": "0 0 0 * * *", "Priority": 15, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -200,6 +209,7 @@ "Cron": "0 0 23 * * *", "Priority": 20, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -212,6 +222,7 @@ "Cron": "0 0 0 * * *", "Priority": 20, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -221,6 +232,7 @@ "Cron": "0 0 2 * * *", "Priority": 21, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -230,6 +242,7 @@ "Cron": "0 30 2 * * *", "Priority": 22, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -239,6 +252,7 @@ "Cron": "0 0 3 * * *", "Priority": 23, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true }, { @@ -248,6 +262,7 @@ "Cron": "0 0 4 * * *", "Priority": 24, "RunOnProcessor": true, + "TZOffset": true, "IsSystem": true } ] diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 index 2a4357ec7d98..508da7a7e02e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 @@ -62,6 +62,7 @@ function Push-DomainAnalyserDomain { MSCNAMEDKIMSelectors = '' EnterpriseEnrollment = '' EnterpriseRegistration = '' + AutoDiscover = '' Score = '' MaximumScore = 160 ScorePercentage = '' @@ -293,6 +294,26 @@ function Push-DomainAnalyserDomain { } #EndRegion Intune Enrollment CNAME Check + #Region AutoDiscover Check + try { + $AutoDiscoverRecord = Read-AutoDiscoverRecord -Domain $Domain + $AutoDiscoverFailCount = $AutoDiscoverRecord.ValidationFails | Measure-Object | Select-Object -ExpandProperty Count + $AutoDiscoverWarnCount = $AutoDiscoverRecord.ValidationWarns | Measure-Object | Select-Object -ExpandProperty Count + if ($AutoDiscoverFailCount -eq 0 -and $AutoDiscoverWarnCount -eq 0) { + $Result.AutoDiscover = 'Correct' + } elseif ($AutoDiscoverFailCount -eq 0) { + $Result.AutoDiscover = "$($AutoDiscoverRecord.RecordType): $($AutoDiscoverRecord.Record)" + $ScoreExplanation.Add("AutoDiscover $($AutoDiscoverRecord.RecordType) record points to unexpected target") | Out-Null + } else { + $Result.AutoDiscover = 'No Record' + $ScoreExplanation.Add('No AutoDiscover DNS record found') | Out-Null + } + } catch { + $Result.AutoDiscover = 'Error' + Write-LogMessage -API 'DomainAnalyser' -tenant $DomainObject.TenantId -message "AutoDiscover check error for $Domain" -LogData (Get-CippException -Exception $_) -sev Error + } + #EndRegion AutoDiscover Check + #Region MSCNAME DKIM Records # Get Microsoft DKIM CNAME selector Records # Ugly, but i needed to create a scope/loop i could break out of without breaking the rest of the function diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 index 9c838711e42c..fb6c7fe15bcc 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 @@ -63,6 +63,15 @@ function Push-ListGraphRequestQueue { Data = [string]$Json } Add-CIPPAzDataTableEntity @Table -Entity $GraphResults -Force | Out-Null + + if ($env:CIPPNG -eq 'true') { + try { + [Craft.Services.CacheBridge]::InvalidateByScope('AllTenants') + } catch { + Write-Information "CacheBridge invalidation skipped: $($_.Exception.Message)" + } + } + return $true } catch { Write-Warning "Queue Error: $($_.Exception.Message)" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index e4df4f354f9c..d0318bbdde5e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -5,9 +5,11 @@ function Push-UpdatePermissionsQueue { #> param($Item) - try { - $DomainRefreshRequired = $false + $Status = 'Failed' + $FailureMessage = $null + $DomainRefreshRequired = $false + try { if (!$Item.defaultDomainName) { $DomainRefreshRequired = $true } @@ -46,33 +48,55 @@ function Push-UpdatePermissionsQueue { if ($Item.defaultDomainName -ne 'PartnerTenant') { Write-Information 'Pushing CIPP-SAM admin roles' - Set-CIPPSAMAdminRoles -TenantFilter $Item.customerId + try { + Set-CIPPSAMAdminRoles -TenantFilter $Item.customerId + } catch { + $SamRoleError = Get-CippException -Exception $_ + Write-Information "Failed to set CIPP-SAM admin roles for $($Item.displayName): $($_.Exception.Message)" + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Failed to set CIPP-SAM admin roles for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Warning' -API 'UpdatePermissionsQueue' -LogData $SamRoleError + if ($Status -eq 'Success') { + $Status = 'Failed' + $FailureMessage = "Set-CIPPSAMAdminRoles: $($_.Exception.Message)" + } + } } - - $Table = Get-CIPPTable -TableName cpvtenants - $unixtime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds - $GraphRequest = @{ - LastApply = "$unixtime" - LastStatus = "$Status" - applicationId = "$($env:ApplicationID)" - Tenant = "$($Item.customerId)" - PartitionKey = 'Tenant' - RowKey = "$($Item.customerId)" + } catch { + Write-Information "Error updating permissions for $($Item.displayName): $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' -LogData (Get-CippException -Exception $_) + $Status = 'Failed' + if (-not $FailureMessage) { + $FailureMessage = $_.Exception.Message } - if ($PermissionFailures) { - $GraphRequest.LastError = $FailureMessage + } finally { + try { + $CpvTable = Get-CIPPTable -TableName cpvtenants + $unixtime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + $GraphRequest = @{ + LastApply = "$unixtime" + LastStatus = "$Status" + applicationId = "$($env:ApplicationID)" + Tenant = "$($Item.customerId)" + PartitionKey = 'Tenant' + RowKey = "$($Item.customerId)" + } + if ($FailureMessage) { + $GraphRequest.LastError = "$FailureMessage" + } + Add-CIPPAzDataTableEntity @CpvTable -Entity $GraphRequest -Force + } catch { + Write-Information "Failed to persist cpvtenants row for $($Item.displayName): $($_.Exception.Message)" } - Add-CIPPAzDataTableEntity @Table -Entity $GraphRequest -Force if ($DomainRefreshRequired) { - $UpdatedTenant = Get-Tenants -TenantFilter $Item.customerId -TriggerRefresh - if ($UpdatedTenant.defaultDomainName) { - Write-Information "Updated tenant domains $($UpdatedTenant.defaultDomainName)" + try { + $UpdatedTenant = Get-Tenants -TenantFilter $Item.customerId -TriggerRefresh + if ($UpdatedTenant.defaultDomainName) { + Write-Information "Updated tenant domains $($UpdatedTenant.defaultDomainName)" + } + } catch { + Write-Information "Failed to refresh tenant domains for $($Item.displayName): $($_.Exception.Message)" } } - } catch { - Write-Information "Error updating permissions for $($Item.displayName): $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' -LogData (Get-CippException -Exception $_) } } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 deleted file mode 100644 index b8336c9975ff..000000000000 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-Z_CIPPQueueTrigger.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -function Push-Z_CIPPQueueTrigger { - <# - .FUNCTIONALITY - Entrypoint - #> - Param($QueueItem, $TriggerMetadata) - $APIName = $QueueItem.FunctionName - - $FunctionName = 'Push-{0}' -f $APIName - if (Get-Command -Name $FunctionName -ErrorAction SilentlyContinue) { - & $FunctionName -QueueItem $QueueItem -TriggerMetadata $TriggerMetadata - } -} \ No newline at end of file diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index e28d14425cfc..916fc512fba9 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -31,7 +31,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom') $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index d9b2239b1bcb..071f65cff4e7 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -48,9 +48,10 @@ function Get-CIPPAlertIntunePolicyConflicts { return } - $AlertableStatuses = @() - if ($Config.AlertErrors) { $AlertableStatuses += 'error', 'failed' } - if ($Config.AlertConflicts) { $AlertableStatuses += 'conflict' } + $AlertableStatuses = @( + if ($Config.AlertErrors) { 'error'; 'failed' } + if ($Config.AlertConflicts) { 'conflict' } + ) if (-not $AlertableStatuses) { return @@ -68,7 +69,7 @@ function Get-CIPPAlertIntunePolicyConflicts { return } - $Issues = @() + $Issues = [System.Collections.Generic.List[object]]::new() if ($Config.IncludePolicies) { try { @@ -77,16 +78,16 @@ function Get-CIPPAlertIntunePolicyConflicts { foreach ($Device in $ManagedDevices) { $PolicyStates = $Device.deviceConfigurationStates | Where-Object { $_.state -and ($AlertableStatuses -contains $_.state) } foreach ($State in $PolicyStates) { - $Issues += [PSCustomObject]@{ - Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Policy' - PolicyName = $State.displayName - IssueStatus = $State.state - DeviceName = $Device.deviceName - UserPrincipalName = $Device.userPrincipalName - DeviceId = $Device.id - } + $Issues.Add([PSCustomObject]@{ + Message = "Policy '$($State.displayName)' is $($State.state) on device '$($Device.deviceName)' for $($Device.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Policy' + PolicyName = $State.displayName + IssueStatus = $State.state + DeviceName = $Device.deviceName + UserPrincipalName = $Device.userPrincipalName + DeviceId = $Device.id + }) } } } catch { @@ -105,16 +106,16 @@ function Get-CIPPAlertIntunePolicyConflicts { } foreach ($Status in $BadStatuses) { - $Issues += [PSCustomObject]@{ - Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)." - Tenant = $TenantFilter - Type = 'Application' - AppName = $App.displayName - IssueStatus = $Status.installState - DeviceName = $Status.deviceName - UserPrincipalName = $Status.userPrincipalName - DeviceId = $Status.deviceId - } + $Issues.Add([PSCustomObject]@{ + Message = "App '$($App.displayName)' install is $($Status.installState) on device '$($Status.deviceName)' for $($Status.userPrincipalName)." + Tenant = $TenantFilter + Type = 'Application' + AppName = $App.displayName + IssueStatus = $Status.installState + DeviceName = $Status.deviceName + UserPrincipalName = $Status.userPrincipalName + DeviceId = $Status.deviceId + }) } } } catch { @@ -132,11 +133,11 @@ function Get-CIPPAlertIntunePolicyConflicts { $AppCount = ($Issues | Where-Object { $_.Type -eq 'Application' }).Count $AlertData = @([PSCustomObject]@{ - Message = "Found $PolicyCount policy issues and $AppCount application issues in Intune." - Tenant = $TenantFilter - PolicyIssues = $PolicyCount - AppIssues = $AppCount - Issues = $Issues + Message = "Found $PolicyCount policy issues and $AppCount application issues in Intune." + Tenant = $TenantFilter + PolicyIssues = $PolicyCount + AppIssues = $AppCount + Issues = $Issues }) } else { $AlertData = $Issues diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 index d6899a8af1f4..ac6aa1bef28e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 @@ -14,7 +14,7 @@ function Get-CIPPAlertNewAppApproval { ) try { - $Approvals = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/identityGovernance/appConsent/appConsentRequests?`$top=100&`$filter=userConsentRequests/any (u:u/status eq 'InProgress')" -tenantid $TenantFilter + $Approvals = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/identityGovernance/appConsent/appConsentRequests?`$top=100&`$filter=userConsentRequests/any(u:u/status eq 'InProgress')" -tenantid $TenantFilter if ($Approvals.count -gt 0) { $TenantGUID = (Get-Tenants -TenantFilter $TenantFilter -SkipDomains).customerId @@ -24,6 +24,9 @@ function Get-CIPPAlertNewAppApproval { $userConsentRequests = New-GraphGetRequest -Uri "https://graph.microsoft.com/v1.0/identityGovernance/appConsent/appConsentRequests/$($App.id)/userConsentRequests" -tenantid $TenantFilter $userConsentRequests | ForEach-Object { + if ($_.status -eq 'Expired') { + return + } $consentUrl = if ($App.consentType -eq 'Static') { # if something is going wrong here you've probably stumbled on a fourth variation - rvdwegen "https://login.microsoftonline.com/$($TenantFilter)/adminConsent?client_id=$($App.appId)&bf_id=$($App.id)&redirect_uri=https://entra.microsoft.com/TokenAuthorize" diff --git a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 b/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 index bed52e8cc786..74c1110550a6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 @@ -34,7 +34,7 @@ function Add-CIPPBPAField { $Result[$fieldName] = [string]$JsonString } 'string' { - $Result[$fieldName], [string]$FieldValue + $Result[$fieldName] = [string]$FieldValue } } Add-CIPPAzDataTableEntity @Table -Entity $Result -Force diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index 9faf31d4a479..e135d29d6d74 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -123,7 +123,7 @@ function New-CippAuditLogSearch { $SearchParams = @{ displayName = $DisplayName filterStartDateTime = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') - filterEndDateTime = $EndTime.AddHours(1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + filterEndDateTime = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') } if ($OperationsFilters) { $SearchParams.operationFilters = @($OperationsFilters) diff --git a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 index 6302f05994e8..ae77578f4ae3 100644 --- a/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/New-CippQueueEntry.ps1 @@ -21,6 +21,7 @@ function New-CippQueueEntry { } if ($env:CIPPNG -eq 'true') { + [Craft.Services.QueueStatusBridge]::RegisterQueueMetadata($QueueEntry.RowKey, $Name, $Link, $Reference) return $QueueEntry } diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index d4a19075ac9e..fc9902d80823 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -12,7 +12,7 @@ function Compare-CIPPIntuneObject { [Parameter(Mandatory = $false)] [string[]]$CompareType = @() ) - if ($CompareType -ne 'Catalog') { + if ($CompareType -notcontains 'Catalog') { $defaultExcludeProperties = @( 'id', 'createdDateTime', diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 new file mode 100644 index 000000000000..68ed78012e63 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 @@ -0,0 +1,284 @@ +function Invoke-ListObjectHistory { + <# + .SYNOPSIS + In progress concept - Rvd + Returns a transformed timeline of audit events for any tenant object over a configurable period. + + .DESCRIPTION + Aggregates change history from Graph directoryAudits and (for Exchange objects) the Unified Audit Log. + Returns a normalised timeline array with parsed property changes, actor details, and source metadata. + Supports users, groups, applications, service principals, devices, administrative units, + conditional access policies, shared mailboxes, distribution lists, and mail contacts. + + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.AuditLog.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $ObjectId = $Request.Query.objectId ?? $Request.Query.id ?? $Request.Body.objectId ?? $Request.Body.id + $ObjectType = $Request.Query.objectType ?? $Request.Body.objectType + $Days = try { [int]($Request.Query.days ?? $Request.Body.days ?? 30) } catch { 30 } + + #region Validation + if (-not $ObjectId) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: objectId is required' } + } + } + + try { + $ObjectId = ConvertTo-CIPPODataFilterValue -Value $ObjectId -Type Guid + } catch { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: objectId must be a valid GUID' } + } + } + + if (-not $TenantFilter) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: tenantFilter is required' } + } + } + + if ($Days -lt 1) { $Days = 1 } + if ($Days -gt 90) { $Days = 90 } + #endregion + + $StartTime = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $TypeKey = ($ObjectType ?? '').Trim().ToLowerInvariant() + $Warnings = [System.Collections.Generic.List[string]]::new() + $Sources = [System.Collections.Generic.List[string]]::new() + + #region Resolve object and classify source lanes + $ResolveUri = if ($TypeKey -eq 'conditionalaccesspolicy') { + "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$ObjectId" + } else { + "https://graph.microsoft.com/v1.0/directoryObjects/$ObjectId" + } + + $ResolvedObject = $null + $ResolvedAs = $null + $ResolvedDisplayName = $null + try { + $ResolvedObject = New-GraphGetRequest -uri $ResolveUri -tenantid $TenantFilter -ErrorAction Stop + $ODataType = $ResolvedObject.'@odata.type' -replace '^#microsoft\.graph\.', '' + $ResolvedAs = if ($TypeKey -eq 'conditionalaccesspolicy') { 'conditionalAccessPolicy' } else { $ODataType ?? 'directoryObject' } + $ResolvedDisplayName = $ResolvedObject.displayName ?? $ResolvedObject.userPrincipalName ?? $ResolvedObject.appId ?? $ObjectId + } catch { + $ResolvedAs = if ($TypeKey) { $TypeKey } else { 'directoryObject' } + $ResolvedDisplayName = $ObjectId + } + + # Classify which audit sources to query based on resolved type + $ExchangeOnlyTypes = @('mailbox', 'sharedmailbox', 'distributionlist', 'mailcontact', 'resource', 'roommailbox', 'equipmentmailbox') + $EntraOnlyTypes = @('application', 'serviceprincipal', 'device', 'administrativeunit', 'conditionalaccesspolicy') + + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $false + $ExchangeAnchor = $null + + if ($TypeKey -in $ExchangeOnlyTypes) { + # Caller explicitly said this is an Exchange object — skip Graph, go Exchange only + $QueryDirectoryAudits = $false + $QueryExchangeAudit = $true + $ExchangeAnchor = $ResolvedObject.userPrincipalName ?? $ResolvedObject.mail + } elseif ($TypeKey -in $EntraOnlyTypes) { + # Pure Entra object — Graph directoryAudits only + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $false + } elseif ($ResolvedObject.mail -and $ResolvedAs -in @('user', 'group')) { + # Mail-enabled user or group — query both + $QueryDirectoryAudits = $true + $QueryExchangeAudit = $true + $ExchangeAnchor = $ResolvedObject.userPrincipalName ?? $ResolvedObject.mail + } + # else: unknown type or resolution failed — default is Graph directoryAudits only + #endregion + + #region Helper: parse modifiedProperties + $ParseModifiedProperties = { + param([array]$Properties) + foreach ($Prop in $Properties) { + $OldVal = $null + $NewVal = $null + if ($Prop.oldValue -and $Prop.oldValue -ne '[]' -and $Prop.oldValue -ne 'null') { + $OldVal = try { $Prop.oldValue | ConvertFrom-Json -ErrorAction Stop } catch { $Prop.oldValue } + } + if ($Prop.newValue -and $Prop.newValue -ne '[]' -and $Prop.newValue -ne 'null') { + $NewVal = try { $Prop.newValue | ConvertFrom-Json -ErrorAction Stop } catch { $Prop.newValue } + } + if ($null -ne $OldVal -or $null -ne $NewVal) { + [PSCustomObject]@{ + property = $Prop.displayName + oldValue = $OldVal + newValue = $NewVal + } + } + } + } + #endregion + + #region Helper: normalize initiatedBy + $NormalizeActor = { + param($InitiatedBy) + if ($InitiatedBy.user) { + [PSCustomObject]@{ + displayName = $InitiatedBy.user.displayName ?? $InitiatedBy.user.userPrincipalName + id = $InitiatedBy.user.id + upn = $InitiatedBy.user.userPrincipalName + type = 'user' + } + } elseif ($InitiatedBy.app) { + [PSCustomObject]@{ + displayName = $InitiatedBy.app.displayName + id = $InitiatedBy.app.servicePrincipalId ?? $InitiatedBy.app.appId + upn = $null + type = 'app' + } + } else { + [PSCustomObject]@{ + displayName = 'Unknown' + id = $null + upn = $null + type = 'unknown' + } + } + } + #endregion + + #region Query Graph directoryAudits + [array]$DirectoryTimeline = @() + if ($QueryDirectoryAudits) { + try { + $Filter = "activityDateTime ge $StartTime and targetResources/any(s:s/id eq '$ObjectId')" + $Uri = "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=$Filter&`$orderby=activityDateTime desc" + + Write-LogMessage -API $APIName -message "Object history: querying directoryAudits for $ObjectId (last $Days days)" -Sev 'Debug' -tenant $TenantFilter + + [array]$RawAudits = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -ComplexFilter -ErrorAction Stop + + [array]$DirectoryTimeline = @(foreach ($Event in $RawAudits) { + $TargetResource = $Event.targetResources | Where-Object { $_.id -eq $ObjectId } | Select-Object -First 1 + $TargetResource = $TargetResource ?? ($Event.targetResources | Select-Object -First 1) + + [PSCustomObject]@{ + id = $Event.id + timestamp = $Event.activityDateTime + activity = $Event.activityDisplayName + category = $Event.category + operationType = $Event.operationType + result = $Event.result + actor = & $NormalizeActor $Event.initiatedBy + target = $TargetResource.displayName ?? $TargetResource.userPrincipalName ?? $ObjectId + changes = @(& $ParseModifiedProperties ($TargetResource.modifiedProperties ?? @())) + source = 'directoryAudit' + } + }) + [void]$Sources.Add('directoryAudit') + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Object history: directoryAudits failed - $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + [void]$Warnings.Add("Directory audit query failed: $($ErrorMessage.NormalizedError)") + } + } + #endregion + + #region Query Exchange Unified Audit Log + [array]$ExchangeTimeline = @() + if ($QueryExchangeAudit) { + if ($ExchangeAnchor) { + try { + $SessionId = "ObjectHistory_$(Get-Random -Minimum 10000 -Maximum 99999)" + $SearchParam = @{ + SessionCommand = 'ReturnLargeSet' + ObjectIds = @($ExchangeAnchor) + SessionId = $SessionId + StartDate = (Get-Date).AddDays(-$Days) + EndDate = (Get-Date) + ResultSize = 5000 + } + + $ExchangeLogs = [System.Collections.Generic.List[object]]::new() + $MaxPages = 10 + $Page = 0 + do { + $Batch = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SearchParam -Anchor $ExchangeAnchor) + foreach ($Item in $Batch) { [void]$ExchangeLogs.Add($Item) } + $Page++ + } while ($Batch.Count -eq 5000 -and $Page -lt $MaxPages) + + [array]$ExchangeTimeline = @(foreach ($Log in $ExchangeLogs) { + $AuditData = try { $Log.AuditData | ConvertFrom-Json -ErrorAction Stop } catch { $null } + if (-not $AuditData) { continue } + + [PSCustomObject]@{ + id = $AuditData.Id ?? $Log.Identity + timestamp = $Log.CreationDate ?? $AuditData.CreationTime + activity = $AuditData.Operation + category = 'ExchangeItem' + operationType = $AuditData.Operation + result = if ($AuditData.ResultStatus -eq 'Succeeded' -or $AuditData.ResultStatus -eq 'True') { 'success' } else { $AuditData.ResultStatus ?? 'success' } + actor = [PSCustomObject]@{ + displayName = $AuditData.UserId + id = $AuditData.UserId + upn = $AuditData.UserId + type = 'user' + } + target = $AuditData.ObjectId ?? $ExchangeAnchor + changes = @( + if ($AuditData.Parameters) { + foreach ($Param in $AuditData.Parameters) { + [PSCustomObject]@{ + property = $Param.Name + oldValue = $null + newValue = $Param.Value + } + } + } + ) + source = 'exchangeAudit' + } + }) + [void]$Sources.Add('exchangeAudit') + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Object history: Exchange UAL failed - $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + [void]$Warnings.Add("Exchange audit query failed: $($ErrorMessage.NormalizedError)") + } + } else { + [void]$Warnings.Add('Exchange audit skipped: could not determine mailbox anchor (UPN/mail)') + } + } + #endregion + + #region Merge and sort timeline + $Timeline = @($DirectoryTimeline + $ExchangeTimeline | Where-Object { $_ } | Sort-Object -Property timestamp -Descending) + #endregion + + $Body = [PSCustomObject]@{ + objectId = $ObjectId + objectType = $ObjectType + resolvedObject = $ResolvedObject + resolvedAs = $ResolvedAs + resolvedDisplayName = $ResolvedDisplayName + days = $Days + activityFromUtc = $StartTime + totalEvents = $Timeline.Count + sources = @($Sources) + warnings = @($Warnings) + timeline = @($Timeline) + } + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 31a13b68a50b..b5509bb4efc4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -42,10 +42,7 @@ function Start-CIPPOrchestrator { if (-not $InputObject.Batch -and $InputObject.QueueFunction) { $QueueFuncName = "Push-$($InputObject.QueueFunction.FunctionName)" Write-Information "Craft: Calling QueueFunction '$QueueFuncName' to build batch for '$OrchestratorName'" - $QueueItem = [PSCustomObject]@{} - if ($InputObject.QueueFunction.Parameters) { - $QueueItem = [PSCustomObject]$InputObject.QueueFunction.Parameters - } + $QueueItem = [PSCustomObject]$InputObject.QueueFunction $BatchResult = & $QueueFuncName -Item $QueueItem $QueueBatch = @($BatchResult | Where-Object { $null -ne $_ }) if ($QueueBatch.Count -eq 0) { @@ -78,7 +75,8 @@ function Start-CIPPOrchestrator { $BatchJson, 4, $PostExecFunctionName, - $PostExecParametersJson + $PostExecParametersJson, + $InputObject.Reference ) return "Craft-$OrchestratorName" } diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 11548bc85a5b..b5c1368413a1 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -69,7 +69,17 @@ function Get-CIPPTenantAlignment { $Tenants = Get-Tenants -IncludeErrors $AllStandards | Where-Object { $_.PartitionKey -in $Tenants.defaultDomainName } } - $TagTemplates = Get-CIPPAzDataTableEntity @TemplateTable + $TagTemplates = Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'IntuneTemplate'" + # Build a hashtable indexed by Package for O(1) tag lookup + $TemplatesByPackage = @{} + foreach ($t in $TagTemplates) { + if ($t.Package) { + if (-not $TemplatesByPackage.ContainsKey($t.Package)) { + $TemplatesByPackage[$t.Package] = [System.Collections.Generic.List[object]]::new() + } + $TemplatesByPackage[$t.Package].Add($t) + } + } # Build tenant standards data structure $tenantData = @{} foreach ($Standard in $Standards) { @@ -202,10 +212,9 @@ function Get-CIPPTenantAlignment { if ($IntuneTemplate.'TemplateList-Tags') { foreach ($Tag in $IntuneTemplate.'TemplateList-Tags') { - Write-Host "Processing Intune Tag: $($Tag.value)" $IntuneActions = if ($IntuneTemplate.action) { $IntuneTemplate.action } else { @() } $IntuneReportingEnabled = ($IntuneActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 - $TagTemplate = $TagTemplates | Where-Object -Property package -EQ $Tag.value + $TagTemplate = if ($TemplatesByPackage.ContainsKey($Tag.value)) { $TemplatesByPackage[$Tag.value] } else { @() } $TagTemplate | ForEach-Object { $TagStandardId = "standards.IntuneTemplate.$($_.GUID)" [PSCustomObject]@{ @@ -378,6 +387,14 @@ function Get-CIPPTenantAlignment { $LicenseMissingStandards = 0 $ReportingDisabledStandardsCount = 0 + # Initialize deviation counts before ComparisonResults loop so standards can be counted too + $PendingDeviationsCount = $null + $DeniedDeviationsCount = $null + if ($IsDriftTemplate) { + $PendingDeviationsCount = 0 + $DeniedDeviationsCount = 0 + } + foreach ($item in $ComparisonResults) { $IsAcceptedDeviation = $false $DeviationStatus = $null @@ -395,30 +412,44 @@ function Get-CIPPTenantAlignment { } if ($item.ComplianceStatus -in @('Compliant', 'Accepted Deviation', 'Customer Specific')) { $CompliantStandards++ } - elseif ($item.ComplianceStatus -eq 'Non-Compliant') { $NonCompliantStandards++ } + elseif ($item.ComplianceStatus -eq 'Non-Compliant') { + $NonCompliantStandards++ + # Count non-compliant standards as pending/denied based on drift status + if ($IsDriftTemplate) { + if (-not $DeviationStatus -or $DeviationStatus -eq 'New') { + $PendingDeviationsCount++ + } elseif ($DeviationStatus -in @('Denied', 'DeniedRemediate', 'DeniedDelete')) { + $DeniedDeviationsCount++ + } + } + } elseif ($item.ComplianceStatus -eq 'License Missing') { $LicenseMissingStandards++ } if ($item.ReportingDisabled) { $ReportingDisabledStandardsCount++ } } # For drift templates, include all policy deviation entries from tenantDrift table in alignment score # Accepted/CustomerSpecific count as compliant, all others (New, Denied, etc.) count as non-compliant - $CurrentDeviationsCount = $null if ($IsDriftTemplate) { $PolicyDeviationCompliant = 0 $PolicyDeviationNonCompliant = 0 foreach ($DriftKey in $TenantDriftStatuses.Keys) { if ($DriftKey -like 'IntuneTemplates.*' -or $DriftKey -like 'ConditionalAccessTemplates.*') { - if ($TenantDriftStatuses[$DriftKey] -in @('Accepted', 'CustomerSpecific')) { + $DriftStatus = $TenantDriftStatuses[$DriftKey] + if ($DriftStatus -in @('Accepted', 'CustomerSpecific')) { $PolicyDeviationCompliant++ } else { $PolicyDeviationNonCompliant++ + if ($DriftStatus -eq 'New') { + $PendingDeviationsCount++ + } else { + $DeniedDeviationsCount++ + } } } } $AllCount += $PolicyDeviationCompliant + $PolicyDeviationNonCompliant $CompliantStandards += $PolicyDeviationCompliant $NonCompliantStandards += $PolicyDeviationNonCompliant - $CurrentDeviationsCount = $PolicyDeviationNonCompliant } $AlignmentPercentage = if (($AllCount - $ReportingDisabledStandardsCount) -gt 0) { @@ -450,7 +481,8 @@ function Get-CIPPTenantAlignment { LicenseMissingStandards = $LicenseMissingStandards TotalStandards = $AllCount ReportingDisabledCount = $ReportingDisabledStandardsCount - CurrentDeviationsCount = $CurrentDeviationsCount + PendingDeviationsCount = $PendingDeviationsCount + DeniedDeviationsCount = $DeniedDeviationsCount LatestDataCollection = if ($LatestDataCollection) { $LatestDataCollection } else { $null } ComparisonDetails = $ComparisonResults } diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 0c9f4fb305bd..7700cfa119da 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -33,35 +33,48 @@ function Get-CIPPDrift { $ConditionalAccessCapable = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') $IntuneTable = Get-CippTable -tablename 'templates' - # Always load templates for display name resolution, even if tenant doesn't have licenses - $IntuneFilter = "PartitionKey eq 'IntuneTemplate'" - $RawIntuneTemplates = (Get-CIPPAzDataTableEntity @IntuneTable -Filter $IntuneFilter) - $AllIntuneTemplates = $RawIntuneTemplates | ForEach-Object { + # Load only IntuneTemplate partition for tag resolution and display name lookup + $RawIntuneTemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'IntuneTemplate'" + # Build a hashtable indexed by Package for O(1) tag lookup + $TemplatesByPackage = @{} + foreach ($t in $RawIntuneTemplates) { + if ($t.Package) { + if (-not $TemplatesByPackage.ContainsKey($t.Package)) { + $TemplatesByPackage[$t.Package] = [System.Collections.Generic.List[object]]::new() + } + $TemplatesByPackage[$t.Package].Add($t) + } + } + # Build GUID-indexed hashtables for O(1) display name lookups in deviation loop + $IntuneTemplatesByGuid = @{} + $AllIntuneTemplates = foreach ($RawTemplate in $RawIntuneTemplates) { try { - $JSONData = $_.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue + $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force + $IntuneTemplatesByGuid[$RawTemplate.RowKey] = $data $data } catch { # Skip invalid templates } - } | Sort-Object -Property displayName + } - # Load all CA templates - $CAFilter = "PartitionKey eq 'CATemplate'" - $RawCATemplates = (Get-CIPPAzDataTableEntity @IntuneTable -Filter $CAFilter) - $AllCATemplates = $RawCATemplates | ForEach-Object { + # Load CA templates with GUID hashtable + $RawCATemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'CATemplate'" + $CATemplatesByGuid = @{} + $AllCATemplates = foreach ($RawTemplate in $RawCATemplates) { try { - $data = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force + $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force + $CATemplatesByGuid[$RawTemplate.RowKey] = $data $data } catch { # Skip invalid templates } - } | Sort-Object -Property displayName + } try { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter -TemplateId $TemplateId | Where-Object -Property standardType -EQ 'drift' @@ -104,44 +117,22 @@ function Get-CIPPDrift { $standardDescription = $null #if the $ComparisonItem.StandardName contains *IntuneTemplate*, then it's an Intune policy deviation, and we need to grab the correct displayname from the template table if ($ComparisonItem.StandardName -like '*IntuneTemplate*') { - # Extract GUID from format like: standards.IntuneTemplate.{GUID}.IntuneTemplate.json - # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) $Parts = $ComparisonItem.StandardName.Split('.') - $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 - - if ($CompareGuid) { - Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllIntuneTemplates | Where-Object { $_.GUID -match "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found Intune template: $displayName" - } else { - Write-Warning "Intune template not found for GUID: $CompareGuid" - } - } else { - Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" + $CompareGuid = foreach ($p in $Parts) { if ($p -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { $p; break } } + if ($CompareGuid -and $IntuneTemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $IntuneTemplatesByGuid[$CompareGuid] + $displayName = $Template.displayName + $standardDescription = $Template.description } } # Handle Conditional Access templates if ($ComparisonItem.StandardName -like '*ConditionalAccessTemplate*') { - # Extract GUID from format like: standards.ConditionalAccessTemplate.{GUID}.CATemplate.json - # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) $Parts = $ComparisonItem.StandardName.Split('.') - $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 - - if ($CompareGuid) { - Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllCATemplates | Where-Object { $_.GUID -match "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found CA template: $displayName" - } else { - Write-Warning "CA template not found for GUID: $CompareGuid" - } - } else { - Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" + $CompareGuid = foreach ($p in $Parts) { if ($p -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { $p; break } } + if ($CompareGuid -and $CATemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $CATemplatesByGuid[$CompareGuid] + $displayName = $Template.displayName + $standardDescription = $Template.description } } # Handle QuarantineTemplate — suffix is hex-encoded display name, decode it @@ -269,9 +260,15 @@ function Get-CIPPDrift { if ($Template.TemplateList.value) { $IntuneTemplateIds.Add($Template.TemplateList.value) } - if ($Template.'TemplateList-Tags'.rawData.templates) { - foreach ($TagTemplate in $Template.'TemplateList-Tags'.rawData.templates) { - $IntuneTemplateIds.Add($TagTemplate.GUID) + if ($Template.'TemplateList-Tags') { + foreach ($Tag in $Template.'TemplateList-Tags') { + $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } + $ResolvedTagTemplates = if ($TemplatesByPackage.ContainsKey($TagValue)) { $TemplatesByPackage[$TagValue] } else { @() } + foreach ($ResolvedTemplate in $ResolvedTagTemplates) { + if ($ResolvedTemplate.RowKey -and $ResolvedTemplate.RowKey -notin $IntuneTemplateIds) { + $IntuneTemplateIds.Add($ResolvedTemplate.RowKey) + } + } } } } @@ -284,7 +281,9 @@ function Get-CIPPDrift { # Get actual CA templates from templates table if ($CATemplateIds.Count -gt 0) { try { - $TemplateCATemplates = $AllCATemplates | Where-Object { $_.GUID -in $CATemplateIds } + $TemplateCATemplates = foreach ($id in $CATemplateIds) { + if ($CATemplatesByGuid.ContainsKey($id)) { $CATemplatesByGuid[$id] } + } } catch { Write-Warning "Failed to get CA templates: $($_.Exception.Message)" } @@ -293,8 +292,9 @@ function Get-CIPPDrift { # Get actual Intune templates from templates table if ($IntuneTemplateIds.Count -gt 0) { try { - - $TemplateIntuneTemplates = $AllIntuneTemplates | Where-Object { $_.GUID -in $IntuneTemplateIds } + $TemplateIntuneTemplates = foreach ($id in $IntuneTemplateIds) { + if ($IntuneTemplatesByGuid.ContainsKey($id)) { $IntuneTemplatesByGuid[$id] } + } } catch { Write-Warning "Failed to get Intune templates: $($_.Exception.Message)" } @@ -380,6 +380,28 @@ function Get-CIPPDrift { $AllDeviations.AddRange($StandardsDeviations) $AllDeviations.AddRange($PolicyDeviations) + # Persist newly detected deviations to the tenantDrift table so the summary page can count them + $NewDriftEntities = [System.Collections.Generic.List[object]]::new() + foreach ($Deviation in $AllDeviations) { + if (-not $ExistingDriftStates.ContainsKey($Deviation.standardName)) { + $RowKey = $Deviation.standardName -replace '\.', '_' + $NewDriftEntities.Add(@{ + PartitionKey = $TenantFilter + RowKey = $RowKey + StandardName = $Deviation.standardName + Status = 'New' + LastModified = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + }) + } + } + if ($NewDriftEntities.Count -gt 0) { + try { + Add-CIPPAzDataTableEntity @DriftTable -Entity $NewDriftEntities -Force + } catch { + Write-Warning "Failed to persist new drift deviations: $($_.Exception.Message)" + } + } + # Filter deviations by status for counting $NewDeviations = $AllDeviations | Where-Object { $_.Status -eq 'New' } $AcceptedDeviations = $AllDeviations | Where-Object { $_.Status -eq 'Accepted' } @@ -399,7 +421,7 @@ function Get-CIPPDrift { deniedDeviationsCount = $DeniedDeviations.Count customerSpecificDeviationsCount = $CustomerSpecificDeviations.Count newDeviationsCount = $NewDeviations.Count - alignedCount = $Alignment.CompliantStandards + alignedCount = $Alignment.CompliantStandards - $AcceptedDeviations.Count - $CustomerSpecificDeviations.Count currentDeviations = @($CurrentDeviations) acceptedDeviations = @($AcceptedDeviations) customerSpecificDeviations = @($CustomerSpecificDeviations) diff --git a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 index 20f0e94172ee..754454529e3d 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocation.ps1 @@ -8,7 +8,7 @@ function Get-CIPPGeoIPLocation { $30DaysAgo = (Get-Date).AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') $Filter = "PartitionKey eq 'IP' and RowKey eq '$IP' and Timestamp ge datetime'$30DaysAgo'" $GeoIP = Get-CippAzDataTableEntity @CacheGeoIPTable -Filter $Filter - if ($GeoIP) { + if ($GeoIP -and $GeoIP.Data) { return ($GeoIP.Data | ConvertFrom-Json) } $location = Invoke-CIPPRestMethod -Uri "https://geoipdb.azurewebsites.net/api/GetIPInfo?IP=$IP" diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f03e3187620e..3b2db7623b7e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -17,7 +17,7 @@ function Get-CIPPTextReplacement { [switch]$EscapeForJson ) if ($Text -isnot [string]) { - return $Text + return , $Text } # Without a tenant context, skip replacement lookups and return input as-is. diff --git a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 index ad1db76305bb..134f8aeeeb79 100644 --- a/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 +++ b/Modules/CIPPCore/Public/GraphRequests/Get-GraphRequestList.ps1 @@ -199,7 +199,7 @@ function Get-GraphRequestList { $Type = 'Queue' Write-Information "Cached: $(($Rows | Measure-Object).Count) rows (Type: $($Type))" $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey - $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -ne 'Completed' -and $_.Status -ne 'Failed' } + $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -ne 'Completed' -and $_.Status -ne 'Failed' -and $_.Reference -eq $QueueReference } } elseif (!$SkipCache.IsPresent -and !$ClearCache.IsPresent -and !$CountOnly.IsPresent) { if ($TenantFilter -eq 'AllTenants' -or $Count -gt $SingleTenantThreshold) { $Table = Get-CIPPTable -TableName $TableName @@ -214,7 +214,7 @@ function Get-GraphRequestList { $Type = 'Cache' Write-Information "Table: $TableName | PK: $PartitionKey | Cached: $(($Rows | Measure-Object).Count) rows (Type: $($Type))" $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey - $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } + $RunningQueue = Get-CIPPQueueData -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' -and $_.Reference -eq $QueueReference } } } } catch { @@ -294,6 +294,7 @@ function Get-GraphRequestList { $InputObject = @{ OrchestratorName = 'GraphRequestOrchestrator' Batch = @($Batch) + Reference = $QueueReference } #Write-Information ($InputObject | ConvertTo-Json -Depth 5) $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject diff --git a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 07e5301fdfac..88d7e2bc5ba5 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -90,10 +90,12 @@ function Invoke-CIPPRestMethod { # ------------------------------------------------------------------ # Escape hatch — env var kill switch, missing pooled client type, - # or per-call legacy switch + # per-call legacy switch, or binary body (byte[] cannot be + # serialised to string for the pooled C# client) # ------------------------------------------------------------------ $HasCippRestClient = $null -ne ('CIPP.CIPPRestClient' -as [type]) - if ($UseLegacyInvokeRestMethod -or $env:DisableCIPPRestMethod -eq 'true' -or -not $HasCippRestClient) { + $IsBinaryBody = $Body -is [byte[]] + if ($UseLegacyInvokeRestMethod -or $env:DisableCIPPRestMethod -eq 'true' -or -not $HasCippRestClient -or $IsBinaryBody) { $LegacyParams = @{ Uri = $Uri Method = $Method diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index 2e129932c92a..48514b19b36e 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -15,6 +15,7 @@ function Invoke-CIPPTestCollection { - EIDSCA → Invoke-CippTestEIDSCA* - CISA → Invoke-CippTestCISA* - CIS → Invoke-CippTestCIS_* + - SMB1001 → Invoke-CippTestSMB1001_* - CopilotReadiness → Invoke-CippTestCopilotReady* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function @@ -32,7 +33,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -47,6 +48,7 @@ function Invoke-CIPPTestCollection { EIDSCA = 'Invoke-CippTestEIDSCA*' CISA = 'Invoke-CippTestCISA*' CIS = 'Invoke-CippTestCIS_*' + SMB1001 = 'Invoke-CippTestSMB1001_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' } diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index 221346f1bd04..deb66894da52 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -280,17 +280,9 @@ function New-CIPPAlertTemplate { } if ($Format -eq 'html') { - # Escape curly braces in content variables so the -f format operator - # does not interpret data values (e.g. JSON in drift/standards) as placeholders - $FmtTitle = [string]$Title -replace '\{', '{{' -replace '\}', '}}' - $FmtIntroText = [string]$IntroText -replace '\{', '{{' -replace '\}', '}}' - $FmtButtonUrl = [string]$ButtonUrl -replace '\{', '{{' -replace '\}', '}}' - $FmtButtonText = [string]$ButtonText -replace '\{', '{{' -replace '\}', '}}' - $FmtAfterButtonText = [string]$AfterButtonText -replace '\{', '{{' -replace '\}', '}}' - $FmtAuditLogLink = [string]$AuditLogLink -replace '\{', '{{' -replace '\}', '}}' - return [pscustomobject]@{ + return [pscustomobject]@{ title = $Title - htmlcontent = $HTMLTemplate -f $FmtTitle, $FmtIntroText, $FmtButtonUrl, $FmtButtonText, $FmtAfterButtonText, $FmtAuditLogLink + htmlcontent = $HTMLTemplate -f $Title, $IntroText, $ButtonUrl, $ButtonText, $AfterButtonText, $AuditLogLink } } elseif ($Format -eq 'json') { if ($InputObject -eq 'auditlog') { diff --git a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 index a91904f4724a..7fdfab465719 100644 --- a/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPUserTask.ps1 @@ -81,13 +81,13 @@ function New-CIPPUserTask { } if ($UserObj.setManager) { - $ManagerResult = Set-CIPPManager -User $CreationResults.Username -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResult) + $ManagerResults = Set-CIPPManager -Users $CreationResults.Username -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) } if ($UserObj.setSponsor) { - $SponsorResult = Set-CIPPSponsor -User $CreationResults.Username -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResult) + $SponsorResults = Set-CIPPSponsor -Users $CreationResults.Username -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) } return @{ diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index a7202202bef0..75d7971388a4 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -26,7 +26,7 @@ function Set-CIPPAuthenticationPolicy { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage - Return "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" + return "Could not get CurrentInfo for $AuthenticationMethodId. Error:$($ErrorMessage.NormalizedError)" } switch ($AuthenticationMethodId) { @@ -114,7 +114,7 @@ function Set-CIPPAuthenticationPolicy { throw "Setting $AuthenticationMethodId to enabled is not allowed" } } - Default { + default { Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." -sev Error throw "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." } diff --git a/Modules/CIPPCore/Public/Set-CIPPManager.ps1 b/Modules/CIPPCore/Public/Set-CIPPManager.ps1 index c1394b04936d..b71367bceb88 100644 --- a/Modules/CIPPCore/Public/Set-CIPPManager.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPManager.ps1 @@ -1,23 +1,49 @@ function Set-CIPPManager { [CmdletBinding()] param ( - $User, - $Manager, + [Alias('User')] + [string[]] $Users, + [string] $Manager, $TenantFilter, $APIName = 'Set Manager', $Headers ) - try { - $ManagerBody = [PSCustomObject]@{'@odata.id' = "https://graph.microsoft.com/beta/users/$($Manager)" } - $ManagerBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $ManagerBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($User)/manager/`$ref" -tenantid $TenantFilter -type PUT -body $ManagerBodyJSON - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Set $User's manager to $Manager" -Sev 'Info' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to Set Manager. Error:$($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $_ - throw "Failed to set manager: $($ErrorMessage.NormalizedError)" + if ($Users.Count -eq 0) { + return @() } - return "Set $User's manager to $Manager" -} + $RequestId = 0 + $Requests = foreach ($User in $Users) { + @{ + id = ($RequestId++).ToString() + method = 'PUT' + url = "users/$User/manager/`$ref" + body = @{ '@odata.id' = "https://graph.microsoft.com/beta/users/$Manager" } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $Responses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) + + $Results = foreach ($Response in @($Responses)) { + $ResponseIndex = [int]$Response.id + $User = $Users[$ResponseIndex] + $Success = [int]$Response.status -in @(200, 204) + $ErrorMessage = if ($Response.body.error.message) { $Response.body.error.message } else { "Unknown error (Status: $($Response.status))" } + $Result = if ($Success) { "Set $User's manager to $Manager" } else { "Failed to set $User's manager: $ErrorMessage" } + $Severity = if ($Success) { 'Info' } else { 'Error' } + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev $Severity + + [pscustomobject]@{ + User = $User + Manager = $Manager + Success = $Success + Result = $Result + Status = $Response.status + } + } + + return @($Results) +} diff --git a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 index ab71ea5ca769..a445e296a0d7 100644 --- a/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPOutOfoffice.ps1 @@ -18,7 +18,8 @@ function Set-CIPPOutOfOffice { [bool]$AutoDeclineFutureRequestsWhenOOF, [bool]$DeclineEventsForScheduledOOF, [bool]$DeclineAllEventsForScheduledOOF, - [string]$DeclineMeetingMessage + [string]$DeclineMeetingMessage, + [string]$Timezone ) try { @@ -68,9 +69,23 @@ function Set-CIPPOutOfOffice { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxAutoReplyConfiguration' -cmdParams $CmdParams -Anchor $UserID - $Results = $State -eq 'Scheduled' ? - "Scheduled Out-of-office for $($UserID) between $($StartTime.toString()) and $($EndTime.toString())" : - "Set Out-of-office for $($UserID) to $State." + if ($State -eq 'Scheduled') { + # Convert display times to the user's local timezone if provided + $DisplayStart = $StartTime + $DisplayEnd = $EndTime + if ($Timezone) { + try { + $UserTz = [System.TimeZoneInfo]::FindSystemTimeZoneById($Timezone) + $DisplayStart = [System.TimeZoneInfo]::ConvertTimeFromUtc($StartTime, $UserTz) + $DisplayEnd = [System.TimeZoneInfo]::ConvertTimeFromUtc($EndTime, $UserTz) + } catch { + # Fall back to UTC times if timezone conversion fails + } + } + $Results = "Scheduled Out-of-office for $($UserID) between $($DisplayStart.ToString()) and $($DisplayEnd.ToString())" + } else { + $Results = "Set Out-of-office for $($UserID) to $State." + } Write-LogMessage -headers $Headers -API $APIName -message $Results -Sev 'Info' -tenant $TenantFilter return $Results diff --git a/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 b/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 index 3069799de39f..5bf6225a54fa 100644 --- a/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPProfilePhoto.ps1 @@ -11,7 +11,7 @@ function Set-CIPPProfilePhoto { ) try { $PhotoBytes = [Convert]::FromBase64String($PhotoBase64) - New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$type/$id/photo/`$value" -tenantid $tenantfilter -type PUT -body $PhotoBytes -ContentType $ContentType + New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$type/$id/photo/`$value" -tenantid $TenantFilter -type PUT -body $PhotoBytes -ContentType $ContentType "Successfully set profile photo for $id" Write-LogMessage -headers $Headers -API 'Set-CIPPUserProfilePhoto' -message "Successfully set profile photo for $id" -Sev 'Info' -tenant $TenantFilter } catch { diff --git a/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 b/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 index ad2444c01b9f..8bbb7bfb9850 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSponsor.ps1 @@ -1,22 +1,49 @@ function Set-CIPPSponsor { [CmdletBinding()] param ( - $User, - $Sponsor, + [Alias('User')] + [string[]] $Users, + [string] $Sponsor, $TenantFilter, $APIName = 'Set Sponsor', $Headers ) - try { - $SponsorBody = [PSCustomObject]@{'@odata.id' = "https://graph.microsoft.com/beta/users/$($Sponsor)" } - $SponsorBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $SponsorBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($User)/sponsors/`$ref" -tenantid $TenantFilter -type PUT -body $SponsorBodyJSON - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Set $User's sponsor to $Sponsor" -Sev 'Info' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to Set Sponsor. Error:$($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $_ - throw "Failed to set sponsor: $($_.Exception.Message)" + if ($Users.Count -eq 0) { + return @() } - return "Set $user's sponsor to $Sponsor" + + $RequestId = 0 + $Requests = foreach ($User in $Users) { + @{ + id = ($RequestId++).ToString() + method = 'PUT' + url = "users/$User/sponsors/`$ref" + body = @{ '@odata.id' = "https://graph.microsoft.com/beta/users/$Sponsor" } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $Responses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) + + $Results = foreach ($Response in @($Responses)) { + $ResponseIndex = [int]$Response.id + $User = $Users[$ResponseIndex] + $Success = [int]$Response.status -in @(200, 204) + $ErrorMessage = if ($Response.body.error.message) { $Response.body.error.message } else { "Unknown error (Status: $($Response.status))" } + $Result = if ($Success) { "Set $User's sponsor to $Sponsor" } else { "Failed to set $User's sponsor: $ErrorMessage" } + $Severity = if ($Success) { 'Info' } else { 'Error' } + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev $Severity + + [pscustomobject]@{ + User = $User + Sponsor = $Sponsor + Success = $Success + Result = $Result + Status = $Response.status + } + } + + return @($Results) } diff --git a/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 b/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 index 4adcd4831f7e..9dd7a7254dd0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUserJITAdmin.ps1 @@ -10,7 +10,7 @@ function Set-CIPPUserJITAdmin { Tenant to manage for JIT admin .PARAMETER User - User object to manage JIT admin roles, required property UserPrincipalName - If user is being created we also require FirstName and LastName + User object to manage JIT admin roles, required property UserPrincipalName - If user is being created we also require FirstName and LastName. Optional UsageLocation (ISO 3166-1 alpha-2 country code) can be provided for user creation. .PARAMETER Roles List of Role GUIDs to add or remove @@ -82,6 +82,9 @@ function Set-CIPPUserJITAdmin { jitAdminCreatedBy = if ($Headers) { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } else { 'Unknown' } } } + if (-not [string]::IsNullOrWhiteSpace($User.UsageLocation)) { + $Body.usageLocation = $User.UsageLocation + } $Json = ConvertTo-Json -Depth 5 -InputObject $Body try { $NewUser = New-GraphPOSTRequest -type POST -Uri 'https://graph.microsoft.com/beta/users' -Body $Json -tenantid $TenantFilter diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 603df66a4c96..56d3c5843e44 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -68,7 +68,7 @@ function Import-CommunityTemplate { $Template | Add-Member -MemberType NoteProperty -Name SHA -Value $SHA -Force $Template | Add-Member -MemberType NoteProperty -Name Source -Value $Source -Force Add-CIPPAzDataTableEntity @Table -Entity $Template -Force - + if ($Existing -and $Existing.SHA -ne $SHA) { $StatusMessage = "Updated template '$($Template.RowKey)' from source '$Source' (SHA changed)." } elseif ($Existing) { @@ -217,6 +217,21 @@ function Import-CommunityTemplate { '*managedAppPolicies*' { 'AppProtection' } '*deviceAppManagement*' { 'AppProtection' } } + + # Fallback: infer type from template content when @odata.id is missing or unrecognized + if (-not $URLName) { + $odataType = $Template.'@odata.type' + $URLName = if ($null -ne $Template.settings -and $null -ne $Template.technologies) { 'Catalog' } + elseif ($null -ne $Template.scheduledActionsForRule -or $odataType -match 'CompliancePolicy') { 'DeviceCompliancePolicies' } + elseif ($odataType -match 'windowsDriverUpdateProfile') { 'windowsDriverUpdateProfiles' } + elseif ($odataType -match 'ManagedApp|managedAppProtection') { 'AppProtection' } + elseif ($odataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { 'Device' } + else { $null } + if ($URLName) { + Write-Information "Inferred Intune template type '$URLName' from content structure for '$($Template.displayName ?? $Template.Name)'" + } + } + $RawJson = $Template | Select-Object * -ExcludeProperty id, lastModifiedDateTime, 'assignments', '#microsoft*', '*@odata.navigationLink', '*@odata.associationLink', '*@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime', '@odata.id', '@odata.editLink', 'lastModifiedDateTime@odata.type', 'roleScopeTagIds@odata.type', createdDateTime, 'createdDateTime@odata.type' Remove-ODataProperties -Object $RawJson $RawJson = $RawJson | ConvertTo-Json -Depth 100 -Compress @@ -288,6 +303,6 @@ function Import-CommunityTemplate { Write-Warning $StatusMessage Write-Information $_.InvocationInfo.PositionMessage } - + return $StatusMessage } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 index a9b0190a718d..4ddaf9c44c52 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1 @@ -23,7 +23,7 @@ function Invoke-ExecListBackup { if ($NameOnly) { try { $Processed = foreach ($item in $Result) { - $properties = $item.PSObject.Properties | Where-Object { $_.Name -notin @('TenantFilter', 'ETag', 'PartitionKey', 'RowKey', 'Timestamp', 'OriginalEntityId', 'SplitOverProps', 'PartIndex') -and $_.Value } + $properties = $item.PSObject.Properties | Where-Object { $_.Name -notin @('TenantFilter', 'ETag', 'PartitionKey', 'RowKey', 'Timestamp', 'OriginalEntityId', 'SplitOverProps', 'PartIndex', 'Backup', 'BackupIsBlob') -and $_.Value } if ($Type -eq 'Scheduled') { [PSCustomObject]@{ @@ -51,7 +51,7 @@ function Invoke-ExecListBackup { } } } - $Result = $Processed | Sort-Object Timestamp -Descending + $Result = if ($Processed) { @($Processed | Sort-Object Timestamp -Descending) } else { @() } } catch { Write-Warning "Error processing backup entries: $_" Write-Information $_.InvocationInfo.PositionMessage diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 index be4d69e427e0..ad653e8acfbf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetCippAlerts.ps1 @@ -63,7 +63,7 @@ function Invoke-GetCippAlerts { }) Write-LogMessage -message ('CIPP API is running PowerShell {0}. PowerShell 7.4 or later is required.' -f $PSVersionTable.PSVersion) -API 'Updates' -tenant 'All Tenants' -sev Alert } - if (!(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { + if (${env:CIPPNG} -ne 'true' -and !(![string]::IsNullOrEmpty($env:WEBSITE_RUN_FROM_PACKAGE) -or ![string]::IsNullOrEmpty($env:DEPLOYMENT_STORAGE_CONNECTION_STRING)) -and $env:AzureWebJobsStorage -ne 'UseDevelopmentStorage=true' -and $env:NonLocalHostAzurite -ne 'true') { $Alerts.Add( @{ title = 'Function App in Write Mode' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 index a9df5705c33f..fbb3b64d7e66 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecSetOoO.ps1 @@ -71,6 +71,10 @@ function Invoke-ExecSetOoO { } } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.timezone)) { + $SplatParams.Timezone = $Request.Body.timezone + } + Write-Information "Setting Out of Office with the following parameters: $($SplatParams | ConvertTo-Json -Depth 10)" $Results = Set-CIPPOutOfOffice @SplatParams $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 index deb23870dd96..44f942384722 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 @@ -18,14 +18,19 @@ function Invoke-ExecQuarantineManagement { if ($ActionType -eq 'Release') { $params['ReleaseToAll'] = $true + if ($Request.Body.Identity -is [string]) { + $params['Identity'] = $Request.Body.Identity + } else { + $params['Identities'] = $Request.Body.Identity + $params['Identity'] = '000' + } } else { $params['ActionType'] = $ActionType - } - - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity - } else { - $params['Identities'] = $Request.Body.Identity + if ($Request.Body.Identity -is [string]) { + $params['Identities'] = @($Request.Body.Identity) + } else { + $params['Identities'] = $Request.Body.Identity + } $params['Identity'] = '000' } New-ExoRequest -tenantid $TenantFilter -cmdlet 'Release-QuarantineMessage' -cmdParams $params diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 index ff880318819f..0187c47e1f53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 @@ -19,8 +19,21 @@ function Invoke-AddOfficeApp { try { $ExistingO365 = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' -tenantid $Tenant | Where-Object { $_.displayName -eq 'Microsoft 365 Apps for Windows 10 and later' } if (!$ExistingO365) { - # Check if custom XML is provided - if ($Request.Body.useCustomXml -and $Request.Body.customXml) { + # Check if this is a template deployment with IntuneBody (saved from existing app) + if ($Request.Body.IntuneBody) { + $IntuneBody = $Request.Body.IntuneBody + if ($IntuneBody -is [string]) { + $IntuneBody = $IntuneBody | ConvertFrom-Json -Depth 100 + } + # Remove read-only properties that the Graph API won't accept on create + $ReadOnlyProps = @('id', 'createdDateTime', 'lastModifiedDateTime', 'uploadState', 'publishingState', 'isAssigned', 'roleScopeTagIds', 'dependentAppCount', 'supersedingAppCount', 'supersededAppCount', 'committedContentVersion', 'fileName', 'size') + foreach ($prop in $ReadOnlyProps) { + if ($IntuneBody.PSObject.Properties[$prop]) { + $IntuneBody.PSObject.Properties.Remove($prop) + } + } + $ObjBody = $IntuneBody + } elseif ($Request.Body.useCustomXml -and $Request.Body.customXml) { # Use custom XML configuration $ObjBody = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.officeSuiteApp' @@ -76,7 +89,7 @@ function Invoke-AddOfficeApp { 'officeSuiteAppDefaultFileFormat' = 'OfficeOpenXMLFormat' 'localesToInstall' = @($Request.Body.languages.value) 'shouldUninstallOlderVersionsOfOffice' = [bool]$Request.Body.RemoveVersions - 'updateChannel' = $Request.Body.updateChannel.value + 'updateChannel' = if ($Request.Body.updateChannel.value) { $Request.Body.updateChannel.value } else { $Request.Body.updateChannel } 'useSharedComputerActivation' = [bool]$Request.Body.SharedComputerActivation 'productIds' = $products 'largeIcon' = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index 09158ce20a60..26f924e6e195 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -157,7 +157,8 @@ function Invoke-ExecCompareIntunePolicy { } # Run the comparison - $ComparisonResults = @(Compare-CIPPIntuneObject @CompareParams) + $CompareResult = Compare-CIPPIntuneObject @CompareParams + $ComparisonResults = if ($null -eq $CompareResult) { @() } else { @($CompareResult) } $ResultBody = @{ Results = $ComparisonResults diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 index fd199a71aa7a..c01f718c2a5f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 @@ -12,12 +12,14 @@ function Invoke-ListIntunePolicy { $TenantFilter = $Request.Query.TenantFilter $id = $Request.Query.ID $URLName = $Request.Query.URLName + $DefinitionIds = $Request.Query.DefinitionIds $UseReportDB = $Request.Query.UseReportDB $IncludeSettingDefinitions = [System.Convert]::ToBoolean($Request.Query.IncludeSettingDefinitions ?? 'false') + $IsGroupPolicyDefinitionLookup = ($URLName -ieq 'GroupPolicyDefinitions') -and -not [string]::IsNullOrWhiteSpace($DefinitionIds) try { # Return cached report data when AllTenants is requested or UseReportDB is set - if ($TenantFilter -eq 'AllTenants' -or $UseReportDB -eq 'true') { + if (-not $IsGroupPolicyDefinitionLookup -and ($TenantFilter -eq 'AllTenants' -or $UseReportDB -eq 'true')) { try { $GraphRequest = Get-CIPPIntunePolicyReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK @@ -31,8 +33,30 @@ function Invoke-ListIntunePolicy { }) } - if ($ID) { - if ($URLName -ieq 'ConfigurationPolicies' -or $URLName -ieq 'configurationPolicies') { + if ($IsGroupPolicyDefinitionLookup) { + $DefinitionIdList = @($DefinitionIds -split ',' | ForEach-Object { $_.Trim() } | Where-Object { Test-IsGuid -String $_ } | Select-Object -Unique) + + if ($DefinitionIdList.Count -eq 0) { + $GraphRequest = @() + } else { + $DefinitionRequests = [System.Collections.Generic.List[object]]::new() + $DefinitionIndex = 0 + + foreach ($DefinitionId in $DefinitionIdList) { + $RequestId = "definition$DefinitionIndex" + $DefinitionRequests.Add([PSCustomObject]@{ + id = $RequestId + method = 'GET' + url = "/deviceManagement/groupPolicyDefinitions('$DefinitionId')?`$expand=presentations" + }) + $DefinitionIndex++ + } + + $DefinitionResults = New-GraphBulkRequest -Requests @($DefinitionRequests) -tenantid $TenantFilter + $GraphRequest = $DefinitionResults | Where-Object { $_.status -eq 200 -and $_.body.id } | ForEach-Object { $_.body } + } + } elseif ($ID) { + if ($URLName -ieq 'ConfigurationPolicies') { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$ID')?`$expand=settings" -tenantid $TenantFilter if ($IncludeSettingDefinitions -and $GraphRequest.settings) { @@ -65,6 +89,47 @@ function Invoke-ListIntunePolicy { } } } + } elseif ($URLName -ieq 'GroupPolicyConfigurations') { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations('$ID')" -tenantid $TenantFilter + + if ($IncludeSettingDefinitions) { + $DefinitionValuesResponse = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations('$ID')/definitionValues?`$expand=definition" -tenantid $TenantFilter + $DefinitionValues = @($DefinitionValuesResponse.value ?? $DefinitionValuesResponse) + $GraphRequest | Add-Member -NotePropertyName definitionValues -NotePropertyValue $DefinitionValues -Force + + if ($DefinitionValues.Count -gt 0) { + $PresentationRequests = [System.Collections.Generic.List[object]]::new() + $DefinitionValueLookup = @{} + $DefinitionValueIdMap = @{} + $DefinitionValueIndex = 0 + + foreach ($DefinitionValue in $DefinitionValues) { + if ($DefinitionValue.id) { + $RequestId = "definitionValue$DefinitionValueIndex" + $DefinitionValueIdMap[$RequestId] = $DefinitionValue.id + $DefinitionValueLookup[$DefinitionValue.id] = $DefinitionValue + $PresentationRequests.Add([PSCustomObject]@{ + id = $RequestId + method = 'GET' + url = "/deviceManagement/groupPolicyConfigurations('$ID')/definitionValues('$($DefinitionValue.id)')/presentationValues?`$expand=presentation" + }) + $DefinitionValueIndex++ + } + } + + if ($PresentationRequests.Count -gt 0) { + $PresentationResults = New-GraphBulkRequest -Requests @($PresentationRequests) -tenantid $TenantFilter + foreach ($PresentationResult in $PresentationResults) { + $DefinitionValueId = $DefinitionValueIdMap[$PresentationResult.id] + $DefinitionValue = $DefinitionValueLookup[$DefinitionValueId] + if ($DefinitionValue) { + $PresentationValues = @($PresentationResult.body.value ?? $PresentationResult.body) + $DefinitionValue | Add-Member -NotePropertyName presentationValues -NotePropertyValue $PresentationValues -Force + } + } + } + } + } } else { $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/$($URLName)('$ID')" -tenantid $TenantFilter } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 index cdabf6ab6273..0f12d80773df 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddJITAdminTemplate.ps1 @@ -112,6 +112,9 @@ function Invoke-AddJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } + if ($Request.Body.defaultUsageLocation) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation.value ?? $Request.Body.defaultUsageLocation + } # defaultDomain is only saved for specific tenant templates (not AllTenants) if ($TenantFilter -ne 'AllTenants' -and $Request.Body.defaultDomain) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 index 2207932c0581..4abb488e7f03 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditJITAdminTemplate.ps1 @@ -130,6 +130,9 @@ function Invoke-EditJITAdminTemplate { if (![string]::IsNullOrWhiteSpace($Request.Body.defaultUserName)) { $TemplateObject.defaultUserName = $Request.Body.defaultUserName } + if ($Request.Body.defaultUsageLocation) { + $TemplateObject.defaultUsageLocation = $Request.Body.defaultUsageLocation.value ?? $Request.Body.defaultUsageLocation + } # defaultDomain is only saved for specific tenant templates (not AllTenants) if ($TenantFilter -ne 'AllTenants' -and $Request.Body.defaultDomain) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index b6f95c20f112..417b90b22cc6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -242,13 +242,13 @@ function Invoke-EditUser { } if ($Request.body.setManager.value) { - $ManagerResult = Set-CIPPManager -User $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResult) + $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) } if ($Request.body.setSponsor.value) { - $SponsorResult = Set-CIPPSponsor -User $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResult) + $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) } return ([HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 index c8a44a162f8b..9233c506da6a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecJITAdmin.ps1 @@ -63,6 +63,7 @@ function Invoke-ExecJITAdmin { 'FirstName' = $Request.Body.FirstName 'LastName' = $Request.Body.LastName 'UserPrincipalName' = $Username + 'UsageLocation' = $Request.Body.usageLocation.value ?? $Request.Body.usageLocation } Expiration = $Expiration StartDate = $Start @@ -128,14 +129,42 @@ function Invoke-ExecJITAdmin { #Region TAP creation if ($Request.Body.UseTAP) { try { - if ($Start -gt (Get-Date)) { - $TapParams = @{ - startDateTime = [System.DateTimeOffset]::FromUnixTimeSeconds($Request.Body.StartDate).DateTime + $LifetimeMinutes = $null + $RequestedMinutes = $null + $ParsedRequestLifetime = $false + if (![string]::IsNullOrWhiteSpace($Request.Body.tapLifetimeInMinutes)) { + try { + $RequestedMinutes = [int]$Request.Body.tapLifetimeInMinutes + $ParsedRequestLifetime = $true + } catch { + Write-Warning "Failed to parse TAP lifetime from request: $($_.Exception.Message)" + } + } + + if ($null -eq $RequestedMinutes) { + $RequestedMinutes = [int](($Expiration - $Start).TotalMinutes) + } + + try { + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass' -tenantid $TenantFilter + $PolicyMax = [int]($Policy.maximumLifetimeInMinutes ?? 1440) + $PolicyMin = [Math]::Min([int]($Policy.minimumLifetimeInMinutes ?? 1), $PolicyMax) + $LifetimeMinutes = [Math]::Min([Math]::Max($RequestedMinutes, $PolicyMin), $PolicyMax) + } catch { + Write-Warning "Failed to determine TAP lifetime from policy: $($_.Exception.Message)" + if ($ParsedRequestLifetime) { + $LifetimeMinutes = $RequestedMinutes } - $TapBody = ConvertTo-Json -Depth 5 -InputObject $TapParams - } else { - $TapBody = '{}' } + + $TapParams = @{} + if ($Start -gt (Get-Date)) { + $TapParams.startDateTime = [System.DateTimeOffset]::FromUnixTimeSeconds($Request.Body.StartDate).DateTime + } + if ($LifetimeMinutes -gt 0) { + $TapParams.lifetimeInMinutes = [int]$LifetimeMinutes + } + $TapBody = if ($TapParams.Count) { ConvertTo-Json -Depth 5 -InputObject $TapParams } else { '{}' } # Write-Information "https://graph.microsoft.com/beta/users/$Username/authentication/temporaryAccessPassMethods" # Retry creating the TAP up to 10 times, since it can fail due to the user not being fully created yet. Sometimes it takes 2 reties, sometimes it takes 8+. Very annoying. -Bobby $Retries = 0 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 index f4636684fb1c..261778ad8fc9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSetUserPhoto.ps1 @@ -64,7 +64,7 @@ function Invoke-ExecSetUserPhoto { } # Upload the photo using Graph API - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$userId/photo/`$value" -tenantid $tenantFilter -type PATCH -body $photoBytes -ContentType 'image/jpeg' -NoAuthCheck $true + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$userId/photo/`$value" -tenantid $tenantFilter -type PUT -body $photoBytes -ContentType 'image/jpeg' $Results.Add('Successfully set user profile picture.') Write-LogMessage -API $APIName -tenant $tenantFilter -headers $Headers -message "Set profile picture for user $userId" -Sev Info diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 index c4c269b9c338..b71874b17ca5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-PatchUser.ps1 @@ -14,7 +14,7 @@ function Invoke-PatchUser { $HttpResponse = [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK - Body = @{'Results' = @("Default response, you should never see this.") } + Body = @{'Results' = @('Default response, you should never see this.') } } try { @@ -36,22 +36,37 @@ function Invoke-PatchUser { # Group users by tenant filter $UsersByTenant = $Users | Group-Object -Property tenantFilter - $TotalSuccessCount = 0 - $AllErrorMessages = @() + $TotalPatchSuccessCount = 0 + $TotalManagerSuccessCount = 0 + $TotalSponsorSuccessCount = 0 + $AllErrorMessages = [System.Collections.Generic.List[string]]::new() + $HasManagerUpdates = @($Users | Where-Object { -not [string]::IsNullOrWhiteSpace($_.manager) }).Count -gt 0 + $HasSponsorUpdates = @($Users | Where-Object { -not [string]::IsNullOrWhiteSpace($_.sponsor) }).Count -gt 0 + $HasRelationshipUpdates = $HasManagerUpdates -or $HasSponsorUpdates # Process each tenant separately foreach ($TenantGroup in $UsersByTenant) { $tenantFilter = $TenantGroup.Name $TenantUsers = $TenantGroup.Group + $UsersWithManager = $TenantUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.manager) } + $ManagerGroups = $UsersWithManager | Group-Object -Property manager + $UsersWithSponsor = $TenantUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.sponsor) } + $SponsorGroups = $UsersWithSponsor | Group-Object -Property sponsor # Build bulk requests for this tenant $int = 0 - $BulkRequests = foreach ($User in $TenantUsers) { - # Remove the id and tenantFilter properties from the body since they're not user properties - $PatchBody = $User | Select-Object -Property * -ExcludeProperty id, tenantFilter + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequestUsers = [System.Collections.Generic.List[object]]::new() + foreach ($User in $TenantUsers) { + # Remove routing and relationship properties from the body since they're not normal PATCH properties. + $PatchBody = $User | Select-Object -Property * -ExcludeProperty id, tenantFilter, manager, sponsor - @{ - id = $int++ + if (@($PatchBody.PSObject.Properties).Count -eq 0) { + continue + } + + $BulkRequest = @{ + id = ($int++).ToString() method = 'PATCH' url = "users/$($User.id)" body = $PatchBody @@ -59,38 +74,107 @@ function Invoke-PatchUser { 'Content-Type' = 'application/json' } } + [void]$BulkRequests.Add($BulkRequest) + [void]$BulkRequestUsers.Add($User) } # Execute bulk request for this tenant - $BulkResults = New-GraphBulkRequest -tenantid $tenantFilter -Requests @($BulkRequests) - - # Process results for this tenant - for ($i = 0; $i -lt $BulkResults.Count; $i++) { - $result = $BulkResults[$i] - $user = $TenantUsers[$i] - - if ($result.status -eq 200 -or $result.status -eq 204) { - $TotalSuccessCount++ - Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Successfully patched user $($user.id)" -Sev 'Info' - } else { - $errorMsg = if ($result.body.error.message) { - $result.body.error.message + if ($BulkRequests.Count -gt 0) { + $BulkResults = New-GraphBulkRequest -tenantid $tenantFilter -Requests ($BulkRequests.ToArray()) + + # Process results for this tenant + foreach ($BulkResult in @($BulkResults)) { + $ResultIndex = [int]$BulkResult.id + $User = $BulkRequestUsers[$ResultIndex] + + if ($BulkResult.status -eq 200 -or $BulkResult.status -eq 204) { + $TotalPatchSuccessCount++ + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Successfully patched user $($User.id)" -Sev 'Info' } else { - "Unknown error (Status: $($result.status))" + $ErrorMessage = if ($BulkResult.body.error.message) { + $BulkResult.body.error.message + } else { + "Unknown error (Status: $($BulkResult.status))" + } + [void]$AllErrorMessages.Add("Failed to patch user $($User.id) in tenant $($tenantFilter): $ErrorMessage") + Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Failed to patch user $($User.id). Error: $ErrorMessage" -Sev 'Error' + } + } + } + + foreach ($ManagerGroup in $ManagerGroups) { + $UserIds = @($ManagerGroup.Group | ForEach-Object { $_.id }) + $ManagerUpn = $ManagerGroup.Name + + try { + $ManagerResults = Set-CIPPManager -Users $UserIds -Manager $ManagerUpn -TenantFilter $tenantFilter -Headers $Headers -APIName $APIName + + foreach ($ManagerResult in @($ManagerResults)) { + if ($ManagerResult.Success) { + $TotalManagerSuccessCount++ + } else { + [void]$AllErrorMessages.Add("Failed to set manager for $($ManagerResult.User) in tenant $($tenantFilter): $($ManagerResult.Result)") + } + } + } catch { + foreach ($UserId in $UserIds) { + [void]$AllErrorMessages.Add("Failed to set manager for $UserId in tenant $($tenantFilter): $($_.Exception.Message)") + } + } + } + + foreach ($SponsorGroup in $SponsorGroups) { + $UserIds = @($SponsorGroup.Group | ForEach-Object { $_.id }) + $SponsorUpn = $SponsorGroup.Name + + try { + $SponsorResults = Set-CIPPSponsor -Users $UserIds -Sponsor $SponsorUpn -TenantFilter $tenantFilter -Headers $Headers -APIName $APIName + + foreach ($SponsorResult in @($SponsorResults)) { + if ($SponsorResult.Success) { + $TotalSponsorSuccessCount++ + } else { + [void]$AllErrorMessages.Add("Failed to set sponsor for $($SponsorResult.User) in tenant $($tenantFilter): $($SponsorResult.Result)") + } + } + } catch { + foreach ($UserId in $UserIds) { + [void]$AllErrorMessages.Add("Failed to set sponsor for $UserId in tenant $($tenantFilter): $($_.Exception.Message)") } - $AllErrorMessages += "Failed to patch user $($user.id) in tenant $($tenantFilter): $errorMsg" - Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Failed to patch user $($user.id). Error: $errorMsg" -Sev 'Error' } } } # Build final response + $TenantCount = ($Users | Select-Object -Property tenantFilter -Unique).Count + $RelationshipResults = [System.Collections.Generic.List[string]]::new() + if ($HasManagerUpdates) { + [void]$RelationshipResults.Add("$TotalManagerSuccessCount manager assignment$(if($TotalManagerSuccessCount -ne 1){'s'})") + } + if ($HasSponsorUpdates) { + [void]$RelationshipResults.Add("$TotalSponsorSuccessCount sponsor assignment$(if($TotalSponsorSuccessCount -ne 1){'s'})") + } + $RelationshipResultMessage = [string]::Join(' and ', $RelationshipResults.ToArray()) + + $SuccessMessage = if ($HasRelationshipUpdates -and $TotalPatchSuccessCount -gt 0) { + "Successfully patched $TotalPatchSuccessCount user$(if($TotalPatchSuccessCount -ne 1){'s'}) and updated $RelationshipResultMessage across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } elseif ($HasRelationshipUpdates) { + "Successfully updated $RelationshipResultMessage across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } else { + "Successfully patched $TotalPatchSuccessCount user$(if($TotalPatchSuccessCount -ne 1){'s'}) across $TenantCount tenant$(if($TenantCount -ne 1){'s'})" + } + if ($AllErrorMessages.Count -eq 0) { - $TenantCount = ($Users | Select-Object -Property tenantFilter -Unique).Count - $HttpResponse.Body = @{'Results' = @("Successfully patched $TotalSuccessCount user$(if($TotalSuccessCount -ne 1){'s'}) across $TenantCount tenant$(if($TenantCount -ne 1){'s'})") } + $HttpResponse.Body = @{'Results' = @($SuccessMessage) } } else { $HttpResponse.StatusCode = [HttpStatusCode]::BadRequest - $HttpResponse.Body = @{'Results' = $AllErrorMessages + @("Successfully patched $TotalSuccessCount of $($Users.Count) users") } + $PartialSuccessMessage = if ($HasRelationshipUpdates) { $SuccessMessage } else { "Successfully patched $TotalPatchSuccessCount of $($Users.Count) users" } + $Results = [System.Collections.Generic.List[string]]::new() + foreach ($ErrorMessage in $AllErrorMessages) { + [void]$Results.Add($ErrorMessage) + } + [void]$Results.Add($PartialSuccessMessage) + $HttpResponse.Body = @{'Results' = @($Results.ToArray()) } } } @@ -100,4 +184,4 @@ function Invoke-PatchUser { } return $HttpResponse -} \ No newline at end of file +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 index 094f651314e2..b317057f1870 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListCSPsku.ps1 @@ -19,11 +19,16 @@ function Invoke-ListCSPsku { } $StatusCode = [HttpStatusCode]::OK } catch { - $GraphRequest = [PSCustomObject]@{ - name = @(@{value = 'Error getting catalog' }) - sku = $_.Exception.Message + if ($_.Exception.Message -eq 'No Sherweb mapping found') { + $GraphRequest = @() + $StatusCode = [HttpStatusCode]::OK + } else { + $GraphRequest = [PSCustomObject]@{ + name = @(@{value = 'Error getting catalog' }) + sku = $_.Exception.Message + } + $StatusCode = [HttpStatusCode]::InternalServerError } - $StatusCode = [HttpStatusCode]::InternalServerError } return [HttpResponseContext]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 index 981a6b02a64e..4618714eed85 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTests.ps1 @@ -212,6 +212,7 @@ function Invoke-ListTests { Custom = @{ Passed = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Passed' }).Count Failed = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Failed' }).Count + NeedsAttention = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Investigate' }).Count Skipped = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Skipped' }).Count Informational = @($CustomResultsForCounts | Where-Object { $_.Status -eq 'Informational' }).Count Total = $CustomTotal diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..c251373790d7 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicy.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, RuleParams + $RuleParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).RuleParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $PolicyParams = $RequestParams | Select-Object -Property * -ExcludeProperty RuleParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpCompliancePolicy' -cmdParams $PolicyParams -Compliance -useSystemMailbox $true + + if ($RuleParams) { + # Ensure rule references the new policy + $RuleHash = @{} + $RuleParams.PSObject.Properties | ForEach-Object { $RuleHash[$_.Name] = $_.Value } + $RuleHash['Policy'] = $RequestParams.Name + if (-not $RuleHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($RuleHash['Name'])) { + $RuleHash['Name'] = "$($RequestParams.Name) Rule" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpComplianceRule' -cmdParams $RuleHash -Compliance -useSystemMailbox $true + } + + "Successfully created DLP compliance policy $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created DLP compliance policy $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create DLP compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create DLP compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..911c7817935f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 @@ -0,0 +1,49 @@ +Function Invoke-AddDlpCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Comment, Mode, Workload, Enabled, ExchangeLocation, ExchangeSenderMemberOf, ExchangeSenderMemberOfException, SharePointLocation, SharePointLocationException, OneDriveLocation, OneDriveLocationException, TeamsLocation, TeamsLocationException, EndpointDlpLocation, EndpointDlpLocationException, OnPremisesScannerDlpLocation, OnPremisesScannerDlpLocationException, ThirdPartyAppDlpLocation, ThirdPartyAppDlpLocationException, PowerBIDlpLocation, PowerBIDlpLocationException, RuleParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + # Allow Name to be sourced from displayName/name fields and ensure templated comments preserved + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'DlpCompliancePolicyTemplate' + } + $Result = "Successfully created DLP Compliance Policy Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create DLP Compliance Policy Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..c0e8daee0627 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-EditDlpCompliancePolicy.ps1 @@ -0,0 +1,48 @@ +Function Invoke-EditDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $State = $Request.Query.State ?? $Request.Body.State + + try { + $Params = @{ + Identity = $Identity + } + + # If a state was passed, toggle Enabled on the policy + if ($State) { + $Params['Enabled'] = ($State -eq 'enable' -or $State -eq $true -or $State -eq 'true') + } + + # Allow passing arbitrary additional params via Body.parameters + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpCompliancePolicy' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated DLP compliance policy $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating DLP compliance policy $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..26fcd8eafb9e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicy.ps1 @@ -0,0 +1,30 @@ +Function Invoke-ListDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $Rules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $GraphRequest = $Policies | Select-Object *, + @{l = 'AssociatedRules'; e = { $name = $_.Name; @($Rules | Where-Object { $_.ParentPolicyName -eq $name }) } }, + @{l = 'RuleCount'; e = { $name = $_.Name; (@($Rules | Where-Object { $_.ParentPolicyName -eq $name })).Count } } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 new file mode 100644 index 000000000000..a958e9acd2ee --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-ListDlpCompliancePolicyTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListDlpCompliancePolicyTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.DlpCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'DlpCompliancePolicyTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..4af00a520304 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicy.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveDlpCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpCompliancePolicy' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted DLP compliance policy $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete DLP compliance policy $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..9504141526e5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-RemoveDlpCompliancePolicyTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveDlpCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.DlpCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'DlpCompliancePolicyTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed DLP Compliance Policy template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove DLP Compliance Policy template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..fec42ad1649a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicy.ps1 @@ -0,0 +1,47 @@ +Function Invoke-AddRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, RuleParams + $RuleParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).RuleParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $PolicyParams = $RequestParams | Select-Object -Property * -ExcludeProperty RuleParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-RetentionCompliancePolicy' -cmdParams $PolicyParams -Compliance -AsApp -useSystemMailbox $true + + if ($RuleParams) { + $RuleHash = @{} + $RuleParams.PSObject.Properties | ForEach-Object { $RuleHash[$_.Name] = $_.Value } + $RuleHash['Policy'] = $RequestParams.Name + if (-not $RuleHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($RuleHash['Name'])) { + $RuleHash['Name'] = "$($RequestParams.Name) Rule" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-RetentionComplianceRule' -cmdParams $RuleHash -Compliance -AsApp -useSystemMailbox $true + } + + "Successfully created Retention compliance policy $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Retention compliance policy $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create Retention compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create Retention compliance policy for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..79896013ae2d --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-AddRetentionCompliancePolicyTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddRetentionCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Comment, Enabled, RestrictiveRetention, ExchangeLocation, ExchangeLocationException, ModernGroupLocation, ModernGroupLocationException, OneDriveLocation, OneDriveLocationException, SharePointLocation, SharePointLocationException, SkypeLocation, SkypeLocationException, PublicFolderLocation, TeamsChannelLocation, TeamsChannelLocationException, TeamsChatLocation, TeamsChatLocationException, ApplyComplianceTag, RetentionDuration, RetentionAction, RetentionDurationDisplayHint, ExpirationDateOption, RuleParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'RetentionCompliancePolicyTemplate' + } + $Result = "Successfully created Retention Compliance Policy Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Retention Compliance Policy Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..72db204c7d83 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-EditRetentionCompliancePolicy.ps1 @@ -0,0 +1,46 @@ +Function Invoke-EditRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $State = $Request.Query.State ?? $Request.Body.State + + try { + $Params = @{ + Identity = $Identity + } + + if ($State) { + $Params['Enabled'] = ($State -eq 'enable' -or $State -eq $true -or $State -eq 'true') + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-RetentionCompliancePolicy' -cmdParams $Params -Compliance -AsApp -useSystemMailbox $true + $Result = "Updated Retention compliance policy $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating Retention compliance policy $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..1c5290932c8f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicy.ps1 @@ -0,0 +1,30 @@ +Function Invoke-ListRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionCompliancePolicy' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + $Rules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionComplianceRule' -Compliance -AsApp | Select-Object * -ExcludeProperty *odata*, *data.type* + $GraphRequest = $Policies | Select-Object *, + @{l = 'AssociatedRules'; e = { $name = $_.Name; @($Rules | Where-Object { $_.Policy -eq $name }) } }, + @{l = 'RuleCount'; e = { $name = $_.Name; (@($Rules | Where-Object { $_.Policy -eq $name })).Count } } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 new file mode 100644 index 000000000000..98dace219fbc --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-ListRetentionCompliancePolicyTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListRetentionCompliancePolicyTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.RetentionCompliancePolicy.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'RetentionCompliancePolicyTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 new file mode 100644 index 000000000000..a6061dc75730 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicy.ps1 @@ -0,0 +1,39 @@ +Function Invoke-RemoveRetentionCompliancePolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $ForceRemoval = $Request.Body.ForceDeletion -eq $true + + try { + $Params = @{ + Identity = $Identity + } + if ($ForceRemoval) { $Params['ForceDeletion'] = $true } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-RetentionCompliancePolicy' -cmdParams $Params -Compliance -AsApp -useSystemMailbox $true + $Result = "Deleted Retention compliance policy $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete Retention compliance policy $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 new file mode 100644 index 000000000000..69d7b60eca4e --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-Retention/Invoke-RemoveRetentionCompliancePolicyTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveRetentionCompliancePolicyTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.RetentionCompliancePolicy.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'RetentionCompliancePolicyTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Retention Compliance Policy template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Retention Compliance Policy template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 new file mode 100644 index 000000000000..b8d530757c05 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoType.ps1 @@ -0,0 +1,47 @@ +Function Invoke-AddSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + # New-DlpSensitiveInformationType expects FileData (byte array of XML rule pack) or specific simple parameters. + # We pass through whatever the user provided as JSON parameters. + $Params = @{} + $RequestParams.PSObject.Properties | ForEach-Object { + $Params[$_.Name] = $_.Value + } + + # If the template provided XML rule pack content as base64, decode it for FileData + if ($Params.ContainsKey('FileDataBase64') -and $Params['FileDataBase64']) { + $Params['FileData'] = [System.Convert]::FromBase64String($Params['FileDataBase64']) + $Params.Remove('FileDataBase64') + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + "Successfully created Sensitive Information Type $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Sensitive Information Type $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create Sensitive Information Type for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create Sensitive Information Type for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 new file mode 100644 index 000000000000..d4f18b501fb0 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-AddSensitiveInfoTypeTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddSensitiveInfoTypeTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, Description, Pattern, FileDataBase64, Locale, Publisher, Confidence, Recommended, RulePackVersion, Comment) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'SensitiveInfoTypeTemplate' + } + $Result = "Successfully created Sensitive Information Type Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Sensitive Information Type Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 new file mode 100644 index 000000000000..213ac25966fa --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-EditSensitiveInfoType.ps1 @@ -0,0 +1,41 @@ +Function Invoke-EditSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated Sensitive Information Type $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating Sensitive Information Type $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 new file mode 100644 index 000000000000..5ae4a340fe49 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoType.ps1 @@ -0,0 +1,33 @@ +Function Invoke-ListSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + $IncludeBuiltIn = ($Request.Query.IncludeBuiltIn -eq 'true' -or $Request.Query.IncludeBuiltIn -eq $true) + + try { + $SITs = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + + if (-not $IncludeBuiltIn) { + $SITs = $SITs | Where-Object { $_.Publisher -ne 'Microsoft Corporation' -and $_.Publisher -notlike 'Microsoft*' } + } + + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $SITs + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 new file mode 100644 index 000000000000..fa21108ce653 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListSensitiveInfoTypeTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.SensitiveInfoType.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'SensitiveInfoTypeTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 new file mode 100644 index 000000000000..3fa5502ee294 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitiveInfoType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted Sensitive Information Type $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete Sensitive Information Type $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 new file mode 100644 index 000000000000..23c88a9572a6 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoTypeTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitiveInfoTypeTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitiveInfoType.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'SensitiveInfoTypeTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Sensitive Information Type template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Sensitive Information Type template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 new file mode 100644 index 000000000000..9f47b293933f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabel.ps1 @@ -0,0 +1,49 @@ +Function Invoke-AddSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, comments, PolicyParams + $PolicyParams = ($Request.Body.PowerShellCommand | ConvertFrom-Json).PolicyParams + + $Tenants = ($Request.Body.selectedTenants).value + $Result = foreach ($TenantFilter in $Tenants) { + try { + $LabelParams = $RequestParams | Select-Object -Property * -ExcludeProperty PolicyParams + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-Label' -cmdParams $LabelParams -Compliance -useSystemMailbox $true + + if ($PolicyParams) { + $PolicyHash = @{} + $PolicyParams.PSObject.Properties | ForEach-Object { $PolicyHash[$_.Name] = $_.Value } + if (-not $PolicyHash.ContainsKey('Labels') -or -not $PolicyHash['Labels']) { + $PolicyHash['Labels'] = @($RequestParams.Name) + } + if (-not $PolicyHash.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($PolicyHash['Name'])) { + $PolicyHash['Name'] = "$($RequestParams.Name) Policy" + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-LabelPolicy' -cmdParams $PolicyHash -Compliance -useSystemMailbox $true + } + + "Successfully created sensitivity label $($RequestParams.Name) for $TenantFilter." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created sensitivity label $($RequestParams.Name) for $TenantFilter." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + "Could not create sensitivity label for $($TenantFilter): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not create sensitivity label for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{Results = @($Result) } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 new file mode 100644 index 000000000000..045a31fa3d80 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 @@ -0,0 +1,48 @@ +Function Invoke-AddSensitivityLabelTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $GUID = (New-Guid).GUID + $JSON = if ($Request.Body.PowerShellCommand) { + $Request.Body.PowerShellCommand | ConvertFrom-Json + } else { + ([pscustomobject]$Request.Body | Select-Object Name, DisplayName, Comment, Tooltip, ParentId, ContentType, EncryptionEnabled, EncryptionProtectionType, EncryptionRightsDefinitions, EncryptionContentExpiredOnDateInDaysOrNever, EncryptionDoNotForward, EncryptionEncryptOnly, EncryptionOfflineAccessDays, EncryptionPromptUser, EncryptionAESKeySize, ContentMarkingHeaderEnabled, ContentMarkingHeaderText, ContentMarkingHeaderFontSize, ContentMarkingHeaderFontColor, ContentMarkingHeaderAlignment, ContentMarkingFooterEnabled, ContentMarkingFooterText, ContentMarkingFooterFontSize, ContentMarkingFooterFontColor, ContentMarkingFooterAlignment, ContentMarkingWatermarkEnabled, ContentMarkingWatermarkText, ContentMarkingWatermarkFontSize, ContentMarkingWatermarkFontColor, ContentMarkingWatermarkLayout, ApplyContentMarkingHeaderEnabled, ApplyContentMarkingFooterEnabled, ApplyWaterMarkingEnabled, SiteAndGroupProtectionEnabled, SiteAndGroupProtectionPrivacy, SiteAndGroupProtectionAllowAccessToGuestUsers, SiteAndGroupProtectionAllowEmailFromGuestUsers, SiteAndGroupProtectionAllowFullAccess, SiteAndGroupProtectionAllowLimitedAccess, SiteAndGroupProtectionBlockAccess, Conditions, AdvancedSettings, PolicyParams) | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + } + + $JSON = ($JSON | Select-Object @{n = 'name'; e = { $_.Name ?? $_.name } }, @{n = 'comments'; e = { $_.Comment ?? $_.comments } }, * | ConvertTo-Json -Depth 10) + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$JSON" + RowKey = "$GUID" + PartitionKey = 'SensitivityLabelTemplate' + } + $Result = "Successfully created Sensitivity Label Template: $($Request.Body.Name ?? $Request.Body.name) with GUID $GUID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create Sensitivity Label Template: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 new file mode 100644 index 000000000000..acad97a18693 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 @@ -0,0 +1,41 @@ +Function Invoke-EditSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + + if ($Request.Body.parameters) { + $Request.Body.parameters.PSObject.Properties | ForEach-Object { + if ($_.Name -ne 'Identity') { $Params[$_.Name] = $_.Value } + } + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Updated sensitivity label $Identity" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed updating sensitivity label $Identity. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 new file mode 100644 index 000000000000..4c6947962223 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 @@ -0,0 +1,35 @@ +Function Invoke-ListSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $TenantFilter = $Request.Query.tenantFilter + + try { + $Labels = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Label' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + $Policies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-LabelPolicy' -Compliance | Select-Object * -ExcludeProperty *odata*, *data.type* + + $GraphRequest = $Labels | Select-Object *, + @{l = 'PublishedInPolicies'; e = { + $labelGuid = $_.Guid + @($Policies | Where-Object { $_.Labels -contains $labelGuid -or $_.Labels -contains $_.ImmutableId }) | Select-Object -ExpandProperty Name + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 new file mode 100644 index 000000000000..ab066a4eb6d5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabelTemplates.ps1 @@ -0,0 +1,26 @@ +Function Invoke-ListSensitivityLabelTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Security.SensitivityLabel.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'SensitivityLabelTemplate'" + $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $GUID = $_.RowKey + $data = $_.JSON | ConvertFrom-Json + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $GUID -Force + $data + } + + if ($Request.Query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.Query.ID } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 new file mode 100644 index 000000000000..225351276dc5 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabel.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitivityLabel { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + + try { + $Params = @{ + Identity = $Identity + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-Label' -cmdParams $Params -Compliance -useSystemMailbox $true + $Result = "Deleted sensitivity label $Identity" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete sensitivity label $Identity - $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{Results = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 new file mode 100644 index 000000000000..c8084265db52 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-RemoveSensitivityLabelTemplate.ps1 @@ -0,0 +1,36 @@ +Function Invoke-RemoveSensitivityLabelTemplate { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.SensitivityLabel.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Body.ID ?? $Request.Query.ID + try { + $Table = Get-CippTable -tablename 'templates' + $SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid + $Filter = "PartitionKey eq 'SensitivityLabelTemplate' and RowKey eq '$SafeID'" + $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $ClearRow + $Result = "Removed Sensitivity Label template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Sensitivity Label template $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) + +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 index dcf555008f41..f02dff9d052b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 @@ -101,10 +101,10 @@ function Invoke-ExecAddGDAPRole { if ($GroupName -in $ExistingGroups.displayName) { @{ PartitionKey = 'Roles' - RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName).id + RowKey = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id RoleName = $RoleName GroupName = $GroupName - GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName).id + GroupId = ($ExistingGroups | Where-Object -Property displayName -EQ $GroupName | Select-Object -First 1).id roleDefinitionId = $Value } $Results.Add("$GroupName already exists") diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 index 8a87edb67943..2ae766dda392 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 @@ -133,6 +133,15 @@ function Invoke-ListDomainHealth { } $Body = Test-MtaSts @HttpsQuery } + 'ReadAutoDiscover' { + $AutoDiscoverQuery = @{ + Domain = $Request.Query.Domain + } + if ($Request.Query.ExpectedTarget) { + $AutoDiscoverQuery.ExpectedTarget = $Request.Query.ExpectedTarget + } + $Body = Read-AutoDiscoverRecord @AutoDiscoverQuery + } } } else { $body = [pscustomobject]@{'Results' = "Domain: $($Request.Query.Domain) is invalid" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index 8693cdc0c835..2b79546c3e81 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 @@ -92,7 +92,8 @@ function Invoke-ListTenantAlignment { alignmentScore = $_.AlignmentScore LicenseMissingPercentage = $_.LicenseMissingPercentage combinedAlignmentScore = $_.CombinedScore - currentDeviationsCount = $_.CurrentDeviationsCount + pendingDeviationsCount = $_.PendingDeviationsCount + deniedDeviationsCount = $_.DeniedDeviationsCount latestDataCollection = $_.LatestDataCollection } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 index 5549e79b9bc1..688250583458 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAutoAddProxy.ps1 @@ -36,22 +36,43 @@ function Invoke-CIPPStandardAutoAddProxy { $QueueItem ) - try { - $Domains = New-ExoRequest -TenantId $Tenant -Cmdlet 'Get-AcceptedDomain' | Select-Object -ExpandProperty DomainName - $AllMailboxes = New-ExoRequest -TenantId $Tenant -Cmdlet 'Get-Mailbox' - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the AutoAddProxy state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $TestResult = Test-CIPPStandardLicense -StandardName 'AutoArchive' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { + return $true + } + + # Re-run protection — skip if already executed within the last 24 hours + $Rerun = Test-CIPPRerun -Tenant $Tenant -API 'AutoAddProxy' -Interval 86400 + if ($Rerun) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'AutoAddProxy recently executed. Skipping to prevent duplicate execution.' -sev Debug + return $true + } + + # Use the reporting DB cache for both accepted domains and mailboxes + $Domains = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'ExoAcceptedDomains' | Select-Object -ExpandProperty DomainName) + if ($Domains.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No cached accepted domains found. Ensure the ExoAcceptedDomains cache has been populated.' -sev Error + return + } + + $AllMailboxes = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'Mailboxes') + if ($AllMailboxes.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No cached mailboxes found. Ensure the mailbox cache has been populated.' -sev Error return } + # Build a list of all email addresses per mailbox from the cache fields + # Cache stores: primarySmtpAddress, AdditionalEmailAddresses (comma-separated lowercase smtp aliases) $MissingProxies = 0 foreach ($Domain in $Domains) { - $ProcessMailboxes = $AllMailboxes | Where-Object { - $addresses = @($_.EmailAddresses) -replace '^[^:]+:' # remove SPO:, SMTP:, etc. - $hasDomain = $addresses | Where-Object { $_ -like "*@$Domain" } - if ($hasDomain) { return $false } else { return $true } - } + $ProcessMailboxes = @($AllMailboxes | Where-Object { + $AllAddresses = @($_.primarySmtpAddress) + if (-not [string]::IsNullOrWhiteSpace($_.AdditionalEmailAddresses)) { + $AllAddresses += @($_.AdditionalEmailAddresses -split ',\s*') + } + $HasDomain = $AllAddresses | Where-Object { $_ -like "*@$Domain" } + -not $HasDomain + }) $MissingProxies += $ProcessMailboxes.Count } @@ -63,7 +84,6 @@ function Invoke-CIPPStandardAutoAddProxy { MissingProxies = $MissingProxies } - if ($Settings.report -eq $true) { Set-CIPPStandardsCompareField -FieldName 'standards.AutoAddProxy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'AutoAddProxy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant @@ -83,19 +103,25 @@ function Invoke-CIPPStandardAutoAddProxy { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All mailboxes already have proxy addresses for all domains' -sev Info } else { foreach ($Domain in $Domains) { - $ProcessMailboxes = $AllMailboxes | Where-Object { - $addresses = @($_.EmailAddresses) -replace '^[^:]+:' # remove SPO:, SMTP:, etc. - $hasDomain = $addresses | Where-Object { $_ -like "*@$Domain" } - if ($hasDomain) { return $false } else { return $true } - } + $ProcessMailboxes = @($AllMailboxes | Where-Object { + $AllAddresses = @($_.primarySmtpAddress) + if (-not [string]::IsNullOrWhiteSpace($_.AdditionalEmailAddresses)) { + $AllAddresses += @($_.AdditionalEmailAddresses -split ',\s*') + } + $HasDomain = $AllAddresses | Where-Object { $_ -like "*@$Domain" } + -not $HasDomain + }) $bulkRequest = foreach ($Mailbox in $ProcessMailboxes) { - $LocalPart = $Mailbox.UserPrincipalName -split '@' | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($Mailbox.UPN)) { continue } + $LocalPart = $Mailbox.UPN -split '@' | Select-Object -First 1 $NewAlias = "$LocalPart@$Domain" @{ CmdletInput = @{ CmdletName = 'Set-Mailbox' - Parameters = @{Identity = $Mailbox.Identity ; EmailAddresses = @{ + Parameters = @{ + Identity = $Mailbox.UPN + EmailAddresses = @{ '@odata.type' = '#Exchange.GenericHashTable' Add = "smtp:$NewAlias" } @@ -103,11 +129,13 @@ function Invoke-CIPPStandardAutoAddProxy { } } } - $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($bulkRequest) - foreach ($Result in $BatchResults) { - if ($Result.error) { - $ErrorMessage = Get-CippException -Exception $Result.error - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to apply proxy address to $($Result.error.target) Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if ($bulkRequest) { + $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($bulkRequest) + foreach ($Result in $BatchResults) { + if ($Result.error) { + $ErrorMessage = Get-CippException -Exception $Result.error + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to apply proxy address to $($Result.error.target) Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } } } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 new file mode 100644 index 000000000000..742c67aa89df --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCollaborationDomainRestriction.ps1 @@ -0,0 +1,118 @@ +function Invoke-CIPPStandardCollaborationDomainRestriction { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) CollaborationDomainRestriction + .SYNOPSIS + (Label) Restrict collaboration invitations to allowed domains only + .DESCRIPTION + (Helptext) Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured. + (DocsDescription) By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list. + .NOTES + CAT + Entra (AAD) Standards + TAG + "CIS M365 6.0.1 (5.1.6.1)" + EXECUTIVETEXT + Restricts external collaboration invitations to approved domains only, preventing users from sharing data with unapproved external organizations. This reduces the risk of data exfiltration and ensures that collaboration occurs only with trusted business partners. + ADDEDCOMPONENT + {"type":"textField","name":"standards.CollaborationDomainRestriction.allowedDomains","label":"Allowed domains (comma separated)","required":false,"placeholder":"contoso.com, fabrikam.com"} + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $Uri = 'https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default' + + try { + $CurrentState = New-GraphGetRequest -Uri $Uri -tenantid $Tenant + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Could not get B2B management policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $CurrentDomains = $CurrentState.invitationsAllowedAndBlockedDomainsPolicy + $HasRestrictions = $CurrentDomains -and ( + ($CurrentDomains.allowedDomains -and $CurrentDomains.allowedDomains.Count -gt 0) -or + ($CurrentDomains.blockedDomains -and $CurrentDomains.blockedDomains.Count -gt 0) + ) + + $DesiredDomains = @() + if ($Settings.allowedDomains) { + $DesiredDomains = @($Settings.allowedDomains -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + } + + if ($DesiredDomains.Count -gt 0) { + $CurrentAllowed = @($CurrentDomains.allowedDomains | Sort-Object) + $DesiredSorted = @($DesiredDomains | Sort-Object) + $StateIsCorrect = ($null -ne $CurrentDomains) -and ($CurrentAllowed -join ',') -eq ($DesiredSorted -join ',') + } else { + $StateIsCorrect = $HasRestrictions + } + + if ($Settings.remediate -eq $true) { + if ($DesiredDomains.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No allowed domains specified for CollaborationDomainRestriction. Skipping remediation.' -sev Info + } elseif ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'B2B collaboration domain restrictions are already configured correctly.' -sev Info + } else { + try { + $Body = @{ + invitationsAllowedAndBlockedDomainsPolicy = @{ + allowedDomains = $DesiredDomains + } + } | ConvertTo-Json -Depth 10 -Compress + + $null = New-GraphPostRequest -Uri $Uri -Body $Body -TenantID $Tenant -Type PATCH -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully set B2B collaboration allowed domains to: $($DesiredDomains -join ', ')" -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set B2B collaboration domain restrictions. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'B2B collaboration domain restrictions are configured.' -sev Info + } else { + $AlertMsg = if ($DesiredDomains.Count -gt 0) { + "B2B collaboration allowed domains do not match expected list: $($DesiredDomains -join ', ')" + } else { + 'B2B collaboration invitations are not restricted by domain allow/block list' + } + Write-StandardsAlert -message $AlertMsg -object $CurrentDomains -tenant $Tenant -standardName 'CollaborationDomainRestriction' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = @{ + hasRestrictions = $HasRestrictions + allowedDomains = $CurrentDomains.allowedDomains -join ', ' + blockedDomains = $CurrentDomains.blockedDomains -join ', ' + } + $ExpectedValue = @{ + hasRestrictions = $true + } + if ($DesiredDomains.Count -gt 0) { + $ExpectedValue.allowedDomains = $DesiredDomains -join ', ' + } + + Set-CIPPStandardsCompareField -FieldName 'standards.CollaborationDomainRestriction' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'CollaborationDomainRestriction' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index 5a6ce39e07a6..43c37afa8fa4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -60,13 +60,25 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { }) } + try { + $AuthPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant + $AllowEmailSubscriptions = if ($AuthPolicy.allowedToSignUpEmailBasedSubscriptions) { 'Enabled' } else { 'Disabled' } + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = $AllowEmailSubscriptions + }) + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve authorization policy: $($_.Exception.Message)" -sev Error + } + if ($Settings.DisableTrials) { try { $AutoClaimPolicy = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = $AutoClaimPolicy.policyValue + policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy: $($_.Exception.Message)" -sev Error @@ -99,6 +111,12 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { }) } + $ExpectedValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = 'Disabled' + }) + if ($settings.remediate) { $Compare = Compare-Object -ReferenceObject $ExpectedValues -DifferenceObject $CurrentValues -Property productName, productId, policyValue @@ -119,6 +137,9 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { if ($Item.productId -eq 'autoclaim') { New-GraphPostRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' -Body $body + } elseif ($Item.productId -eq 'allowedToSignUpEmailBasedSubscriptions') { + $authBody = @{ allowedToSignUpEmailBasedSubscriptions = $false } | ConvertTo-Json -Compress + New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant -body $authBody -type PATCH } else { New-GraphPOSTRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -uri "https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products/$($Item.productId)" -tenantid $Tenant -body $body -type PUT } @@ -139,13 +160,25 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { policyValue = $Item.policyValue }) } + try { + $AuthPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -tenantid $Tenant + $AllowEmailSubscriptions = if ($AuthPolicy.allowedToSignUpEmailBasedSubscriptions) { 'Enabled' } else { 'Disabled' } + $CurrentValues.Add([PSCustomObject]@{ + productName = 'Email Based Subscriptions' + productId = 'allowedToSignUpEmailBasedSubscriptions' + policyValue = $AllowEmailSubscriptions + }) + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve authorization policy after remediation: $($_.Exception.Message)" -sev Error + } + if ($Settings.DisableTrials) { try { $AutoClaimPolicy = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' $CurrentValues.Add([PSCustomObject]@{ productName = 'Trial Autoclaim' productId = 'autoclaim' - policyValue = $AutoClaimPolicy.policyValue + policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' }) } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve trial autoclaim policy after remediation: $($_.Exception.Message)" -sev Error diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 new file mode 100644 index 000000000000..834a84e735ec --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEmptyFilterIPAllowList.ps1 @@ -0,0 +1,93 @@ +function Invoke-CIPPStandardEmptyFilterIPAllowList { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) EmptyFilterIPAllowList + .SYNOPSIS + (Label) Ensure connection filter IP allow list is empty + .DESCRIPTION + (Helptext) Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks. + (DocsDescription) IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it. + .NOTES + CAT + Defender Standards + TAG + "CIS M365 6.0.1 (2.1.12)" + EXECUTIVETEXT + Ensures the Exchange Online connection filter IP allow list is empty, preventing any IP addresses from bypassing spam filtering, spoofing checks, and sender authentication. Keeping this list empty ensures all inbound email undergoes full security scanning, reducing the risk of phishing and malware delivery through trusted-but-compromised sources. + ADDEDCOMPONENT + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @() + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "EXCHANGE_S_STANDARD" + "EXCHANGE_S_ENTERPRISE" + "EXCHANGE_S_STANDARD_GOV" + "EXCHANGE_S_ENTERPRISE_GOV" + "EXCHANGE_LITE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'EmptyFilterIPAllowList' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { return $true } + + try { + $CurrentState = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-HostedConnectionFilterPolicy' -cmdParams @{ Identity = 'Default' }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "EmptyFilterIPAllowList: Failed to get connection filter policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $IPAllowList = @($CurrentState.IPAllowList) + $StateIsCorrect = ($IPAllowList.Count -eq 0) + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Connection filter IP allow list is already empty.' -sev Info + } else { + try { + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-HostedConnectionFilterPolicy' -cmdParams @{ + Identity = 'Default' + IPAllowList = @() + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cleared connection filter IP allow list. Removed: $($IPAllowList -join ', ')" -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to clear connection filter IP allow list. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Connection filter IP allow list is empty.' -sev Info + } else { + Write-StandardsAlert -message "Connection filter IP allow list is not empty. Current entries: $($IPAllowList -join ', ')" -object @{ IPAllowList = $IPAllowList } -tenant $Tenant -standardName 'EmptyFilterIPAllowList' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + IPAllowListEmpty = $StateIsCorrect + IPAllowList = ($IPAllowList -join ', ') + } + $ExpectedValue = [PSCustomObject]@{ + IPAllowListEmpty = $true + IPAllowList = '' + } + Set-CIPPStandardsCompareField -FieldName 'standards.EmptyFilterIPAllowList' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'EmptyFilterIPAllowList' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 new file mode 100644 index 000000000000..6318cb73f1c1 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnforcePrivateGroups.ps1 @@ -0,0 +1,126 @@ +function Invoke-CIPPStandardEnforcePrivateGroups { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) EnforcePrivateGroups + .SYNOPSIS + (Label) Enforce Private M365 Groups + .DESCRIPTION + (Helptext) Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword. + (DocsDescription) Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 6.0.1 benchmark control 1.2.1. + .NOTES + CAT + Entra (AAD) Standards + TAG + "CIS M365 6.0.1 (1.2.1)" + EXECUTIVETEXT + Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces. + ADDEDCOMPONENT + {"type":"autoComplete","name":"standards.EnforcePrivateGroups.ExcludedGroupNames","label":"Exclude groups by display name keyword","multiple":true,"creatable":true,"required":false} + IMPACT + Medium Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Update-MgGroup -GroupId -Visibility Private + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "SHAREPOINTWAC" + "SHAREPOINTSTANDARD" + "SHAREPOINTENTERPRISE" + "SHAREPOINTENTERPRISE_EDU" + "SHAREPOINTENTERPRISE_GOV" + "ONEDRIVE_BASIC" + "ONEDRIVE_ENTERPRISE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'EnforcePrivateGroups' -TenantFilter $Tenant ` + -RequiredCapabilities @('SHAREPOINTWAC', 'SHAREPOINTSTANDARD', 'SHAREPOINTENTERPRISE', 'SHAREPOINTENTERPRISE_EDU', 'SHAREPOINTENTERPRISE_GOV', 'ONEDRIVE_BASIC', 'ONEDRIVE_ENTERPRISE') + if ($TestResult -eq $false) { return $true } + + # Parse exclusion keywords from settings + $ExcludeKeywords = @( + @($Settings.ExcludedGroupNames) | ForEach-Object { + if ($_ -is [string]) { $_ } else { [string]($_.value ?? $_.label) } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + + # Get all M365 (Unified) groups + try { + $AllGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=groupTypes/any(c:c eq 'Unified')&`$select=id,displayName,visibility&`$top=999" -tenantid $Tenant) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -message "EnforcePrivateGroups: Could not retrieve groups. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + # Identify public groups, excluding any that match exclusion keywords + $PublicGroups = foreach ($Group in $AllGroups) { + if ($Group.visibility -ne 'Public') { continue } + $IsExcluded = $false + foreach ($Keyword in $ExcludeKeywords) { + if ($Group.displayName -match [regex]::Escape($Keyword)) { + $IsExcluded = $true + break + } + } + if (-not $IsExcluded) { $Group } + } + + $StateIsCorrect = ($PublicGroups.Count -eq 0) + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All M365 groups are already private (or excluded).' -sev Info + } else { + $SuccessCount = 0 + $FailCount = 0 + foreach ($Group in $PublicGroups) { + try { + $Body = @{ visibility = 'Private' } | ConvertTo-Json -Compress -Depth 10 + New-GraphPostRequest -tenantid $Tenant -Uri "https://graph.microsoft.com/beta/groups/$($Group.id)" ` + -Type PATCH -Body $Body -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Set group '$($Group.displayName)' to Private." -sev Info + $SuccessCount++ + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set group '$($Group.displayName)' to Private: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $FailCount++ + } + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "EnforcePrivateGroups: Remediated $SuccessCount group(s), $FailCount failure(s)." -sev Info + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All M365 groups are private (or excluded).' -sev Info + } else { + $GroupNames = ($PublicGroups | Select-Object -ExpandProperty displayName) -join ', ' + Write-StandardsAlert -message "The following M365 groups are public and not excluded: $GroupNames" ` + -object ($PublicGroups | Select-Object id, displayName, visibility) ` + -tenant $Tenant -standardName 'EnforcePrivateGroups' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + PublicGroupCount = @($PublicGroups).Count + PublicGroups = ($PublicGroups | Select-Object -ExpandProperty displayName) -join ', ' + } + $ExpectedValue = [PSCustomObject]@{ + PublicGroupCount = 0 + PublicGroups = '' + } + Set-CIPPStandardsCompareField -FieldName 'standards.EnforcePrivateGroups' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'EnforcePrivateGroups' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index bb1cf5dd4ca6..7af9715f79db 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -75,6 +75,28 @@ function Invoke-CIPPStandardIntuneTemplate { $RawJSON = $rawJsonFromTemplate $TemplateType = $Template.Type + # Fallback: infer type from RAWJson content when stored template has no Type + if (-not $TemplateType) { + try { + $parsedRaw = $rawJsonFromTemplate | ConvertFrom-Json -ErrorAction SilentlyContinue + $odataType = $parsedRaw.'@odata.type' + $TemplateType = if ($null -ne $parsedRaw.settings -and $null -ne $parsedRaw.technologies) { 'Catalog' } + elseif ($null -ne $parsedRaw.scheduledActionsForRule -or $odataType -match 'CompliancePolicy') { 'deviceCompliancePolicies' } + elseif ($odataType -match 'windowsDriverUpdateProfile') { 'windowsDriverUpdateProfiles' } + elseif ($odataType -match 'ManagedApp|managedAppProtection') { 'AppProtection' } + elseif ($odataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { 'Device' } + else { $null } + } catch { + $TemplateType = $null + } + if ($TemplateType) { + Write-Information "[IntuneTemplate][$Tenant] Inferred template type '$TemplateType' from content for '$displayname'" + } else { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Intune Template '$displayname' has no Type and type could not be inferred. Re-import the template to fix." -sev 'Error' + return $true + } + } + $AssignmentsMatch = $null try { $ExistingPolicy = Get-CIPPIntunePolicy -tenantFilter $Tenant -DisplayName $displayname -TemplateType $TemplateType diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 index a8036b8ea184..0b27c0a250b3 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 @@ -106,13 +106,13 @@ function Invoke-CIPPStandardMailContacts { } if ($Settings.report -eq $true) { $CurrentValue = @{ - marketingNotificationEmails = @($CurrentInfo.marketingNotificationEmails | Sort-Object) - technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Sort-Object) + marketingNotificationEmails = @($CurrentInfo.marketingNotificationEmails | Sort-Object -Unique) + technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $CurrentInfo.privacyProfile.contactEmail } $ExpectedValue = @{ - marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object) - technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { $_ -ne $null } | Select-Object -Unique | Sort-Object) + marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object -Unique) + technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $Contacts.GeneralContact } Set-CIPPStandardsCompareField -FieldName 'standards.MailContacts' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 index 978103c47d88..661ee92e0760 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 @@ -220,7 +220,7 @@ function Invoke-CIPPStandardMalwareFilterPolicy { Name = $CurrentState.Name EnableFileFilter = $CurrentState.EnableFileFilter FileTypeAction = $CurrentState.FileTypeAction - FileTypes = $CurrentState.FileTypes + FileTypes = @($CurrentState.FileTypes | Sort-Object) ZapEnabled = $CurrentState.ZapEnabled QuarantineTag = $CurrentState.QuarantineTag EnableInternalSenderAdminNotifications = $CurrentState.EnableInternalSenderAdminNotifications @@ -238,7 +238,7 @@ function Invoke-CIPPStandardMalwareFilterPolicy { Name = $PolicyName EnableFileFilter = $true FileTypeAction = $FileTypeAction - FileTypes = $ExpectedFileTypes + FileTypes = @($ExpectedFileTypes | Sort-Object) ZapEnabled = $true QuarantineTag = $Settings.QuarantineTag EnableInternalSenderAdminNotifications = $Settings.EnableInternalSenderAdminNotifications diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 index 099d196ac80e..11cba32e784f 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPFileRequests.ps1 @@ -48,8 +48,15 @@ function Invoke-CIPPStandardSPFileRequests { return $true } + $SharingCapabilityEnum = @{ + 0L = 'Disabled' + 1L = 'External Users Only' + 2L = 'External Users and Guests (Anyone)' + 3L = 'Existing External Users Only' + } + try { - $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object _ObjectIdentity_, TenantFilter, CoreRequestFilesLinkEnabled, OneDriveRequestFilesLinkEnabled, CoreRequestFilesLinkExpirationInDays, OneDriveRequestFilesLinkExpirationInDays + $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object _ObjectIdentity_, TenantFilter, CoreRequestFilesLinkEnabled, OneDriveRequestFilesLinkEnabled, CoreRequestFilesLinkExpirationInDays, OneDriveRequestFilesLinkExpirationInDays, SharingCapability } catch { Write-LogMessage -API 'Standards' -tenant $tenant -message 'Failed to get current state of SPO tenant details' -sev Error return @@ -61,8 +68,8 @@ function Invoke-CIPPStandardSPFileRequests { return } - $WantedState = $Settings.state - $ExpirationDays = $Settings.expirationDays + $WantedState = [bool]$Settings.state + $ExpirationDays = if ($null -ne $Settings.expirationDays) { [int]$Settings.expirationDays } else { $null } $HumanReadableState = if ($WantedState -eq $true) { 'enabled' } else { 'disabled' } # Check if current state matches desired state @@ -83,25 +90,39 @@ function Invoke-CIPPStandardSPFileRequests { if ($Settings.remediate -eq $true) { if ($AllSettingsCorrect -eq $false) { - try { - $Properties = @{ - CoreRequestFilesLinkEnabled = $WantedState - OneDriveRequestFilesLinkEnabled = $WantedState - } - - # Add expiration settings if specified and feature is being enabled - if ($null -ne $ExpirationDays -and $WantedState -eq $true) { - $Properties['CoreRequestFilesLinkExpirationInDays'] = $ExpirationDays - $Properties['OneDriveRequestFilesLinkExpirationInDays'] = $ExpirationDays + if ($CurrentState.SharingCapability -ne 2) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot set File Requests to $HumanReadableState because the Tenant sharing level is set to $($SharingCapabilityEnum[$CurrentState.SharingCapability]). The sharing level must be set to 'External Users and Guests (Anyone)' to remediate this standard. There may be a conflicting standard preventing this." -sev Error + } else { + try { + $Properties = @{ + CoreRequestFilesLinkEnabled = $WantedState + OneDriveRequestFilesLinkEnabled = $WantedState + } + + # Add expiration settings if specified and feature is being enabled + if ($null -ne $ExpirationDays -and $WantedState -eq $true) { + $Properties['CoreRequestFilesLinkExpirationInDays'] = $ExpirationDays + $Properties['OneDriveRequestFilesLinkExpirationInDays'] = $ExpirationDays + } + + $CurrentState | Set-CIPPSPOTenant -Properties $Properties + + # Reflect the just-applied state in-memory so the report block does not write + # the pre-remediation values into the drift compare field. + $CurrentState.CoreRequestFilesLinkEnabled = $WantedState + $CurrentState.OneDriveRequestFilesLinkEnabled = $WantedState + if ($null -ne $ExpirationDays -and $WantedState -eq $true) { + $CurrentState.CoreRequestFilesLinkExpirationInDays = $ExpirationDays + $CurrentState.OneDriveRequestFilesLinkExpirationInDays = $ExpirationDays + } + + $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } + Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set File Requests to $HumanReadableState$ExpirationMessage" -sev Info + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set File Requests to $HumanReadableState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } - - $CurrentState | Set-CIPPSPOTenant -Properties $Properties - - $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } - Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set File Requests to $HumanReadableState$ExpirationMessage" -sev Info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set File Requests to $HumanReadableState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } else { $ExpirationMessage = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { " with $ExpirationDays day expiration" } else { '' } @@ -138,12 +159,14 @@ function Invoke-CIPPStandardSPFileRequests { OneDriveRequestFilesLinkEnabled = $CurrentState.OneDriveRequestFilesLinkEnabled CoreRequestFilesLinkExpirationInDays = $CurrentState.CoreRequestFilesLinkExpirationInDays OneDriveRequestFilesLinkExpirationInDays = $CurrentState.OneDriveRequestFilesLinkExpirationInDays + SharingCapability = $SharingCapabilityEnum[$CurrentState.SharingCapability] } $ExpectedValue = @{ CoreRequestFilesLinkEnabled = $WantedState OneDriveRequestFilesLinkEnabled = $WantedState CoreRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } OneDriveRequestFilesLinkExpirationInDays = if ($null -ne $ExpirationDays -and $WantedState -eq $true) { $ExpirationDays } else { $null } + SharingCapability = 'External Users and Guests (Anyone)' } Set-CIPPStandardsCompareField -FieldName 'standards.SPFileRequests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 index 04527ce524d6..132389c0f15f 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSafeLinksPolicy.ps1 @@ -231,7 +231,7 @@ function Invoke-CIPPStandardSafeLinksPolicy { DeliverMessageAfterScan = $CurrentState.DeliverMessageAfterScan DisableUrlRewrite = $CurrentState.DisableUrlRewrite EnableOrganizationBranding = $CurrentState.EnableOrganizationBranding - DoNotRewriteUrls = $CurrentState.DoNotRewriteUrls + DoNotRewriteUrls = @($CurrentState.DoNotRewriteUrls | Sort-Object) } $ExpectedValue = @{ Name = $PolicyName @@ -245,7 +245,7 @@ function Invoke-CIPPStandardSafeLinksPolicy { DeliverMessageAfterScan = $true DisableUrlRewrite = $Settings.DisableUrlRewrite EnableOrganizationBranding = $Settings.EnableOrganizationBranding - DoNotRewriteUrls = $Settings.DoNotRewriteUrls.value ?? @() + DoNotRewriteUrls = @(($Settings.DoNotRewriteUrls.value ?? @()) | Sort-Object) } Set-CIPPStandardsCompareField -FieldName 'standards.SafeLinksPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 index 6fa95268d1cc..3ef10d9e5e15 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpoofWarn.ps1 @@ -58,6 +58,9 @@ function Invoke-CIPPStandardSpoofWarn { return } + # Sanitize AllowList — the API may return @('') instead of @() for an empty list + $CurrentInfo.AllowList = @($CurrentInfo.AllowList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + # Get state value using null-coalescing operator $state = $Settings.state.value ?? $Settings.state diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index ab245b7fd939..425aa552a2b6 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -75,7 +75,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $AllowedDomains = $null $BlockedDomains = @() if ($null -ne $Settings.DomainList) { - $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() + $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() | Sort-Object } else { $AllowedDomainsAsAList = @() } @@ -85,7 +85,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $AllowedDomains = $AllowAllKnownDomains $AllowedDomainsAsAList = @() if ($null -ne $Settings.DomainList) { - $BlockedDomains = @($Settings.DomainList).Split(',').Trim() + $BlockedDomains = @($Settings.DomainList).Split(',').Trim() | Sort-Object } else { $BlockedDomains = @() } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 index e84885986aee..e093179116f2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 @@ -91,7 +91,6 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { if ($Settings.report -eq $true) { Add-CIPPBPAField -FieldName 'TeamsMeetingRecordingExpiration' -FieldValue $CurrentExpirationDays -StoreAs string -Tenant $Tenant - $CurrentExpirationDays = if ($StateIsCorrect) { $true } else { $CurrentExpirationDays } $CurrentValue = @{ MeetingRecordingExpirationDays = $CurrentExpirationDays } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 new file mode 100644 index 000000000000..3b8b0ea9b22d --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsZAP.ps1 @@ -0,0 +1,90 @@ +function Invoke-CIPPStandardTeamsZAP { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) TeamsZAP + .SYNOPSIS + (Label) Ensure Zero-hour auto purge for Microsoft Teams is on + .DESCRIPTION + (Helptext) Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery. + (DocsDescription) Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 6.0.1 benchmark control 2.4.4. + .NOTES + CAT + Defender Standards + TAG + "CIS M365 6.0.1 (2.4.4)" + EXECUTIVETEXT + Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users. + ADDEDCOMPONENT + IMPACT + Low Impact + ADDEDDATE + 2026-05-06 + POWERSHELLEQUIVALENT + Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true + RECOMMENDEDBY + "CIS" + REQUIREDCAPABILITIES + "EXCHANGE_S_STANDARD" + "EXCHANGE_S_ENTERPRISE" + "EXCHANGE_S_STANDARD_GOV" + "EXCHANGE_S_ENTERPRISE_GOV" + "EXCHANGE_LITE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'TeamsZAP' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_S_STANDARD_GOV', 'EXCHANGE_S_ENTERPRISE_GOV', 'EXCHANGE_LITE') + if ($TestResult -eq $false) { return $true } + + try { + $CurrentState = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TeamsProtectionPolicy' -cmdParams @{ Identity = 'Teams Protection Policy' }).ZapEnabled + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "TeamsZAP: Failed to get Teams Protection Policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $StateIsCorrect = $CurrentState -eq $true + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Teams ZAP is already enabled.' -sev Info + } else { + try { + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-TeamsProtectionPolicy' -cmdParams @{ + Identity = 'Teams Protection Policy' + ZapEnabled = $true + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Successfully enabled Teams ZAP.' -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable Teams ZAP. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Teams ZAP is enabled.' -sev Info + } else { + Write-StandardsAlert -message 'Teams Zero-hour auto purge (ZAP) is not enabled.' -object @{ ZapEnabled = $CurrentState } -tenant $Tenant -standardName 'TeamsZAP' -standardId $Settings.standardId + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = [PSCustomObject]@{ + ZapEnabled = $CurrentState + } + $ExpectedValue = [PSCustomObject]@{ + ZapEnabled = $true + } + Set-CIPPStandardsCompareField -FieldName 'standards.TeamsZAP' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'TeamsZAP' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 index 38ab5cedcbbc..c3d4522eacd2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTenantAllowBlockListTemplate.ps1 @@ -63,16 +63,42 @@ function Invoke-CIPPStandardTenantAllowBlockListTemplate { }) if ($Settings.remediate -eq $true) { + # Track entries submitted across templates to handle overlapping entries without relying on Exchange replication + $SubmittedEntries = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.HashSet[string]]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($TemplateData in $ResolvedTemplates) { try { $Entries = @($TemplateData.entries -split '[,;]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + $ListType = [string]$TemplateData.listType + + # Get existing entries to avoid duplicate errors that block the entire batch + if (-not $SubmittedEntries.ContainsKey($ListType)) { + $SubmittedEntries[$ListType] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + try { + $ExistingItems = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TenantAllowBlockListItems' -cmdParams @{ + ListType = $ListType + } + foreach ($Item in @($ExistingItems)) { + [void]$SubmittedEntries[$ListType].Add($Item.Value) + } + } catch { + # If we can't fetch existing items, continue with empty set + } + } + + $NewEntries = @($Entries | Where-Object { -not $SubmittedEntries[$ListType].Contains($_) }) + + if ($NewEntries.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "All entries from Tenant Allow/Block List template '$($TemplateData.templateName)' already exist for $Tenant" -sev 'Info' + continue + } $ExoParams = @{ tenantid = $Tenant cmdlet = 'New-TenantAllowBlockListItems' cmdParams = @{ - Entries = $Entries - ListType = [string]$TemplateData.listType + Entries = $NewEntries + ListType = $ListType Notes = [string]$TemplateData.notes $TemplateData.listMethod = [bool]$true } @@ -85,14 +111,13 @@ function Invoke-CIPPStandardTenantAllowBlockListTemplate { } New-ExoRequest @ExoParams - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully deployed Tenant Allow/Block List template '$($TemplateData.templateName)' with entries: $($TemplateData.entries)" -sev 'Info' + foreach ($Entry in $NewEntries) { + [void]$SubmittedEntries[$ListType].Add($Entry) + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully deployed Tenant Allow/Block List template '$($TemplateData.templateName)' with entries: $($NewEntries -join ', ')" -sev 'Info' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - if ($ErrorMessage -like '*already exists*') { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Tenant Allow/Block List entries from template '$($TemplateData.templateName)' already exist for $Tenant" -sev 'Info' - } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy Tenant Allow/Block List template '$($TemplateData.templateName)' for $Tenant. Error: $ErrorMessage" -sev 'Error' - } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy Tenant Allow/Block List template '$($TemplateData.templateName)' for $Tenant. Error: $ErrorMessage" -sev 'Error' } } } diff --git a/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md b/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md deleted file mode 100644 index 5b7bd652195a..000000000000 --- a/Modules/CIPPTests/Public/Tests/CISA-Missing-Caches.md +++ /dev/null @@ -1,195 +0,0 @@ -# Missing CIPP Caches for CISA Tests - -This document lists the caches that need to be created to support the remaining CISA tests that cannot currently be implemented. - -## ✅ Implemented Cache Functions - -### 1. ✅ CASMailbox Cache -**Required For:** -- ✅ MS.EXO.5.1 - SMTP Authentication - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheCASMailbox.ps1 - ---- - -### 2. ✅ ExoSharingPolicy Cache -**Required For:** -- ✅ MS.EXO.6.1 - Contact Sharing -- ✅ MS.EXO.6.2 - Calendar Sharing - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoSharingPolicy.ps1 - ---- - -### 3. ✅ ExoAdminAuditLogConfig Cache -**Required For:** -- ✅ MS.EXO.17.1 - Audit Log -- ✅ MS.EXO.17.3 - Audit Log Retention - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 - ---- - -### 4. ✅ ExoPresetSecurityPolicy Cache -**Required For:** -- ✅ MS.EXO.11.1 - Impersonation -- ✅ MS.EXO.11.2 - Impersonation Tips -- ✅ MS.EXO.11.3 - Mailbox Intelligence - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 - ---- - -### 5. ✅ ExoTenantAllowBlockList Cache -**Required For:** -- ✅ MS.EXO.12.1 - Anti-Spam Allow List - -**Status**: ✅ IMPLEMENTED in Set-CIPPDBCacheExoTenantAllowBlockList.ps1 - ---- - -## Required New Cache Functions -**Required For:** -- MS.EXO.8.1 - DLP Solution -- MS.EXO.8.2 - DLP PII -- MS.EXO.8.4 - DLP Baseline Rules - -**SecurityCompliance Command:** -```powershell -Get-DlpCompliancePolicy | Select-Object Name, Enabled, Mode -Get-DlpComplianceRule | Select-Object Name, ParentPolicyName, ContentContainsSensitiveInformation, Disabled -``` -1 -**Cache Function Names:** -- `Set-CIPPDBCacheSccDlpPolicy` -- `Set-CIPPDBCacheSccDlpRule` - -**Properties Needed:** -- Policy: Name, Enabled, Mode -- Rule: Name, ParentPolicyName, ContentContainsSensitiveInformation, Disabled - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -### 2. SecurityCompliance ProtectionAlert Cache -**Required For:** -- MS.EXO.16.1 - Alerts - -**SecurityCompliance Command:** -```powershell -Get-ProtectionAlert | Select-Object Name, Disabled -``` - -**Cache Function Name:** `Set-CIPPDBCacheSccProtectionAlert` - -**Properties Needed:** -- Name -- Disabled - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -### 3. SecurityCompliance ActivityAlert Cache -**Required For:** -- MS.EXO.16.2 - Alert SIEM - -**SecurityCompliance Command:** -```powershell -Get-ActivityAlert | Select-Object Name, Disabled, NotificationEnabled, Type -``` - -**Cache Function Name:** `Set-CIPPDBCacheSccActivityAlert` - -**Properties Needed:** -- Name -- Disabled -- NotificationEnabled -- Type - -**Note:** Requires SecurityCompliance PowerShell connection - ---- - -## DNS-Based Tests (Cannot Be Cached) - -These tests require external DNS lookups and cannot be implemented with cached Exchange data: - -### MS.EXO.2.1 - SPF Restriction -**Requires:** DNS TXT record lookup for SPF -**Query:** `nslookup -type=txt ` - -### MS.EXO.2.2 - SPF Directive -**Requires:** DNS TXT record parsing for SPF policy -**Query:** Parse SPF record for `~all` or `-all` - -### MS.EXO.4.1 - DMARC Record Exists -**Requires:** DNS TXT record lookup for DMARC -**Query:** `nslookup -type=txt _dmarc.` - -### MS.EXO.4.2 - DMARC Reject Policy -**Requires:** DNS TXT record parsing for DMARC policy -**Query:** Parse DMARC record for `p=reject` or `p=quarantine` - -### MS.EXO.4.3 - DMARC Aggregate Reports -**Requires:** DNS TXT record parsing for DMARC rua tags -**Query:** Parse DMARC record for `rua=` email addresses - -### MS.EXO.4.4 - DMARC Reports -**Requires:** DNS TXT record parsing for DMARC report configuration -**Query:** Parse DMARC record for report targets - -### MS.EXO.7.1 - External Sender Warning -**Requires:** ExoOrganizationConfig.ExternalInOutlook property -**Note:** May already be in ExoOrganizationConfig cache - needs verification - -### MS.EXO.13.1 - Mailbox Auditing -**Requires:** ExoOrganizationConfig.AuditDisabled property -**Note:** May already be in ExoOrganizationConfig cache - needs verification - -## Manual Assessment Tests (Cannot Be Automated) - -### MS.EXO.8.3 - DLP Alternate Solution -**Reason:** Requires manual assessment of 3rd party DLP solutions - -### MS.EXO.9.4 - Email Filter Alternative -**Reason:** Requires manual assessment of 3rd party email filtering solutions - -### MS.EXO.14.4 - Spam Alternative Solution -**Reason:** Requires manual assessment of 3rd party anti-spam solutions - -### MS.EXO.17.2 - Audit Log Premium -**Reason:** Requires license validation and advanced audit policy checks beyond cached data - ---- - -## Implementation Priority - -### High Priority (Core Security Controls): -1. CASMailbox - SMTP Auth control -2. ExoAdminAuditLogConfig - Audit logging -3. ExoTenantAllowBlockList - Allow list bypass prevention - -### Medium Priority (DLP and Alerts): -4. SecurityCompliance DLP caches - Data loss prevention -5. SecurityCompliance Alert caches - Security monitoring - -### Low Priority (Advanced Features): -6. ExoSharingPolicy - External sharing controls -7. ExoPresetSecurityPolicy - Preset security policies - ---- - -## Notes on Implementation - -1. **Graph API Alternative**: Some Exchange Online cmdlets may have equivalent Graph API endpoints that could be used instead. - -## Summary - -- **New Caches Required**: 8 cache functions -- **DNS Tests**: 6 tests (architectural limitation) -- **Manual Tests**: 4 tests (cannot be automated) -- **Implementable After New Caches**: 15 additional tests -- **Current Implementation**: 13 tests -- **Total Possible with New Caches**: 28 tests (68% coverage) diff --git a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 index 75e65e8dc70c..edd5477a2d1e 100644 --- a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 +++ b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 @@ -68,7 +68,7 @@ function Invoke-CippTestCustomScripts { # Auto-detected status from output, then apply explicit override if present $AutoStatus = if ($FailedRows.Count -gt 0) { 'Failed' } else { 'Passed' } - $ValidExplicitStatuses = @('Passed', 'Failed', 'Info') + $ValidExplicitStatuses = @('Passed', 'Failed', 'Info', 'Investigate') if ($ExplicitStatus -and $ExplicitStatus -in $ValidExplicitStatuses) { $AutoStatus = $ExplicitStatus } @@ -76,6 +76,7 @@ function Invoke-CippTestCustomScripts { $FinalStatus = switch ($ResultMode) { 'AlwaysPass' { 'Passed' } 'AlwaysInfo' { 'Info' } + 'AlwaysInvestigate' { 'Investigate' } default { $AutoStatus } } @@ -90,6 +91,7 @@ function Invoke-CippTestCustomScripts { $FinalStatus = switch ($ResultMode) { 'AlwaysPass' { 'Passed' } 'AlwaysInfo' { 'Info' } + 'AlwaysInvestigate' { 'Investigate' } default { 'Failed' } } Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultMarkdown "Custom script execution failed: $($ErrorMessage.NormalizedError)" -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md new file mode 100644 index 000000000000..5f767458faf0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.md @@ -0,0 +1,18 @@ +SMB1001 (1.10) — Level 5 — disable untrusted Microsoft Office macros. The Intune-managed implementation is Defender Attack Surface Reduction (ASR) rules. The two key rules for SMB1001 1.10 are: + +- **Block Win32 API calls from Office macros** — prevents macros from calling Win32 APIs to download/execute payloads. +- **Block all Office applications from creating child processes** — prevents Office from spawning malicious processes. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Attack surface reduction > Create policy. +2. Choose Windows > Attack Surface Reduction Rules. +3. Set both Office-macro rules to **Block** (or Audit while validating). +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Attack Surface Reduction rules reference](https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 new file mode 100644 index 000000000000..0b8268c6c105 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_10.ps1 @@ -0,0 +1,60 @@ +function Invoke-CippTestSMB1001_1_10 { + <# + .SYNOPSIS + Tests SMB1001 (1.10) - Disable untrusted Microsoft Office macros + + .DESCRIPTION + Verifies an Attack Surface Reduction (ASR) policy is deployed via Intune that blocks + Win32 API calls from Office macros and child processes from Office apps. SMB1001 1.10 + (Level 5) requires untrusted Office macros to be disabled. + #> + param($Tenant) + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $ASRPolicies = $ConfigurationPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules' + } + + if (-not $ASRPolicies -or $ASRPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Attack Surface Reduction policies found. ASR rules block Office macro abuse, which SMB1001 1.10 requires.' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $MacroProtected = $ASRPolicies | Where-Object { + $children = $_.settings.settingInstance.groupSettingCollectionValue.children + $win32MacroSetting = $children | Where-Object { $_.settingDefinitionId -eq 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros' } + $officeChildSetting = $children | Where-Object { $_.settingDefinitionId -eq 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses' } + ($win32MacroSetting.choiceSettingValue.value -like '*_block' -or $win32MacroSetting.choiceSettingValue.value -like '*_warn') -or + ($officeChildSetting.choiceSettingValue.value -like '*_block' -or $officeChildSetting.choiceSettingValue.value -like '*_warn') + } + + if (-not $MacroProtected -or $MacroProtected.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'ASR policies exist but none enable the Office macro protection rules (Block Win32 API calls from Office macros / Block Office child processes).' -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $Assigned = $MacroProtected | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned.Count -gt 0) { + $Status = 'Passed' + $Result = "$($Assigned.Count) ASR policy/policies are assigned with Office macro protection rules enabled." + } else { + $Status = 'Failed' + $Result = "ASR policies with Office macro protection exist but are not assigned. Found $($MacroProtected.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_10' -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Untrusted Microsoft Office macros are disabled' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md new file mode 100644 index 000000000000..305e247c5d85 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.md @@ -0,0 +1,19 @@ +SMB1001 (1.12) — Level 3 + Level 5 — implement Endpoint Detection and Response (EDR). At Level 5 the EDR must be paired with a Managed Detection and Response (MDR) service with a defined SLA. The Microsoft 365 implementation is Microsoft Defender for Endpoint, deployed via Intune onboarding plus an Endpoint security > EDR configuration policy. + +The MDR contractual relationship is verified separately to a Dynamic Standard Certifier (it is an operational control, not a tenant config). + +**Remediation Action** + +1. Microsoft 365 Defender > Settings > Endpoints > Onboarding — generate the onboarding package. +2. Intune admin centre > Endpoint security > Endpoint detection and response > Create policy. +3. Choose "Auto-configure from MDE connector" so devices use the connector's configuration. +4. Assign to All Devices. + +Use CIPP `standards.IntuneTemplate` with an EDR template to deploy across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Onboard devices to Defender for Endpoint with Intune](https://learn.microsoft.com/en-us/defender-endpoint/configure-endpoints-mdm) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 new file mode 100644 index 000000000000..398dd1fc0d22 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_12.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestSMB1001_1_12 { + <# + .SYNOPSIS + Tests SMB1001 (1.12) - Implement Endpoint Detection and Response (EDR) + + .DESCRIPTION + Verifies the Microsoft Defender for Endpoint - Intune connector is enabled. The connector + is the prerequisite for onboarding devices to MDE via Intune. SMB1001 1.12 Level 5 + additionally prescribes a Managed Detection and Response (MDR) service contract — that is + a contractual control evidenced separately. + #> + param($Tenant) + + $TestId = 'SMB1001_1_12' + $Name = 'Endpoint Detection and Response (EDR) is deployed' + + try { + $MDEOnboarding = Get-CIPPTestData -TenantFilter $Tenant -Type 'MDEOnboarding' + + if (-not $MDEOnboarding) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'MDEOnboarding cache not found. This may be due to missing Defender for Endpoint licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + return + } + + $Connector = $MDEOnboarding | Select-Object -First 1 + $State = $Connector.partnerState + + if ($State -eq 'enabled') { + $Status = 'Passed' + $Result = "The Microsoft Defender for Endpoint - Intune connector is enabled (partnerState: $State). Devices onboarded via Intune can report to MDE for EDR. If you are at L5, evidence the MDR service contract separately." + } else { + $Status = 'Failed' + $Result = "The Microsoft Defender for Endpoint - Intune connector is not enabled (partnerState: $($State ?? 'unavailable')). Onboard tenant in Microsoft 365 Defender > Settings > Endpoints > Advanced features and connect Intune." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md new file mode 100644 index 000000000000..7bc9377606fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.md @@ -0,0 +1,16 @@ +SMB1001 (1.2) — Level 1+ — install and configure a firewall on every device that connects to the Internet. The Intune-managed implementation is the Microsoft Defender Firewall configuration policy under Endpoint security > Firewall. This test passes when at least one firewall policy is assigned to a group. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Firewall > Create policy. +2. Choose platform (Windows or macOS) and the Microsoft Defender Firewall profile. +3. Configure rules and assign to All Devices or a target group. + +Use CIPP `standards.IntuneTemplate` with a Defender Firewall template to deploy across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Configure Microsoft Defender Firewall with Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-firewall-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 new file mode 100644 index 000000000000..d5262a461140 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_2.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_1_2 { + <# + .SYNOPSIS + Tests SMB1001 (1.2) - Install and configure a firewall on all devices + + .DESCRIPTION + Verifies an Intune endpoint security firewall configuration policy exists and is assigned. + SMB1001 1.2 requires firewalls on every device that connects to the Internet, including + personal devices used for work. + #> + param($Tenant) + + $TestId = 'SMB1001_1_2' + $Name = 'Firewall is configured on all devices' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $FirewallPolicies = @($ConfigurationPolicies | Where-Object { + $_.templateReference -and $_.templateReference.templateFamily -eq 'endpointSecurityFirewall' + }) + + if ($FirewallPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No endpoint security firewall configuration policies found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AssignedPolicies = @($FirewallPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $FirewallPolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + "| $($P.name) | $A |" + } + $Result = (@( + "$($AssignedPolicies.Count) of $($FirewallPolicies.Count) firewall policy/policies are assigned." + '' + '| Policy Name | Assigned |' + '| :---------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Firewall policies exist but none are assigned. Found $($FirewallPolicies.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md new file mode 100644 index 000000000000..448d87b87354 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.md @@ -0,0 +1,15 @@ +SMB1001 (1.3) — Level 1+ — install and enable antivirus on every workstation and laptop. Mobile devices are covered by ensuring built-in protections (Google Play Protect, App Store) are active. The Intune-managed implementation is a Microsoft Defender Antivirus configuration policy under Endpoint security > Antivirus. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Antivirus > Create policy. +2. Choose platform (Windows, macOS) and Microsoft Defender Antivirus profile. +3. Configure real-time protection, cloud-delivered protection, automatic sample submission. +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Antivirus policy for Endpoint security in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-antivirus-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 new file mode 100644 index 000000000000..39bdcf2e53fb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_3.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_1_3 { + <# + .SYNOPSIS + Tests SMB1001 (1.3) - Install antivirus software on all organization devices + + .DESCRIPTION + Verifies an Intune endpoint security antivirus configuration policy exists and is assigned. + SMB1001 1.3 requires actively-updated antivirus on workstations and laptops. + #> + param($Tenant) + + $TestId = 'SMB1001_1_3' + $Name = 'Antivirus is installed and configured on all devices' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AVPolicies = @($ConfigurationPolicies | Where-Object { + $_.templateReference -and $_.templateReference.templateFamily -eq 'endpointSecurityAntivirus' + }) + + if ($AVPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No endpoint security antivirus configuration policies found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $AssignedPolicies = @($AVPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $AVPolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + $Plat = if ($P.platforms) { $P.platforms } else { 'unknown' } + "| $($P.name) | $Plat | $A |" + } + $Result = (@( + "$($AssignedPolicies.Count) of $($AVPolicies.Count) antivirus policy/policies are assigned." + '' + '| Policy Name | Platform | Assigned |' + '| :---------- | :------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Antivirus policies exist but none are assigned. Found $($AVPolicies.Count) unassigned policy/policies." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md new file mode 100644 index 000000000000..407e93cc1d70 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.md @@ -0,0 +1,14 @@ +SMB1001 (1.4) — Level 1+ — software updates and patches are installed automatically. If automatic updates cannot be configured, manual updates must be applied at least every three months. The Intune-managed implementation is Windows Update for Business — quality update profiles for monthly patches and feature update profiles for OS upgrades. + +**Remediation Action** + +1. Intune admin centre > Devices > Windows > Update rings (or Quality update profiles / Feature update profiles). +2. Configure deferral periods, deadlines, and automatic restarts. +3. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Windows Update for Business with Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/windows-update-for-business-configure) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 new file mode 100644 index 000000000000..d968d7637b08 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_4.ps1 @@ -0,0 +1,56 @@ +function Invoke-CippTestSMB1001_1_4 { + <# + .SYNOPSIS + Tests SMB1001 (1.4) - Automatically install tested software updates and patches + + .DESCRIPTION + Verifies that a Windows Update for Business configuration profile is deployed via Intune + and assigned. The Intune update profile is stored in IntuneDeviceConfigurations under the + '@odata.type' value '#microsoft.graph.windowsUpdateForBusinessConfiguration'. + #> + param($Tenant) + + $TestId = 'SMB1001_1_4' + $Name = 'Software updates are installed automatically' + + try { + $DeviceConfigs = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + + if (-not $DeviceConfigs) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'IntuneDeviceConfigurations cache not found. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $UpdatePolicies = @($DeviceConfigs | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' }) + + if ($UpdatePolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows Update for Business configuration profiles found in Intune.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $Assigned = @($UpdatePolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($Assigned.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($P in $UpdatePolicies) { + $A = if ($P.assignments -and $P.assignments.Count -gt 0) { '✅ Yes' } else { '❌ No' } + "| $($P.displayName) | $A |" + } + $Result = (@( + "$($Assigned.Count) of $($UpdatePolicies.Count) Windows Update for Business profile(s) are assigned." + '' + '| Profile Name | Assigned |' + '| :----------- | :------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + $Result = "Windows Update for Business profiles exist but none are assigned. Found $($UpdatePolicies.Count) unassigned profile(s)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md new file mode 100644 index 000000000000..a8478b604077 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.md @@ -0,0 +1,15 @@ +SMB1001 (1.8) — Level 5 — important digital data must be encrypted at rest. On Windows the Intune-managed implementation is BitLocker, deployed via Endpoint security > Disk encryption (or via a Settings Catalog policy enabling `device_vendor_msft_bitlocker_requiredeviceencryption`). + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Disk encryption > Create policy. +2. Choose Windows > BitLocker. +3. Configure recovery key escrow to Entra, encryption method, and startup authentication. +4. Assign to All Devices or a target group. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Encrypt Windows devices with BitLocker in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/encrypt-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 new file mode 100644 index 000000000000..33ec235959a4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_8.ps1 @@ -0,0 +1,86 @@ +function Invoke-CippTestSMB1001_1_8 { + <# + .SYNOPSIS + Tests SMB1001 (1.8) - Ensure important digital data is encrypted at rest + + .DESCRIPTION + Verifies BitLocker encryption is enforced on Windows devices via an Intune configuration + policy. SMB1001 1.8 (Level 5) requires data at rest to be encrypted on devices that store + sensitive information. Detection follows the ZTNA24550 pattern — looks for the + 'device_vendor_msft_bitlocker_requiredeviceencryption_1' setting value on Windows + configuration policies. + #> + param($Tenant) + + $TestId = 'SMB1001_1_8' + $Name = 'Important digital data is encrypted at rest' + + try { + $ConfigurationPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigurationPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'IntuneConfigurationPolicies cache not found. This may be due to missing Intune licenses or data collection not yet completed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + return + } + + $WindowsPolicies = $ConfigurationPolicies | Where-Object { $_.platforms -match 'windows10' } + + $WindowsBitLockerPolicies = @( + foreach ($Policy in $WindowsPolicies) { + $ValidSettingValues = @('device_vendor_msft_bitlocker_requiredeviceencryption_1') + + if ($Policy.settings.settinginstance.choicesettingvalue.value) { + $PolicySettingValues = $Policy.settings.settinginstance.choicesettingvalue.value + if ($PolicySettingValues -isnot [array]) { + $PolicySettingValues = @($PolicySettingValues) + } + + foreach ($SettingValue in $PolicySettingValues) { + if ($ValidSettingValues -contains $SettingValue) { + $Policy + break + } + } + } + } + ) + + $AssignedPolicies = @($WindowsBitLockerPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + + if ($AssignedPolicies.Count -gt 0) { + $Status = 'Passed' + $TableRows = foreach ($Policy in $WindowsBitLockerPolicies) { + $PolicyStatus = if ($Policy.assignments -and $Policy.assignments.Count -gt 0) { '✅ Assigned' } else { '❌ Not assigned' } + $AssignmentCount = if ($Policy.assignments) { $Policy.assignments.Count } else { 0 } + "| $($Policy.name) | $PolicyStatus | $AssignmentCount |" + } + $Result = (@( + 'At least one Windows BitLocker policy is configured and assigned.' + '' + '**Windows BitLocker Policies:**' + '' + '| Policy Name | Status | Assignment Count |' + '| :---------- | :----- | :--------------- |' + ) + $TableRows) -join "`n" + } else { + $Status = 'Failed' + if ($WindowsBitLockerPolicies.Count -gt 0) { + $UnassignedRows = foreach ($Policy in $WindowsBitLockerPolicies) { "- $($Policy.name)" } + $Result = (@( + 'Windows BitLocker policies exist but none are assigned.' + '' + '**Unassigned BitLocker Policies:**' + '' + ) + $UnassignedRows) -join "`n" + } else { + $Result = 'No Windows BitLocker policy is configured.' + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md new file mode 100644 index 000000000000..83841aba7011 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.md @@ -0,0 +1,15 @@ +SMB1001 (1.9) — Level 5 — implement application control (software allowlisting). Only approved software runs. The Intune-managed implementation is App Control for Business (formerly Windows Defender Application Control / WDAC) or AppLocker, deployed via Endpoint security > Application control or via the Settings Catalog. + +**Remediation Action** + +1. Intune admin centre > Endpoint security > Application control for Business > Create policy. +2. Choose Audit mode first, validate that legitimate apps are not blocked, then move to Enforce. +3. Define trusted publishers / managed installers. +4. Assign to a test ring then expand to All Devices. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [App Control for Business policies in Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/endpoint-security-app-control-policy) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 new file mode 100644 index 000000000000..e4c35a4d0935 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_1_9.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_1_9 { + <# + .SYNOPSIS + Tests SMB1001 (1.9) - Implement application control + + .DESCRIPTION + SMB1001 1.9 (Level 5) requires software allowlisting on workstations via App Control for + Business / WDAC / AppLocker. CIPP does not yet have a proven cache-side detection pattern + for these policy families, so this control is informational and should be evidenced + separately from the Intune Endpoint Security > Application control blade. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_9' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (1.9) requires software allowlisting via App Control for Business, WDAC, or AppLocker. Verify in Microsoft Intune > Endpoint security > Application control for Business and evidence the assigned policy to your Dynamic Standard Certifier directly.' -Risk 'Informational' -Name 'Application control limits unauthorised software' -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'Device' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md new file mode 100644 index 000000000000..c56447e19199 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.md @@ -0,0 +1,30 @@ +SMB1001 (2.2) — Level 2+ — employees who should not be permitted to install software on their workstations or laptops must not have local user accounts with administrative privileges. The Intune-managed implementation has two parts: + +1. The Microsoft Entra device registration policy must deny local admin rights to registering users (so a normal user joining a device does not become its local admin). +2. Windows LAPS must be deployed to manage the local administrator credential — without LAPS the local admin password is either shared, static, or unmanaged. + +**Remediation Action** + +```powershell +# 1. Device registration policy — deny local admin to registering users +$body = @{ + azureADJoin = @{ + localAdmins = @{ + registeringUsers = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } + enableGlobalAdmins = $false + } + } +} | ConvertTo-Json -Depth 10 +Invoke-MgGraphRequest -Method PUT -Uri 'https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy' -Body $body + +# 2. Deploy Windows LAPS via Intune (Endpoint security > Account protection > Local admin password solution) +``` + +Use CIPP `standards.intuneDeviceRegLocalAdmins` and `standards.laps`, and deploy a LAPS Intune template via `standards.IntuneTemplate`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Windows LAPS in Microsoft Intune](https://learn.microsoft.com/en-us/intune/intune-service/protect/windows-laps-overview) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 new file mode 100644 index 000000000000..5244f54f7d2f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_2_2.ps1 @@ -0,0 +1,62 @@ +function Invoke-CippTestSMB1001_2_2 { + <# + .SYNOPSIS + Tests SMB1001 (2.2) - Ensure employee accounts do not have administrative privileges + + .DESCRIPTION + Verifies the device registration policy disables registering users from being granted local + admin rights, and that an Intune Windows LAPS policy is deployed to manage the local + administrator credential. SMB1001 2.2 forbids users from having local admin rights to + install software on their workstations. + #> + param($Tenant) + + $TestId = 'SMB1001_2_2' + $Name = 'Employees do not have administrative privileges on their devices' + $Issues = [System.Collections.Generic.List[string]]::new() + + try { + $DeviceRegPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + # 1. Device registration policy: registering users should NOT auto become local admin + if ($DeviceRegPolicy) { + $Cfg = $DeviceRegPolicy | Select-Object -First 1 + $RegisteringType = $Cfg.azureADJoin.localAdmins.registeringUsers.'@odata.type' + if ($RegisteringType -ne '#microsoft.graph.noDeviceRegistrationMembership') { + $Issues.Add("Registering users are granted local administrator rights ($RegisteringType). Configure deviceRegistrationPolicy to deny.") + } + } else { + $Issues.Add('DeviceRegistrationPolicy cache not found — cannot verify whether registering users get local admin rights.') + } + + # 2. LAPS policy deployed and assigned + if ($ConfigPolicies) { + $LapsPolicies = @($ConfigPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.templateReference.templateFamily -eq 'endpointSecurityAccountProtection' -and + ($_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_laps_policies_backupdirectory') + }) + $AssignedLaps = @($LapsPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) + if ($AssignedLaps.Count -eq 0) { + $Issues.Add('No assigned Windows LAPS policy found in Intune. Without LAPS, the local administrator credential is shared/static, contradicting SMB1001 2.2.') + } + } else { + $Issues.Add('IntuneConfigurationPolicies cache not found — cannot verify Windows LAPS deployment.') + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Registering users are not granted local administrator rights, and an assigned Windows LAPS policy manages the local admin credential.' + } else { + $Status = 'Failed' + $Result = "SMB1001 (2.2) requires employees to lack administrative privileges on their devices.`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Device' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'Device' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md new file mode 100644 index 000000000000..3240bb4e93b9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.md @@ -0,0 +1,17 @@ +SMB1001 (4.7) — Level 3+ — devices that store sensitive, private, or confidential information must be disposed of securely. The standard requires permanent destruction (shredder or external service) for end-of-life devices, or a non-recoverable format if the device is to be reused, sold, or given away. + +This is an operational/process control. The Intune lifecycle (Retire / Wipe / managed device cleanup rules) helps remove corporate data from devices that go missing or are decommissioned, but the physical-destruction or full storage-media format step happens outside Microsoft 365 and must be evidenced to your Dynamic Standard Certifier. + +**Remediation Action** + +1. Document a device-disposal procedure (who approves, how drives are formatted/destroyed, certificate of destruction). +2. Configure Intune managed device cleanup rules (`deviceInactivityBeforeRetirementInDays`) to auto-retire stale devices — see CIPP `standards.intuneDeviceRetirementDays`. +3. For sold/donated devices, run a cryptographic erase or a full disk wipe before handover. +4. For destroyed devices, retain the destruction certificate. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Retire or wipe devices using Intune](https://learn.microsoft.com/en-us/intune/intune-service/remote-actions/devices-wipe) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 new file mode 100644 index 000000000000..f2f96a933fa5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Devices/Invoke-CippTestSMB1001_4_7.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_4_7 { + <# + .SYNOPSIS + Tests SMB1001 (4.7) - Ensure all computer devices that store sensitive information are + disposed of securely + + .DESCRIPTION + SMB1001 4.7 requires permanent destruction or non-recoverable formatting of storage media + on decommissioned devices. The physical-disposal step happens outside Microsoft 365. + This test is informational so the disposal procedure is evidenced separately. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_4_7' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (4.7) requires devices that store sensitive, private, or confidential information to be disposed of securely — by physical destruction (shredder or external service) or non-recoverable formatting if the device is reused or sold. Evidence the disposal procedure (destruction certificates, asset disposal log) to your Dynamic Standard Certifier separately. Configuring an Intune managed-device cleanup rule helps remove corporate data from inactive devices but does not satisfy the physical-disposal requirement on its own.' -Risk 'Informational' -Name 'Devices that store sensitive information are disposed of securely' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Device' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md new file mode 100644 index 000000000000..bdde7820d423 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.md @@ -0,0 +1,15 @@ +SMB1001 (1.11) — Level 5 — requires regular penetration testing, vulnerability scans, and social-engineering simulations. These are operational activities executed outside the Microsoft 365 tenant and cannot be verified automatically. This test is **informational**: evidence the testing programme to your Dynamic Standard Certifier directly. + +**Remediation Action** + +1. Engage a third-party vendor for annual external penetration testing. +2. Schedule quarterly vulnerability scans of public-facing services. +3. Run an ongoing social-engineering simulation programme (KnowBe4, Hoxhunt, Cofense). Configure a Phishing Simulation Override Policy in Defender (Microsoft 365 Defender > Policies > Advanced delivery) to whitelist the vendor's delivery domains and IPs. +4. Maintain a remediation register that links findings to fixes. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Configure third-party phishing simulations in Defender](https://learn.microsoft.com/en-us/defender-office-365/advanced-delivery-policy-configure) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 new file mode 100644 index 000000000000..1351200384e4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_1_11.ps1 @@ -0,0 +1,15 @@ +function Invoke-CippTestSMB1001_1_11 { + <# + .SYNOPSIS + Tests SMB1001 (1.11) - Conduct penetration, vulnerability and social engineering testing + + .DESCRIPTION + Pen testing, vulnerability scanning, and social engineering simulations are operational + activities verified outside the M365 tenant. The closest M365 artefact is the + Phishing Simulation Override Policy (Get-PhishSimOverridePolicy), which is not cached. + This test is informational so that auditors evidence the testing programme separately. + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'SMB1001_1_11' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. SMB1001 (1.11) requires regular penetration tests, vulnerability scans, and social-engineering simulations. Evidence the testing programme (vendor reports, phishing simulation campaign results, remediation register) to your Dynamic Standard Certifier separately.' -Risk 'Informational' -Name 'Penetration, vulnerability and social engineering testing is conducted' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Security Testing' +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md new file mode 100644 index 000000000000..c6e638f6f991 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.md @@ -0,0 +1,18 @@ +SMB1001 (2.1) — Level 1+ — requires strong password hygiene including unique passphrases that have not appeared in data breaches. Entra ID Password Protection ships a global banned-password list maintained by Microsoft and lets you add an organisation-specific custom list (company name, product names, common local terms). The custom list requires Entra ID Premium P1 or P2. + +**Remediation Action** + +```powershell +# Configure custom banned passwords in Entra Portal +# https://entra.microsoft.com > Protection > Authentication methods > Password protection +# Enable "Enforce custom list" and add 4-16 character organisation-specific terms. +``` + +Or use the CIPP standard `standards.CustomBannedPasswordList` to deploy this across tenants. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Microsoft Entra Password Protection](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 new file mode 100644 index 000000000000..e88195f83112 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_1.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestSMB1001_2_1 { + <# + .SYNOPSIS + Tests SMB1001 (2.1) - Ensure strong password hygiene is maintained + + .DESCRIPTION + Verifies the tenant has Entra ID password protection enabled with a custom banned-password + list (the M365 mechanism that blocks weak / breached passwords required by SMB1001 2.1.vi). + #> + param($Tenant) + + $TestId = 'SMB1001_2_1' + $Name = 'Strong password hygiene is maintained' + + try { + $Settings = Get-CIPPTestData -TenantFilter $Tenant -Type 'Settings' + + if (-not $Settings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Settings cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + return + } + + $PwdSetting = $Settings | Where-Object { + $_.templateId -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' -or $_.displayName -eq 'Password Rule Settings' + } | Select-Object -First 1 + + if (-not $PwdSetting) { + $Status = 'Failed' + $Result = 'Entra ID Password Rule Settings not found. Configure a custom banned-password list to satisfy SMB1001 (2.1.vi) — passwords must not appear in previous data breaches.' + } else { + $Enforce = ($PwdSetting.values | Where-Object { $_.name -eq 'EnableBannedPasswordCheck' }).value + $Custom = ($PwdSetting.values | Where-Object { $_.name -eq 'BannedPasswordList' }).value + + if ($Enforce -eq 'True' -and -not [string]::IsNullOrWhiteSpace($Custom)) { + $WordCount = ($Custom -split '\t').Count + $Status = 'Passed' + $Result = "Custom banned passwords are enforced ($WordCount banned term(s))." + } else { + $Status = 'Failed' + $Result = "Entra ID Password Protection is not fully configured.`n`n- EnableBannedPasswordCheck: $Enforce`n- BannedPasswordList length: $($Custom.Length)" + } + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Password Hygiene' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md new file mode 100644 index 000000000000..f91eb901403a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.md @@ -0,0 +1,21 @@ +SMB1001 (2.12) — Level 2+ — configure SPF, DKIM, and DMARC on every domain used to send organisational email. Level 3 prescribes DMARC `p=reject` or `p=quarantine` with annual review. SPF prevents domain spoofing, DKIM cryptographically signs outgoing mail, and DMARC tells receivers what to do when SPF/DKIM fail. + +**Remediation Action** + +```powershell +# DKIM +New-DkimSigningConfig -DomainName contoso.com -KeySize 2048 -Enabled $true +# SPF (DNS TXT) +"v=spf1 include:spf.protection.outlook.com -all" +# DMARC (DNS TXT at _dmarc.contoso.com) +"v=DMARC1; p=reject; rua=mailto:dmarc@contoso.com" +``` + +Use the CIPP standards `standards.AddDKIM`, `standards.RotateDKIM`, and `standards.AddDMARCToMOERA` to automate. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Set up SPF, DKIM and DMARC for Microsoft 365](https://learn.microsoft.com/en-us/defender-office-365/email-authentication-about) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 new file mode 100644 index 000000000000..8b372ec564f9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 @@ -0,0 +1,78 @@ +function Invoke-CippTestSMB1001_2_12 { + <# + .SYNOPSIS + Tests SMB1001 (2.12) - Email Authentication and Anti-Spoofing (SPF, DKIM, DMARC) + + .DESCRIPTION + Verifies SPF, DKIM and DMARC are configured on every accepted sending domain. Combines + Domain Analyser results (SPF, DMARC) with Exchange DKIM signing config. Level 3 prescribes + DMARC p=reject or p=quarantine and 2048-bit DKIM keys. + #> + param($Tenant) + + $TestId = 'SMB1001_2_12' + $Name = 'SPF, DKIM, and DMARC are configured on all sending domains' + + try { + $Analyser = Get-CIPPDomainAnalyser -TenantFilter $Tenant + $Dkim = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoDkimSigningConfig' + $Accepted = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoAcceptedDomains' + + if (-not $Analyser -or -not $Accepted) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required data (Domain Analyser or ExoAcceptedDomains) not found. Run the CIPP Domain Analyser and refresh caches.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + return + } + + $Sending = @($Accepted | Where-Object { -not $_.SendingFromDomainDisabled -and $_.DomainName -notlike '*onmicrosoft.com' }) + + $Failures = @( + foreach ($D in $Sending) { + $A = $Analyser | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 + $K = $Dkim | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 + $Spf = $A.ActualSPFRecord -match 'v=spf1' + $Dmarc = $A.DMARCRecord -match 'v=DMARC1' + $DmarcStrong = $A.DMARCRecord -match 'p=(reject|quarantine)' + $DkimEnabled = ($K -and $K.Enabled -eq $true) + $DomainIssues = @( + if (-not $Spf) { 'no SPF' } + if (-not $DkimEnabled) { 'no DKIM' } + if (-not $Dmarc) { 'no DMARC' } + elseif (-not $DmarcStrong) { 'DMARC weak (not p=reject/quarantine)' } + ) + if ($DomainIssues.Count -gt 0) { + [PSCustomObject]@{ + Domain = $D.DomainName + SPF = if ($Spf) { '✅' } else { '❌' } + DKIM = if ($DkimEnabled) { '✅' } else { '❌' } + DMARC = if ($Dmarc) { if ($DmarcStrong) { '✅' } else { '⚠️' } } else { '❌' } + Issues = $DomainIssues -join ', ' + } + } + } + ) + + if ($Sending.Count -eq 0) { + $Status = 'Passed' + $Result = 'No custom sending domains configured.' + } elseif ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Sending.Count) sending domain(s) have SPF, DKIM, and DMARC (p=reject or p=quarantine) configured." + } else { + $Status = 'Failed' + $TableRows = foreach ($F in ($Failures | Select-Object -First 25)) { + "| $($F.Domain) | $($F.SPF) | $($F.DKIM) | $($F.DMARC) | $($F.Issues) |" + } + $Result = (@( + "$($Failures.Count) of $($Sending.Count) sending domain(s) are missing email authentication:" + '' + '| Domain | SPF | DKIM | DMARC | Issues |' + '| :----- | :-: | :--: | :---: | :----- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Email Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md new file mode 100644 index 000000000000..eb5ade015738 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.md @@ -0,0 +1,18 @@ +SMB1001 (2.3) — Level 2+ — every employee must have their own username and password; shared logins are not permitted. In Microsoft 365 the most common shared-credential risk is a shared mailbox where the underlying Entra account remains enabled and could be signed into directly. Microsoft's recommendation is to disable sign-in on all shared, scheduling, room, and equipment mailboxes so employees access them only via delegated permissions. + +**Remediation Action** + +```powershell +# Disable sign-in for shared mailbox accounts +Get-Mailbox -RecipientTypeDetails SharedMailbox,SchedulingMailbox,RoomMailbox,EquipmentMailbox | + ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId -AccountEnabled:$false } +``` + +Or use the CIPP standards `standards.DisableSharedMailbox` and `standards.DisableResourceMailbox`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Block sign-in for shared mailbox accounts](https://learn.microsoft.com/en-us/microsoft-365/admin/email/about-shared-mailboxes#block-sign-in-for-the-shared-mailbox-account) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 new file mode 100644 index 000000000000..9b67adf36244 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_3.ps1 @@ -0,0 +1,64 @@ +function Invoke-CippTestSMB1001_2_3 { + <# + .SYNOPSIS + Tests SMB1001 (2.3) - Ensure employees have individual user accounts + + .DESCRIPTION + Verifies that shared/resource mailboxes do not have an enabled Entra account that could + be logged into directly with shared credentials. SMB1001 2.3.ii forbids shared usernames + and passwords across employees. + #> + param($Tenant) + + $TestId = 'SMB1001_2_3' + $Name = 'Employees have individual user accounts (no shared logins)' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Mailboxes -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Mailboxes or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + return + } + + $Shared = @($Mailboxes | Where-Object { $_.recipientTypeDetails -in @('SharedMailbox', 'SchedulingMailbox', 'EquipmentMailbox', 'RoomMailbox') }) + + $EnabledShared = @( + foreach ($Mbx in $Shared) { + $User = $Users | Where-Object { $_.id -eq $Mbx.ExternalDirectoryObjectId -or $_.userPrincipalName -eq $Mbx.UPN } | Select-Object -First 1 + if ($User -and $User.accountEnabled -eq $true -and $User.onPremisesSyncEnabled -ne $true) { + [PSCustomObject]@{ + UPN = $Mbx.UPN + DisplayName = $Mbx.displayName + RecipientTypeDetails = $Mbx.recipientTypeDetails + } + } + } + ) + + if ($Shared.Count -eq 0) { + $Status = 'Passed' + $Result = 'No shared, scheduling, room, or equipment mailboxes exist in the tenant.' + } elseif ($EnabledShared.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($Shared.Count) shared/resource mailbox account(s) have sign-in disabled. Employees access them via delegation only." + } else { + $Status = 'Failed' + $TableRows = foreach ($M in ($EnabledShared | Select-Object -First 25)) { + "| $($M.UPN) | $($M.RecipientTypeDetails) |" + } + $Result = (@( + "$($EnabledShared.Count) of $($Shared.Count) shared/resource mailbox(es) still have an enabled Entra account that could be logged into with shared credentials:" + '' + '| Mailbox | Type |' + '| :------ | :--- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Account Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md new file mode 100644 index 000000000000..599cb270374c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.md @@ -0,0 +1,16 @@ +SMB1001 (2.5) — Level 2+ — multi-factor authentication or two-step verification on all employee email accounts, including administrators. The test passes if MFA is enforced through any of: Security Defaults, an enforced Conditional Access policy targeting Office 365 / all apps, or per-user MFA on every active member account. + +**Remediation Action** + +Choose one path: + +- Enable **Security Defaults** in Microsoft Entra (Identity > Overview > Properties > Manage Security defaults). +- Deploy a **Conditional Access policy** that requires MFA for all users targeting all cloud apps (or Office 365). With CIPP: `standards.ConditionalAccessTemplate`. +- Enforce **per-user MFA** on every member account (legacy). With CIPP: `standards.PerUserMFA`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Common Conditional Access policy: Require MFA for all users](https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 new file mode 100644 index 000000000000..66c65c6d67bf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_2_5 { + <# + .SYNOPSIS + Tests SMB1001 (2.5) - Multi-factor authentication (MFA) on all employee email accounts + + .DESCRIPTION + Verifies MFA is enforced for every active member account. Uses the MFAState cache, which + aggregates Conditional Access coverage, Security Defaults state, and per-user MFA into a + single per-user record. SMB1001 2.5 requires MFA on email for all users including admins. + #> + param($Tenant) + + $TestId = 'SMB1001_2_5' + $Name = 'MFA is enforced on all employee email accounts' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -notlike 'Enforced*' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) are protected by MFA (Conditional Access, Security Defaults, or per-user MFA)." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) are not protected by any MFA enforcement mechanism:" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md new file mode 100644 index 000000000000..a164b6c7827f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.md @@ -0,0 +1,19 @@ +SMB1001 Level 4 / 5 hardens controls 2.5 (MFA on email), 2.6 (MFA on business apps) and 2.9 (MFA where data is stored) with a factor-type prohibition: only Authenticator App, phone-based push, or U2F/FIDO2 may be used. SMS, Voice, Text and Email are explicitly forbidden as second factors and as backup/recovery methods. + +**Remediation Action** + +```powershell +# Disable weak MFA methods +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Sms' -BodyParameter @{state='disabled'} +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Voice' -BodyParameter @{state='disabled'} +Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -Id 'Email' -BodyParameter @{state='disabled'} +``` + +Or use the CIPP standards `standards.DisableSMS`, `standards.DisableVoice`, `standards.DisableEmail`. Pair with `standards.EnableFIDO2` for phishing-resistant factors. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Manage authentication methods](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 new file mode 100644 index 000000000000..c7436abfc061 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_5_L4.ps1 @@ -0,0 +1,48 @@ +function Invoke-CippTestSMB1001_2_5_L4 { + <# + .SYNOPSIS + Tests SMB1001 (2.5/2.6/2.9 Level 4+) - Weak MFA factors disabled (SMS, Voice, Email) + + .DESCRIPTION + SMB1001 Level 4 hardens MFA controls 2.5, 2.6, 2.9 by prohibiting SMS, Voice, Text and + Email as second factors. Only Authenticator App, phone-based push, or U2F/FIDO2 may be + used. This test verifies the Authentication Methods Policy disables SMS, Voice, and Email. + #> + param($Tenant) + + $TestId = 'SMB1001_2_5_L4' + $Name = 'Phishing-resistant MFA factors are enforced (SMS, Voice, Email disabled)' + + try { + $AMP = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + + if (-not $AMP) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Cfg = $AMP | Select-Object -First 1 + $Sms = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Sms' } | Select-Object -First 1 + $Voice = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Voice' } | Select-Object -First 1 + $Email = $Cfg.authenticationMethodConfigurations | Where-Object { $_.id -eq 'Email' } | Select-Object -First 1 + + $WeakStill = @( + if ($Sms -and $Sms.state -ne 'disabled') { "SMS ($($Sms.state))" } + if ($Voice -and $Voice.state -ne 'disabled') { "Voice ($($Voice.state))" } + if ($Email -and $Email.state -ne 'disabled') { "Email ($($Email.state))" } + ) + + if ($WeakStill.Count -eq 0) { + $Status = 'Passed' + $Result = 'SMS, Voice and Email authentication methods are all disabled. Phishing-resistant factors (Authenticator app, FIDO2, Hardware OATH) are the only paths.' + } else { + $Status = 'Failed' + $Result = "Level 4/5 of SMB1001 prohibits SMS/Voice/Email as MFA factors. The following weak methods remain enabled:`n`n- $($WeakStill -join "`n- ")`n`nDisable each via the Authentication Methods Policy." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md new file mode 100644 index 000000000000..98702a2f0bb3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.md @@ -0,0 +1,20 @@ +SMB1001 (2.6) — Level 3+ — multi-factor authentication for all user and administrator accounts on all cloud-hosted business applications, including social media. The strongest Microsoft 365 implementation is a Conditional Access policy that targets All Cloud Apps with the grant control "Require multi-factor authentication" applied to All Users. + +**Remediation Action** + +Deploy a Conditional Access policy: + +- **Users**: All users +- **Cloud apps**: All cloud apps +- **Grant**: Require multi-factor authentication + +Use CIPP `standards.ConditionalAccessTemplate` with the "Require MFA for all users" template. + +For tenants without Entra ID Premium, fall back to Security Defaults or per-user MFA on every active member account. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Common Conditional Access policy: Require MFA for all users](https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 new file mode 100644 index 000000000000..1de907830b6e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_6.ps1 @@ -0,0 +1,59 @@ +function Invoke-CippTestSMB1001_2_6 { + <# + .SYNOPSIS + Tests SMB1001 (2.6) - MFA on all business applications and social media accounts + + .DESCRIPTION + Verifies MFA covers ALL cloud applications (not just specific ones). The MFAState cache + classifies each user's CA coverage as 'Enforced - All Apps', 'Enforced - Specific Apps', + or 'Not Enforced'. SMB1001 2.6 requires MFA across all business applications, so we + require All-Apps coverage, Security Defaults, or per-user MFA. + #> + param($Tenant) + + $TestId = 'SMB1001_2_6' + $Name = 'MFA is enforced on all business applications' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -ne 'Enforced - All Apps' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) have MFA enforced across all business applications." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) are not protected by an All-Apps MFA policy. Specific-Apps CA policies satisfy 2.5 (email) but not 2.6 (all business apps):" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md new file mode 100644 index 000000000000..1da9993cd87b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.md @@ -0,0 +1,25 @@ +SMB1001 (2.8) — Level 4+ — manage remote access cloud credentials with least-privilege IAM. The Microsoft Entra default user role gives standard users the ability to register applications, create new M365 tenants, and create security groups — all administrative actions. SMB1001 2.8.i requires those privileges to be minimised for non-admin accounts. + +**Remediation Action** + +```powershell +# Disable user-level admin actions in the authorization policy +$body = @{ + defaultUserRolePermissions = @{ + allowedToCreateApps = $false + allowedToCreateTenants = $false + allowedToCreateSecurityGroups = $false + } + allowedToSignUpEmailBasedSubscriptions = $false +} | ConvertTo-Json +Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -Body $body +``` + +Or use the CIPP standards `standards.DisableAppCreation`, `standards.DisableTenantCreation`, `standards.DisableSecurityGroupUsers`, and `standards.DisableSelfServiceLicenses`. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Restrict default user permissions in Microsoft Entra](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 new file mode 100644 index 000000000000..2ae05c2cc783 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_8.ps1 @@ -0,0 +1,53 @@ +function Invoke-CippTestSMB1001_2_8 { + <# + .SYNOPSIS + Tests SMB1001 (2.8) - Management of remote access cloud credentials + + .DESCRIPTION + Verifies the cloud IAM is configured with least privilege — regular users cannot create + tenants, applications, or security groups, all of which are administrative actions that + should be reserved for dedicated admin accounts. Implements the IAM scope of SMB1001 2.8. + #> + param($Tenant) + + $TestId = 'SMB1001_2_8' + $Name = 'Cloud IAM is configured with least privilege' + $Issues = [System.Collections.Generic.List[string]]::new() + + try { + $Auth = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + + if (-not $Auth) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + return + } + + $Cfg = $Auth | Select-Object -First 1 + + if ($Cfg.defaultUserRolePermissions.allowedToCreateApps -ne $false) { + $Issues.Add("Users can create app registrations (allowedToCreateApps: $($Cfg.defaultUserRolePermissions.allowedToCreateApps))") + } + if ($Cfg.defaultUserRolePermissions.allowedToCreateTenants -ne $false) { + $Issues.Add("Users can create new M365 tenants (allowedToCreateTenants: $($Cfg.defaultUserRolePermissions.allowedToCreateTenants))") + } + if ($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups -ne $false) { + $Issues.Add("Users can create security groups (allowedToCreateSecurityGroups: $($Cfg.defaultUserRolePermissions.allowedToCreateSecurityGroups))") + } + if ($Cfg.allowedToSignUpEmailBasedSubscriptions -ne $false) { + $Issues.Add("Users can sign up for self-service subscriptions (allowedToSignUpEmailBasedSubscriptions: $($Cfg.allowedToSignUpEmailBasedSubscriptions))") + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Cloud IAM is configured with least privilege — users cannot create app registrations, tenants, security groups, or self-service subscriptions.' + } else { + $Status = 'Failed' + $Result = "Cloud IAM grants users administrative-level capabilities that should be restricted to dedicated admin accounts:`n`n- $($Issues -join "`n- ")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Privileged Access' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md new file mode 100644 index 000000000000..b3c485974260 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.md @@ -0,0 +1,18 @@ +SMB1001 (2.9) — Level 4+ — MFA on every account that can access important digital data. In Microsoft 365 the principal data stores are SharePoint Online, OneDrive for Business, and Exchange Online. The strongest implementation is a Conditional Access policy targeting all cloud apps (or specifically these three workloads) requiring MFA. + +**Remediation Action** + +Deploy a Conditional Access policy: + +- **Users**: All users (or those with access to data) +- **Cloud apps**: Office 365 (covers SPO/ODB/EXO) — or All cloud apps +- **Grant**: Require multi-factor authentication + +Use CIPP `standards.ConditionalAccessTemplate` with the Microsoft "Require MFA for all users" baseline template. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Conditional Access app: Office 365](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#office-365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 new file mode 100644 index 000000000000..251bfcd05a46 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_9.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestSMB1001_2_9 { + <# + .SYNOPSIS + Tests SMB1001 (2.9) - MFA where important digital data is stored + + .DESCRIPTION + Verifies MFA is enforced for every active member account that can access important + digital data. Uses the MFAState cache, which evaluates Conditional Access coverage, + Security Defaults state, and per-user MFA per user. + #> + param($Tenant) + + $TestId = 'SMB1001_2_9' + $Name = 'MFA is enforced where important digital data is stored' + + try { + $MFA = Get-CIPPTestData -TenantFilter $Tenant -Type 'MFAState' + + if (-not $MFA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'MFAState cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $ActiveMembers = @($MFA | Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' }) + + if ($ActiveMembers.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No active member accounts found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + return + } + + $Unprotected = @($ActiveMembers | Where-Object { + $_.CoveredByCA -notlike 'Enforced*' -and + $_.CoveredBySD -ne $true -and + $_.PerUser -notin @('Enforced', 'Enabled') + }) + + if ($Unprotected.Count -eq 0) { + $Status = 'Passed' + $Result = "All $($ActiveMembers.Count) active member account(s) accessing data-storing workloads are protected by MFA." + } else { + $Status = 'Failed' + $TableRows = foreach ($U in ($Unprotected | Select-Object -First 25)) { + "| $($U.UPN) | $($U.CoveredByCA) | $($U.CoveredBySD) | $($U.PerUser) |" + } + $Result = (@( + "$($Unprotected.Count) of $($ActiveMembers.Count) active member account(s) can access data-storing workloads (SharePoint, OneDrive, Exchange) without MFA:" + '' + '| User | Covered by CA | Security Defaults | Per-user MFA |' + '| :--- | :------------ | :---------------- | :----------- |' + ) + $TableRows) -join "`n" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Authentication' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md new file mode 100644 index 000000000000..1326ff0b8bda --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.md @@ -0,0 +1,20 @@ +SMB1001 (3.1) — Level 1+ — implement a backup and recovery strategy for important digital data, with at least one offline copy isolated from the business network and a six-month minimum recovery history. Microsoft 365 native data-preservation features (Litigation Hold, retention policies, archive mailboxes) cover part of the recovery surface but do not satisfy the offline-isolated backup requirement on their own — that needs a third-party M365 backup product (Veeam, Datto, Spanning, AvePoint, or Microsoft 365 Backup). + +This test verifies the M365-native preservation half. Evidence the offline-backup half to your Dynamic Standard Certifier separately. + +**Remediation Action** + +```powershell +# Enable Litigation Hold on all user mailboxes +Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited | + Set-Mailbox -LitigationHoldEnabled $true +``` + +Or use CIPP `standards.EnableLitigationHold`. Pair with a third-party M365 backup product for offline copies. + +**Links** +- [SMB1001:2026 Standard](https://dsi.org) +- [Litigation Hold in Exchange Online](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 new file mode 100644 index 000000000000..eec43b854577 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_3_1.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestSMB1001_3_1 { + <# + .SYNOPSIS + Tests SMB1001 (3.1) - Implement a backup and recovery strategy for important digital assets + + .DESCRIPTION + Verifies the M365 data preservation feature most relevant to recovery — Litigation Hold + on user mailboxes — is enabled where licensed. SMB1001 3.1 also requires offline isolated + backups; that requirement is met by a third-party M365 backup product and must be + evidenced separately. + #> + param($Tenant) + + $TestId = 'SMB1001_3_1' + $Name = 'Backup and recovery strategy preserves important digital data' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Mailboxes cache not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + return + } + + $UserMailboxes = @($Mailboxes | Where-Object { $_.recipientTypeDetails -eq 'UserMailbox' -and $_.LicensedForLitigationHold -eq $true }) + $WithoutHold = @($UserMailboxes | Where-Object { $_.LitigationHoldEnabled -ne $true }) + + if ($UserMailboxes.Count -eq 0) { + $Status = 'Informational' + $Result = 'No user mailboxes with a licence that supports Litigation Hold were found. SMB1001 (3.1) still requires an offline-isolated backup strategy — evidence the third-party backup product separately.' + } elseif ($WithoutHold.Count -eq 0) { + $Status = 'Passed' + $Result = "Litigation Hold is enabled on all $($UserMailboxes.Count) eligible user mailbox(es). Evidence the offline-isolated backup half of SMB1001 (3.1) separately (e.g., third-party M365 backup vendor)." + } else { + $Status = 'Failed' + $TableRows = foreach ($M in ($WithoutHold | Select-Object -First 25)) { "- $($M.UPN)" } + $Result = "$($WithoutHold.Count) of $($UserMailboxes.Count) eligible user mailbox(es) do not have Litigation Hold enabled. Without preservation, deleted email cannot be recovered after the retention window:`n`n$(($TableRows) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Data Protection' + } +} diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/report.json b/Modules/CIPPTests/Public/Tests/SMB1001/report.json new file mode 100644 index 000000000000..76b619b9826c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/SMB1001/report.json @@ -0,0 +1,30 @@ +{ + "name": "SMB1001:2026 Cybersecurity Standard", + "description": "Dynamic Standards International (DSI) SMB1001:2026 — multi-tiered cybersecurity certification for small and medium-sized businesses. The standard prescribes a five-level pathway across Technology Management, Access Management, Backup and Recovery, Policies/Processes/Plans, and Education and Training. CIPP tests cover the technical controls implementable against a Microsoft 365 tenant (Identity) and via Intune-managed workstations (Devices).", + "version": "2026 v1.0", + "source": "https://dsi.org", + "category": "SMB Security Baselines", + "IdentityTests": [ + "SMB1001_1_11", + "SMB1001_2_1", + "SMB1001_2_3", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_8", + "SMB1001_2_9", + "SMB1001_2_12", + "SMB1001_3_1" + ], + "DevicesTests": [ + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_2_2", + "SMB1001_4_7" + ] +} diff --git a/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 index f0cf0308dc7c..bfdbf623ca80 100644 --- a/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Get-SherwebCurrentSubscription.ps1 @@ -11,6 +11,10 @@ function Get-SherwebCurrentSubscription { $CustomerId = Get-ExtensionMapping -Extension 'Sherweb' | Where-Object { $_.RowKey -eq $TenantFilter } | Select-Object -ExpandProperty IntegrationId } + if ([string]::IsNullOrEmpty($CustomerId)) { + throw 'No Sherweb mapping found' + } + Write-Information "Getting current subscription for $CustomerId" $AuthHeader = Get-SherwebAuthentication $Uri = "https://api.sherweb.com/service-provider/v1/billing/subscriptions/details?customerId=$CustomerId" diff --git a/Modules/DNSHealth/1.1.6/DNSHealth.psd1 b/Modules/DNSHealth/1.1.7/DNSHealth.psd1 similarity index 99% rename from Modules/DNSHealth/1.1.6/DNSHealth.psd1 rename to Modules/DNSHealth/1.1.7/DNSHealth.psd1 index 92d590ea5bcf..be713cdf7187 100644 --- a/Modules/DNSHealth/1.1.6/DNSHealth.psd1 +++ b/Modules/DNSHealth/1.1.7/DNSHealth.psd1 @@ -12,7 +12,7 @@ RootModule = 'DNSHealth.psm1' # Version number of this module. - ModuleVersion = '1.1.6' + ModuleVersion = '1.1.7' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/Modules/DNSHealth/1.1.6/DNSHealth.psm1 b/Modules/DNSHealth/1.1.7/DNSHealth.psm1 similarity index 100% rename from Modules/DNSHealth/1.1.6/DNSHealth.psm1 rename to Modules/DNSHealth/1.1.7/DNSHealth.psm1 diff --git a/Modules/DNSHealth/1.1.6/MailProviders/AppRiver.json b/Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/AppRiver.json rename to Modules/DNSHealth/1.1.7/MailProviders/AppRiver.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/BarracudaESS.json b/Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/BarracudaESS.json rename to Modules/DNSHealth/1.1.7/MailProviders/BarracudaESS.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Google.json b/Modules/DNSHealth/1.1.7/MailProviders/Google.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Google.json rename to Modules/DNSHealth/1.1.7/MailProviders/Google.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/HornetSecurity.json b/Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/HornetSecurity.json rename to Modules/DNSHealth/1.1.7/MailProviders/HornetSecurity.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Intermedia.json b/Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Intermedia.json rename to Modules/DNSHealth/1.1.7/MailProviders/Intermedia.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Microsoft365.json b/Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Microsoft365.json rename to Modules/DNSHealth/1.1.7/MailProviders/Microsoft365.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Mimecast.json b/Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Mimecast.json rename to Modules/DNSHealth/1.1.7/MailProviders/Mimecast.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Null.json b/Modules/DNSHealth/1.1.7/MailProviders/Null.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Null.json rename to Modules/DNSHealth/1.1.7/MailProviders/Null.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Proofpoint.json b/Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Proofpoint.json rename to Modules/DNSHealth/1.1.7/MailProviders/Proofpoint.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Reflexion.json b/Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Reflexion.json rename to Modules/DNSHealth/1.1.7/MailProviders/Reflexion.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/Sophos.json b/Modules/DNSHealth/1.1.7/MailProviders/Sophos.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/Sophos.json rename to Modules/DNSHealth/1.1.7/MailProviders/Sophos.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/SpamTitan.json b/Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/SpamTitan.json rename to Modules/DNSHealth/1.1.7/MailProviders/SpamTitan.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/SymantecCloud.json b/Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/SymantecCloud.json rename to Modules/DNSHealth/1.1.7/MailProviders/SymantecCloud.json diff --git a/Modules/DNSHealth/1.1.6/MailProviders/_template.json b/Modules/DNSHealth/1.1.7/MailProviders/_template.json similarity index 100% rename from Modules/DNSHealth/1.1.6/MailProviders/_template.json rename to Modules/DNSHealth/1.1.7/MailProviders/_template.json diff --git a/Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml b/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml similarity index 85% rename from Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml rename to Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml index 0a8245a31c45..441517b7b04a 100644 --- a/Modules/DNSHealth/1.1.6/PSGetModuleInfo.xml +++ b/Modules/DNSHealth/1.1.7/PSGetModuleInfo.xml @@ -7,13 +7,13 @@ DNSHealth - 1.1.6 + 1.1.7 Module CIPP DNS Health Check Module John Duprey johnduprey 2023 John Duprey -
2026-04-24T17:42:26-04:00
+
2026-05-08T13:48:38-04:00
@@ -36,18 +36,14 @@ - Cmdlet + Workflow - RoleCapability - - - - Command + Function @@ -72,15 +68,15 @@ - DscResource + RoleCapability - Workflow + DscResource - Function + Command @@ -104,6 +100,10 @@ + + Cmdlet + + @@ -127,24 +127,24 @@ True True 0 - 477 + 491 31557 - 4/24/2026 5:42:26 PM -04:00 - 4/24/2026 5:42:26 PM -04:00 - 4/24/2026 5:42:26 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 + 5/8/2026 1:48:38 PM -04:00 PSModule PSFunction_Read-DmarcPolicy PSCommand_Read-DmarcPolicy PSFunction_Read-MtaStsPolicy PSCommand_Read-MtaStsPolicy PSFunction_Add-MailProvider PSCommand_Add-MailProvider PSFunction_Get-MailProvider PSCommand_Get-MailProvider PSFunction_Read-DkimRecord PSCommand_Read-DkimRecord PSFunction_Read-MtaStsRecord PSCommand_Read-MtaStsRecord PSFunction_Read-MXRecord PSCommand_Read-MXRecord PSFunction_Read-NSRecord PSCommand_Read-NSRecord PSFunction_Read-SPFRecord PSCommand_Read-SPFRecord PSFunction_Read-TlsRptRecord PSCommand_Read-TlsRptRecord PSFunction_Read-WhoisRecord PSCommand_Read-WhoisRecord PSFunction_Remove-MailProvider PSCommand_Remove-MailProvider PSFunction_Resolve-DnsHttpsQuery PSCommand_Resolve-DnsHttpsQuery PSFunction_Set-DnsResolver PSCommand_Set-DnsResolver PSFunction_Test-DNSSEC PSCommand_Test-DNSSEC PSFunction_Test-HttpsCertificate PSCommand_Test-HttpsCertificate PSFunction_Test-MtaSts PSCommand_Test-MtaSts PSIncludes_Function False - 2026-04-24T17:42:26Z - 1.1.6 + 2026-05-08T13:48:38Z + 1.1.7 John Duprey false Module - DNSHealth.nuspec|MailProviders\Microsoft365.json|MailProviders\Sophos.json|DNSHealth.psd1|MailProviders\SymantecCloud.json|MailProviders\SpamTitan.json|MailProviders\AppRiver.json|DNSHealth.psm1|MailProviders\Intermedia.json|MailProviders\_template.json|MailProviders\BarracudaESS.json|MailProviders\Reflexion.json|MailProviders\HornetSecurity.json|MailProviders\Google.json|MailProviders\Proofpoint.json|MailProviders\Null.json|MailProviders\Mimecast.json + DNSHealth.nuspec|MailProviders\SymantecCloud.json|MailProviders\Microsoft365.json|MailProviders\Sophos.json|DNSHealth.psd1|MailProviders\Intermedia.json|MailProviders\SpamTitan.json|MailProviders\AppRiver.json|DNSHealth.psm1|MailProviders\Reflexion.json|MailProviders\_template.json|MailProviders\BarracudaESS.json|MailProviders\Null.json|MailProviders\HornetSecurity.json|MailProviders\Google.json|MailProviders\Proofpoint.json|MailProviders\Mimecast.json a300d2b0-d468-46d1-88a3-e442a76b655b 7.0
- /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.6 + /Users/johnduprey/GitHub/CIPP Workspace/CIPP-API/Modules/DNSHealth/1.1.7