Public/Import-CISBaseline.ps1
|
function Import-CISBaseline { <# .SYNOPSIS Imports CIS Baseline policies from bundled templates .DESCRIPTION Imports CIS Benchmark and industry baseline policies from the Templates/CISBaselines directory. Supports Settings Catalog, Compliance, and Device Configuration policies. Routes each policy to the correct Graph API endpoint based on its @odata.type property. .PARAMETER BaselinePath Path to the CISBaselines directory (defaults to Templates/CISBaselines) .PARAMETER TenantId Target tenant ID (uses connected tenant if not specified) .PARAMETER Platform Filter imports by platform. Valid values: Windows, macOS, iOS, Android, Linux, All. Defaults to 'All'. Filters based on the 'platforms' property inside each JSON policy. .PARAMETER ImportMode Import mode: SkipIfExists (default - skip policies that already exist) .PARAMETER RemoveExisting Delete existing CIS baseline policies created by this kit instead of importing .EXAMPLE Import-CISBaseline .EXAMPLE Import-CISBaseline -Platform Windows .EXAMPLE Import-CISBaseline -RemoveExisting #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter()] [string]$BaselinePath, [Parameter()] [string]$TenantId, [Parameter()] [ValidateSet('Windows', 'macOS', 'iOS', 'Android', 'Linux', 'All')] [string[]]$Platform = @('All'), [Parameter()] [ValidateSet('SkipIfExists')] [string]$ImportMode = 'SkipIfExists', [Parameter()] [switch]$RemoveExisting ) if (-not $TenantId) { $TenantId = $script:HydrationState.TenantId } if (-not $TenantId) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("TenantId is required. Either connect using Connect-IntuneHydration or specify -TenantId parameter."), 'MissingTenantId', [System.Management.Automation.ErrorCategory]::InvalidArgument, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Resolve BaselinePath to bundled templates if not provided if ($BaselinePath -and -not (Test-Path -Path $BaselinePath)) { Write-Verbose "Specified BaselinePath '$BaselinePath' not found, using bundled templates" $BaselinePath = $null } if (-not $BaselinePath) { if ($script:TemplatesPath -and (Test-Path -Path $script:TemplatesPath)) { $BaselinePath = Join-Path -Path $script:TemplatesPath -ChildPath 'CISBaselines' } elseif ($script:ModuleRoot -and (Test-Path -Path $script:ModuleRoot)) { $BaselinePath = Join-Path -Path (Join-Path -Path $script:ModuleRoot -ChildPath 'Templates') -ChildPath 'CISBaselines' } else { $scriptPath = $MyInvocation.MyCommand.ScriptBlock.File if ($scriptPath) { $moduleRoot = Split-Path -Parent (Split-Path -Parent $scriptPath) $BaselinePath = Join-Path -Path (Join-Path -Path $moduleRoot -ChildPath 'Templates') -ChildPath 'CISBaselines' } elseif (-not $RemoveExisting) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Cannot determine CIS Baselines path. Please specify -BaselinePath parameter."), 'BaselinePathNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } } if (-not $RemoveExisting -and (-not $BaselinePath -or -not (Test-Path -Path $BaselinePath))) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("CIS Baseline templates not found at: $BaselinePath"), 'BaselineTemplatesNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $BaselinePath ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $importMetadata = Get-BaselineImportMetadata -Kind 'CIS' $odataTypeToEndpoint = $importMetadata.ODataTypeToEndpoint $odataContextToEndpoint = $importMetadata.ODataContextToEndpoint $platformValueMapping = $importMetadata.PlatformValueMapping $endpointNamePropertyMap = @{ 'deviceManagement/configurationPolicies' = 'name' 'deviceManagement/compliancePolicies' = 'name' } $results = @() function Get-CISNameProperty { param( [Parameter(Mandatory)] [string]$Endpoint ) if ($endpointNamePropertyMap.ContainsKey($Endpoint)) { return $endpointNamePropertyMap[$Endpoint] } return 'displayName' } # Remove existing CIS baseline policies if requested if ($RemoveExisting) { if ([string]::IsNullOrWhiteSpace($BaselinePath) -or -not (Test-Path -Path $BaselinePath)) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("A resolvable CIS baseline template path is required when using -RemoveExisting. Unable to load templates from: $BaselinePath"), 'BaselinePathRequiredForDelete', [System.Management.Automation.ErrorCategory]::InvalidArgument, $BaselinePath ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $knownTemplateNames = Get-TemplateDisplayNames -Path $BaselinePath -NameProperty 'name' -Recurse if (-not $knownTemplateNames -or $knownTemplateNames.Count -eq 0) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Unable to load CIS baseline template names from '$BaselinePath'. Deletion is blocked to avoid removing hydration-marked objects without template matching."), 'TemplateNamesRequiredForDelete', [System.Management.Automation.ErrorCategory]::InvalidData, $BaselinePath ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $deleteEndpoints = @( 'beta/deviceManagement/configurationPolicies', 'beta/deviceManagement/groupPolicyConfigurations', 'beta/deviceManagement/intents', 'beta/deviceManagement/compliancePolicies', 'beta/deviceManagement/deviceCompliancePolicies', 'beta/deviceManagement/deviceConfigurations' ) $policiesToDelete = Get-HydrationDeleteCandidates -Endpoint $deleteEndpoints -KnownTemplateNames $knownTemplateNames -RequireTemplateMatch if ($policiesToDelete.Count -eq 0) { Write-Verbose "No CIS baseline policies found to delete" return $results } if (-not $PSCmdlet.ShouldProcess("$($policiesToDelete.Count) CIS baseline policies", "Delete")) { if ($WhatIfPreference) { foreach ($policy in $policiesToDelete) { Write-HydrationLog -Message " WouldDelete: $($policy.Name)" -Level Info $results += New-HydrationResult -Name $policy.Name -Type 'CISBaselinePolicy' -Action 'WouldDelete' -Status 'DryRun' } } return $results } $results += Invoke-GraphBatchOperation -Items $policiesToDelete -Operation 'DELETE' -ResultType 'CISBaselinePolicy' return $results } # Collect all JSON files from category folders (flat structure: category/policy.json) $categoryFolders = Get-ChildItem -Path $BaselinePath -Directory | Where-Object { $_.Name -notmatch '^\.' } # Collect all policies with their metadata $allPolicies = @() foreach ($categoryFolder in $categoryFolders) { $jsonFiles = Get-ChildItem -Path $categoryFolder.FullName -Filter "*.json" -File -Recurse foreach ($jsonFile in $jsonFiles) { $allPolicies += @{ File = $jsonFile Category = $categoryFolder.Name } } } $totalPolicies = $allPolicies.Count if ($totalPolicies -eq 0) { Write-Verbose "No CIS baseline policies found to import" return $results } $shouldImport = $PSCmdlet.ShouldProcess("$totalPolicies policies from CIS Baselines", "Import to Intune") if (-not $shouldImport -and -not $WhatIfPreference) { return $results } $logParams = @{ Message = "Discovered $totalPolicies CIS baseline templates across $($categoryFolders.Count) folders" Level = 'Info' } Write-HydrationLog @logParams # Pre-fetch existing policies from all unique endpoints $endpointPolicyCache = @{} $failedCacheEndpoints = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $uniqueEndpoints = $odataTypeToEndpoint.Values | Sort-Object -Unique $cacheIndex = 0 foreach ($cacheEndpoint in $uniqueEndpoints) { $cacheIndex++ $logParams = @{ Message = "Caching existing CIS policies ($cacheIndex/$($uniqueEndpoints.Count)): $cacheEndpoint" Level = 'Info' } Write-HydrationLog @logParams $endpointPolicyCache[$cacheEndpoint] = @{} try { Get-GraphPagedResults -Uri "beta/$cacheEndpoint" -ProcessItems { param($items) foreach ($policy in $items) { $nameProperty = Get-CISNameProperty -Endpoint $cacheEndpoint $policyDisplayName = if ($nameProperty -eq 'name') { if ($policy.name) { $policy.name } elseif ($policy.displayName) { $policy.displayName } else { $null } } else { if ($policy.displayName) { $policy.displayName } elseif ($policy.name) { $policy.name } else { $null } } if ($policyDisplayName -and -not $endpointPolicyCache[$cacheEndpoint].ContainsKey($policyDisplayName)) { $endpointPolicyCache[$cacheEndpoint][$policyDisplayName] = @{ Id = $policy.id } } } } } catch { Write-Verbose "Could not cache policies from $cacheEndpoint - will check individually" [void]$failedCacheEndpoints.Add($cacheEndpoint) } } $logParams = @{ Message = 'Caching complete. Preparing CIS baseline payloads...' Level = 'Info' } Write-HydrationLog @logParams $policiesToCreate = @() function Update-CISSecretSettingValues { param( [Parameter(Mandatory)] [object]$Node ) if ($null -eq $Node) { return } $hasProperties = $Node.PSObject -and $Node.PSObject.Properties if (-not $hasProperties) { return } if ($Node.PSObject.Properties['settingDefinitionId'] -and $Node.PSObject.Properties['simpleSettingValue']) { $settingDefinitionId = [string]$Node.settingDefinitionId $settingName = ($settingDefinitionId -split '[_-]')[-1] $simpleSettingValue = $Node.simpleSettingValue if ($simpleSettingValue -and $simpleSettingValue.PSObject.Properties['@odata.type'] -and $simpleSettingValue.'@odata.type' -eq '#microsoft.graph.deviceManagementConfigurationStringSettingValue' -and $settingName -match '(?i)(password|secret|credential)s?$') { $simpleSettingValue.'@odata.type' = '#microsoft.graph.deviceManagementConfigurationSecretSettingValue' if ($simpleSettingValue.PSObject.Properties['valueState']) { $simpleSettingValue.valueState = 'notEncrypted' } else { $simpleSettingValue | Add-Member -MemberType NoteProperty -Name valueState -Value 'notEncrypted' } } } foreach ($property in $Node.PSObject.Properties) { $value = $property.Value if ($null -eq $value -or $value -is [string]) { continue } if ($value -is [System.Collections.IEnumerable]) { foreach ($item in $value) { Update-CISSecretSettingValues -Node $item } continue } Update-CISSecretSettingValues -Node $value } } foreach ($policyEntry in $allPolicies) { $jsonFile = $policyEntry.File $category = $policyEntry.Category $policyName = [System.IO.Path]::GetFileNameWithoutExtension($jsonFile.Name) try { $jsonContent = Get-Content -Path $jsonFile.FullName -Raw -Encoding utf8 $jsonContent = $jsonContent.TrimStart([char]0xFEFF) $policyContent = $jsonContent | ConvertFrom-Json $odataType = $policyContent.'@odata.type' $odataContext = $policyContent.'@odata.context' # Some exported templates omit @odata.type but still include enough metadata to infer the endpoint. $typeEndpoint = $null if ($odataType -and $odataTypeToEndpoint.ContainsKey($odataType)) { $typeEndpoint = $odataTypeToEndpoint[$odataType] } elseif ($odataContext) { foreach ($contextFragment in $odataContextToEndpoint.Keys) { if ($odataContext -like "*$contextFragment*") { $typeEndpoint = $odataContextToEndpoint[$contextFragment] break } } } elseif ($policyContent.PSObject.Properties['settings'] -and $policyContent.PSObject.Properties['platforms'] -and $policyContent.PSObject.Properties['technologies']) { $typeEndpoint = 'deviceManagement/configurationPolicies' } if (-not $typeEndpoint) { $unsupportedType = if ($odataType) { $odataType } else { '<missing>' } Write-Verbose " Skipping $policyName - unsupported @odata.type: $unsupportedType" $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "CISBaseline/$category" -Action 'Skipped' -Status "Unsupported @odata.type: $unsupportedType" continue } # Platform filtering based on JSON platforms property if ($Platform -and $Platform -notcontains 'All') { $policyPlatformValues = @($policyContent.platforms | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($policyPlatformValues.Count -gt 0) { $mappedPlatforms = @() foreach ($policyPlatformValue in $policyPlatformValues) { $mappedPlatform = $platformValueMapping[[string]$policyPlatformValue] if ($null -ne $mappedPlatform) { $mappedPlatforms += $mappedPlatform } } if ($mappedPlatforms.Count -eq 0) { $platformLabel = $policyPlatformValues -join ', ' Write-Warning "Skipping $policyName - unrecognized platform value '$platformLabel' (not in platform mapping)" $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "CISBaseline/$category" -Action 'Skipped' -Status "Unrecognized platform: $platformLabel" continue } if (-not ($mappedPlatforms | Where-Object { $_ -in $Platform })) { $platformLabel = $policyPlatformValues -join ', ' Write-Verbose " Skipping $policyName - platform '$platformLabel' not in filter" continue } } } # Get display name with import prefix $displayName = if ($policyContent.displayName) { "$($script:ImportPrefix)$($policyContent.displayName)" } elseif ($policyContent.name) { "$($script:ImportPrefix)$($policyContent.name)" } else { "$($script:ImportPrefix)$policyName" } # Check if policy exists using pre-fetched cache $unprefixedName = if ($policyContent.displayName) { $policyContent.displayName } elseif ($policyContent.name) { $policyContent.name } else { $policyName } $lookupNames = @($displayName) if ($unprefixedName -ne $displayName) { $lookupNames += $unprefixedName } $existingPolicy = $null $matchedName = $null if ($failedCacheEndpoints.Contains($typeEndpoint)) { # Cache unavailable for this endpoint - fall back to per-policy individual existence check. # Single quotes in the name are escaped by doubling them, which is the correct OData string escape. $nameField = Get-CISNameProperty -Endpoint $typeEndpoint foreach ($lookupName in $lookupNames) { $odataFilter = "$nameField eq '$($lookupName -replace "'", "''")'" try { $lookupResult = Invoke-MgGraphRequest -Method GET -Uri "beta/$($typeEndpoint)?`$filter=$odataFilter&`$top=1" -ErrorAction Stop if ($lookupResult.value -and $lookupResult.value.Count -gt 0) { $existingPolicy = @{ Id = $lookupResult.value[0].id } break } } catch { Write-Verbose "Individual policy lookup failed for '$lookupName' in $typeEndpoint - assuming not present: $_" } } } else { $matchedName = $lookupNames | Where-Object { $endpointPolicyCache[$typeEndpoint].ContainsKey($_) } | Select-Object -First 1 $existingPolicy = if ($matchedName) { $endpointPolicyCache[$typeEndpoint][$matchedName] } else { $null } } if ($existingPolicy -and $ImportMode -eq 'SkipIfExists') { $alreadyExists = $true $verifyUri = "beta/$typeEndpoint/$($existingPolicy.Id)" try { $null = Invoke-MgGraphRequest -Method GET -Uri $verifyUri -ErrorAction Stop } catch { if ($_.Exception.Message -match '404|NotFound') { Write-Verbose "Policy '$displayName' returned 404 on verify - stale data, will create" $alreadyExists = $false if ($matchedName) { $null = $endpointPolicyCache[$typeEndpoint].Remove($matchedName) } } else { throw } } if ($alreadyExists) { Write-HydrationLog -Message " Skipped: $displayName" -Level Info $results += New-HydrationResult -Name $displayName -Path $jsonFile.FullName -Type "CISBaseline/$category" -Action 'Skipped' -Status 'Already exists' continue } } # Prepare import body $importBody = Copy-DeepObject -InputObject $policyContent Remove-ReadOnlyGraphProperties -InputObject $importBody -AdditionalProperties @( 'supportsScopeTags', 'deviceManagementApplicabilityRuleOsEdition', 'deviceManagementApplicabilityRuleOsVersion', 'deviceManagementApplicabilityRuleDeviceMode', '@odata.id', '@odata.editLink', 'creationSource', 'settingCount', 'priorityMetaData', 'assignments', 'settingDefinitions', 'isAssigned' ) # Add hydration kit tag to description $importBody.description = New-HydrationDescription -ExistingText $importBody.description # Apply import prefix to body properties if ($importBody.displayName) { $importBody.displayName = $displayName } if ($importBody.name) { $importBody.name = $displayName } if ($typeEndpoint -eq 'deviceManagement/groupPolicyConfigurations' -and $importBody.PSObject.Properties['definitionValues']) { $importBody.definitionValues = @($importBody.definitionValues) foreach ($definitionValue in $importBody.definitionValues) { if ($definitionValue -and $definitionValue.PSObject.Properties['presentationValues']) { $definitionValue.presentationValues = @($definitionValue.presentationValues) } } } # Remove @odata metadata and action properties except @odata.type $metadataProps = @($importBody.PSObject.Properties | Where-Object { ($_.Name -match '^@odata\.' -and $_.Name -ne '@odata.type') -or ($_.Name -match '@odata\.') -or ($_.Name -match '^#microsoft\.graph\.') }) foreach ($prop in $metadataProps) { if ($prop.Name -ne '@odata.type') { $importBody.PSObject.Properties.Remove($prop.Name) } } # Settings Catalog policies need special clean body construction if ($typeEndpoint -eq 'deviceManagement/configurationPolicies') { $cleanBody = @{ name = $importBody.name description = $importBody.description platforms = $importBody.platforms technologies = $importBody.technologies settings = @() } if ($importBody.roleScopeTagIds) { $cleanBody.roleScopeTagIds = $importBody.roleScopeTagIds } if ($importBody.templateReference -and $importBody.templateReference.templateId) { $cleanBody.templateReference = @{ templateId = $importBody.templateReference.templateId } } if ($importBody.settings) { foreach ($setting in $importBody.settings) { $cleanSetting = $setting | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json $propsToRemove = @($cleanSetting.PSObject.Properties | Where-Object { $_.Name -eq 'id' -or $_.Name -match '@odata\.' -or $_.Name -eq 'settingDefinitions' }) foreach ($prop in $propsToRemove) { $cleanSetting.PSObject.Properties.Remove($prop.Name) } Update-CISSecretSettingValues -Node $cleanSetting $cleanBody.settings += $cleanSetting } } $importBody = [PSCustomObject]$cleanBody } # Compliance policies: clean scheduledActionsForRule if ($importBody.scheduledActionsForRule) { $cleanedActions = @() foreach ($action in $importBody.scheduledActionsForRule) { $cleanAction = @{ ruleName = $action.ruleName } if ($action.scheduledActionConfigurations) { $cleanConfigs = @() foreach ($config in $action.scheduledActionConfigurations) { $ccList = @() if ($null -ne $config.notificationMessageCCList -and $config.notificationMessageCCList.Count -gt 0) { $ccList = @($config.notificationMessageCCList) } $cleanConfig = @{ actionType = $config.actionType gracePeriodHours = [int]$config.gracePeriodHours notificationTemplateId = if ($config.notificationTemplateId) { $config.notificationTemplateId } else { "" } notificationMessageCCList = $ccList } $cleanConfigs += $cleanConfig } $cleanAction.scheduledActionConfigurations = $cleanConfigs } $cleanedActions += $cleanAction } $importBody.scheduledActionsForRule = $cleanedActions } $policiesToCreate += @{ Name = $displayName Path = $jsonFile.FullName Type = "CISBaseline/$category" Url = "/$typeEndpoint" BodyJson = ($importBody | ConvertTo-Json -Depth 100 -Compress) } } catch { $errorMsg = Get-GraphErrorMessage -ErrorRecord $_ Write-HydrationLog -Message " Failed to prepare: $policyName - $errorMsg" -Level Warning $results += New-HydrationResult -Name $policyName -Path $jsonFile.FullName -Type "CISBaseline/$category" -Action 'Failed' -Status "Prepare error: $errorMsg" } } # Batch create all collected policies if ($policiesToCreate.Count -gt 0) { if ($shouldImport) { $logParams = @{ Message = "Prepared $($policiesToCreate.Count) CIS baseline payloads. Starting batch import..." Level = 'Info' } Write-HydrationLog @logParams $results += Invoke-GraphBatchOperation -Items $policiesToCreate -Operation 'POST' -ResultType 'CISBaselinePolicy' } else { $logParams = @{ Message = "Prepared $($policiesToCreate.Count) CIS baseline payloads. Reporting WhatIf results..." Level = 'Info' } Write-HydrationLog @logParams foreach ($policyToCreate in $policiesToCreate) { $results += New-HydrationResult -Name $policyToCreate.Name -Path $policyToCreate.Path -Type $policyToCreate.Type -Action 'WouldCreate' -Status 'DryRun' } } } return $results } |