Public/Import-CISBenchmarkToIntune.ps1
|
<# .SYNOPSIS Imports CIS Benchmark recommendations to Intune. .DESCRIPTION Imports CIS Windows 11 Enterprise Benchmark recommendations to Intune, organized by category and level. Supports Level 1 (Corporate/Enterprise) and Level 2 (High Security/Sensitive Data) recommendations. .EXAMPLE Import-CISBenchmarkToIntune -Level All -Category All Import-CISBenchmarkToIntune -Level Level1 -Category "Account Policies" #> function Import-CISBenchmarkToIntune { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidateSet("Level1", "Level2", "All")] [string]$Level = "All", [Parameter(Mandatory = $false)] [ValidateSet("Account Policies", "Local Policies", "Windows Services", "Windows Firewall", "User Rights Assignment", "Security Options", "Additional Security", "All")] [string]$Category = "All", [switch]$DryRun, [switch]$ValidateOnly ) $ErrorActionPreference = "Continue" $workspacePath = Get-WorkspacePath if (-not $workspacePath) { Write-Error "Workspace not configured. Run Initialize-NLBaseline first." throw "Workspace not configured" } $config = Get-Config -WorkspacePath $workspacePath -ErrorAction SilentlyContinue if (-not $ValidateOnly) { if (-not $config -or [string]::IsNullOrEmpty($config.AppRegistration.ClientId) -or [string]::IsNullOrEmpty($config.AppRegistration.ClientSecret) -or [string]::IsNullOrEmpty($config.AppRegistration.TenantId)) { Write-Error "App Registration not configured in config.json." throw "App Registration not configured" } } Write-Host "`nImporting CIS Benchmark to Intune`n" -ForegroundColor Cyan Write-Host "Level: $Level" -ForegroundColor White Write-Host "Category: $Category" -ForegroundColor White # CIS sections mapping - with friendly names $sectionMap = @{ "Account Policies" = @("section_1") "Local Policies" = @("section_2") "Windows Services" = @("section_5") "Windows Firewall" = @("section_9") "User Rights Assignment" = @("section_17") "Security Options" = @("section_18") "Additional Security" = @("section_19") } # Try to find CIS benchmark files in multiple locations $cisBasePath = $null # Ensure workspacePath is a string if ($workspacePath -is [Array]) { $workspacePath = $workspacePath[0] Write-Warning "WorkspacePath was an array, using first element: $workspacePath" } # Get module path safely $moduleRoot = $PSScriptRoot if ($moduleRoot -is [Array]) { $moduleRoot = $moduleRoot[0] } if (-not ($moduleRoot -is [String])) { $moduleRoot = Split-Path -Path $MyInvocation.MyCommand.Path -Parent } # PRIORITY: Search in module Resources FIRST (permanent location in project) $possiblePaths = @() # 1. Module Resources (PERMANENT - in project itself) if ($moduleRoot -is [String] -and -not [string]::IsNullOrWhiteSpace($moduleRoot)) { $possiblePaths += Join-Path -Path $moduleRoot -ChildPath "..\Resources\CIS\Windows-11-CIS-devel\tasks" $possiblePaths += Join-Path -Path $moduleRoot -ChildPath "..\Resources\CIS\tasks" } # 2. Workspace (synced from module) if ($workspacePath -is [String] -and -not [string]::IsNullOrWhiteSpace($workspacePath)) { $possiblePaths += Join-Path -Path $workspacePath -ChildPath "CIS\Windows-11-CIS-devel\tasks" $possiblePaths += Join-Path -Path $workspacePath -ChildPath "CIS\tasks" } # 3. Sample location (fallback - will be removed) $samplePath = Join-Path -Path (Split-Path -Path $moduleRoot -Parent -ErrorAction SilentlyContinue) -ChildPath "..\_sample\CIS\Windows-11-CIS-devel\tasks" -ErrorAction SilentlyContinue if ($samplePath) { $possiblePaths += $samplePath } foreach ($path in $possiblePaths) { if (Test-Path $path) { $cisBasePath = $path break } } if (-not $cisBasePath) { # CIS files should be in module Resources - if not found, sync them Write-Host "CIS benchmark files not found in module Resources. Attempting to sync..." -ForegroundColor Yellow try { Sync-NLBaselineWorkspace -Force Start-Sleep -Seconds 2 # Retry finding CIS files after sync - check module Resources first if ($moduleRoot -is [String] -and -not [string]::IsNullOrWhiteSpace($moduleRoot)) { $moduleCISPath = Join-Path -Path $moduleRoot -ChildPath "..\Resources\CIS\Windows-11-CIS-devel\tasks" if (Test-Path $moduleCISPath) { $cisBasePath = $moduleCISPath } } # If still not found, check workspace if (-not $cisBasePath) { foreach ($path in $possiblePaths) { if (Test-Path $path) { $cisBasePath = $path break } } } } catch { Write-Warning "Failed to sync CIS files: $_" } if (-not $cisBasePath) { $errorMsg = "CIS benchmark files not found. Searched in:`n$($possiblePaths -join "`n")`n`nCIS Benchmark files are REQUIRED and should be in NLBaseline\Resources\CIS\Windows-11-CIS-devel\tasks. Run Sync-NLBaselineWorkspace -Force to sync files from module to workspace." Write-Error $errorMsg throw $errorMsg } } Write-Verbose "Using CIS benchmark path: $cisBasePath" # AUTO-DETECT all available sections from the CIS tasks folder Write-Host "Detecting available CIS sections..." -ForegroundColor Gray $allAvailableSections = @() if (Test-Path $cisBasePath) { $detectedSections = Get-ChildItem -Path $cisBasePath -Directory | Where-Object { $_.Name -match '^section_\d+$' } | ForEach-Object { $_.Name } | Sort-Object { [int]($_.Name -replace 'section_','') } $allAvailableSections = $detectedSections Write-Host "Found $($allAvailableSections.Count) sections: $($allAvailableSections -join ', ')" -ForegroundColor Green } # Determine which sections to process $sectionsToProcess = @() if ($Category -eq "All") { # Use ALL detected sections if available, otherwise fall back to sectionMap if ($allAvailableSections.Count -gt 0) { $sectionsToProcess = $allAvailableSections Write-Host "Processing ALL $($sectionsToProcess.Count) detected sections" -ForegroundColor Cyan } else { # Fallback to sectionMap if auto-detection failed foreach ($catSections in $sectionMap.Values) { if ($catSections -is [Array]) { foreach ($section in $catSections) { if ($section -is [String] -and -not [string]::IsNullOrWhiteSpace($section)) { $sectionsToProcess += $section } } } elseif ($catSections -is [String] -and -not [string]::IsNullOrWhiteSpace($catSections)) { $sectionsToProcess += $catSections } } } } else { # Get sections for specific category - ensure it's a flat array $catSections = $sectionMap[$Category] if ($catSections -is [Array]) { foreach ($section in $catSections) { if ($section -is [String] -and -not [string]::IsNullOrWhiteSpace($section)) { $sectionsToProcess += $section } } } elseif ($catSections -is [String] -and -not [string]::IsNullOrWhiteSpace($catSections)) { $sectionsToProcess += $catSections } } # Ensure all sections are strings (defensive check) $sectionsToProcess = $sectionsToProcess | Where-Object { $_ -is [String] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique if ($sectionsToProcess.Count -eq 0) { Write-Warning "No valid sections to process for CIS Benchmark." return } if ($DryRun) { Write-Host "[DryRun] Would import CIS Benchmark recommendations" -ForegroundColor Cyan Write-Host "Sections to process: $($sectionsToProcess -join ', ')" -ForegroundColor Gray return } if (-not $ValidateOnly) { $connected = Connect-Intune -Config $config if (-not $connected) { $errorMsg = "Failed to connect to Microsoft Graph." Write-Error $errorMsg throw $errorMsg } } else { Write-Host "[ValidateOnly] Skipping Graph connect; validating payload only." -ForegroundColor Cyan } $allSettings = @() $policyCount = 0 # Process each section foreach ($section in $sectionsToProcess) { # Ensure section is a string before using in Join-Path if (-not ($section -is [String])) { Write-Warning "Skipping invalid section (not a string): $($section.GetType().FullName)" continue } $sectionPath = Join-Path -Path $cisBasePath -ChildPath $section if (-not (Test-Path $sectionPath)) { Write-Warning "Section path not found: $sectionPath" continue } Write-Host "`nProcessing section: $section" -ForegroundColor Yellow # Find ALL CIS control YAML files recursively (including subsections) $yamlFiles = Get-ChildItem -Path $sectionPath -Recurse -Filter "*.yml" | Where-Object { $_.Name -notmatch 'main\.yml|prelim\.yml|post\.yml|warning_facts\.yml' } Write-Host " Found $($yamlFiles.Count) CIS control files in $section" -ForegroundColor Cyan foreach ($yamlFile in $yamlFiles) { Write-Host " Parsing: $($yamlFile.Name)" -ForegroundColor Gray try { # Load Parse-CISYAML function if not available if (-not (Get-Command Parse-CISYAML -ErrorAction SilentlyContinue)) { $parseScript = Join-Path -Path $moduleRoot -ChildPath "..\Private\Parse-CISYAML.ps1" if (Test-Path $parseScript) { . $parseScript } else { Write-Warning "Parse-CISYAML function not found at: $parseScript" continue } } if (-not (Get-Command Parse-CISYAML -ErrorAction SilentlyContinue)) { Write-Warning "Parse-CISYAML function could not be loaded. Skipping $($yamlFile.Name)" continue } $parsedSettings = Parse-CISYAML -YAMLPath $yamlFile.FullName -Level $Level if ($parsedSettings) { if ($parsedSettings -is [Array]) { $allSettings += $parsedSettings } else { $allSettings += @($parsedSettings) } } } catch { Write-Warning "Failed to parse $($yamlFile.Name): $_" } } } Write-Host "`n=== CIS BENCHMARK PROCESSING SUMMARY ===" -ForegroundColor Cyan Write-Host "Total sections processed: $($sectionsToProcess.Count)" -ForegroundColor White Write-Host "Total settings found: $($allSettings.Count)" -ForegroundColor Cyan if ($allSettings.Count -eq 0) { $errorMsg = "No CIS settings found matching the specified criteria (Level: $Level, Category: $Category). This indicates a problem with the CIS benchmark files or parsing." Write-Error $errorMsg throw $errorMsg } Write-Host "Total CIS controls to deploy: $($allSettings.Count)" -ForegroundColor Green # Filter out settings that require scripts (audit policies) $scriptRequiredSettings = $allSettings | Where-Object { $_.RequiresScript -eq $true } $importableSettings = $allSettings | Where-Object { $_.RequiresScript -ne $true } if ($scriptRequiredSettings.Count -gt 0) { Write-Host "`nNote: $($scriptRequiredSettings.Count) settings require Intune Scripts (Audit Policies)" -ForegroundColor Yellow Write-Host "These will be documented but not imported via Device Configuration" -ForegroundColor Gray } # Group settings by category for policy creation $settingsByCategory = $importableSettings | Group-Object -Property { if ($_.Type -eq "Registry") { "Registry" } elseif ($_.Type -eq "SecurityPolicy") { "SecurityPolicy" } elseif ($_.Type -eq "UserRight") { "UserRights" } elseif ($_.Type -eq "Service") { "Services" } else { "Other" } } foreach ($categoryGroup in $settingsByCategory) { $categoryName = $categoryGroup.Name $categorySettings = $categoryGroup.Group Write-Host "`nCreating policy for category: $categoryName ($($categorySettings.Count) settings)" -ForegroundColor Green $omaSettings = @() foreach ($setting in $categorySettings) { try { $settingName = $setting.RegistryName if (-not $settingName) { $settingName = $setting.Key } if (-not $settingName) { $settingName = $setting.RightName } if (-not $settingName) { $settingName = $setting.ServiceName } $omaSetting = @{ "@odata.type" = "#microsoft.graph.omaSettingString" displayName = "CIS $($setting.ControlId) - $settingName" description = "CIS Benchmark Control $($setting.ControlId)" omaUri = $setting.OMAUri } # Determine value type and set value if ($setting.DataType) { switch ($setting.DataType.ToLower()) { "dword" { $omaSetting["@odata.type"] = "#microsoft.graph.omaSettingInteger" $intVal = 0 if ([int]::TryParse($setting.Value, [ref]$intVal)) { $omaSetting["value"] = $intVal } else { $omaSetting["value"] = 0 } } "qword" { $omaSetting["@odata.type"] = "#microsoft.graph.omaSettingInteger" $longVal = 0L if ([long]::TryParse($setting.Value, [ref]$longVal)) { $omaSetting["value"] = $longVal } else { $omaSetting["value"] = 0 } } default { $omaSetting["value"] = if ($null -eq $setting.Value) { "" } else { [string]$setting.Value } } } } elseif ($null -ne $setting.Value -and $setting.Value -ne "") { # Try to parse as integer $intValue = 0 if ([int]::TryParse([string]$setting.Value, [ref]$intValue)) { $omaSetting["@odata.type"] = "#microsoft.graph.omaSettingInteger" $omaSetting["value"] = $intValue } else { $omaSetting["value"] = [string]$setting.Value } } elseif ($setting.Users) { # User rights assignment - convert to SID format $userSids = @() foreach ($user in $setting.Users) { $userTrimmed = $user.Trim() if ($userTrimmed -eq "Administrators") { $userSids += "S-1-5-32-544" } elseif ($userTrimmed -eq "Users") { $userSids += "S-1-5-32-545" } elseif ($userTrimmed -eq "Local Service") { $userSids += "S-1-5-19" } elseif ($userTrimmed -eq "Network Service") { $userSids += "S-1-5-20" } elseif ($userTrimmed -eq "Remote Desktop Users") { $userSids += "S-1-5-32-555" } elseif ($userTrimmed -eq "") { continue } else { $userSids += $userTrimmed } } if ($userSids.Count -eq 0) { $omaSetting["value"] = "" } else { $omaSetting["value"] = $userSids -join "," } } else { Write-Warning "Setting $($setting.ControlId) has no value; skipping" continue } # Graph requires every OMA setting to have a non-null Value if (-not ($omaSetting.ContainsKey("value")) -or $null -eq $omaSetting["value"]) { if ($omaSetting["@odata.type"] -eq "#microsoft.graph.omaSettingInteger") { $omaSetting["value"] = 0 } else { $omaSetting["value"] = "" } } $omaSettings += $omaSetting } catch { Write-Warning "Failed to process setting $($setting.ControlId): $_" } } # Deduplicate by OMA-URI (Graph rejects duplicate OMA-URIs per profile). Keep first. $seenOmaUri = @{} $deduped = [System.Collections.ArrayList]::new() foreach ($o in $omaSettings) { $key = if ($o.omaUri) { $o.omaUri.Trim().ToUpperInvariant() } else { "" } if ([string]::IsNullOrEmpty($key) -or $seenOmaUri.ContainsKey($key)) { continue } $seenOmaUri[$key] = $true [void]$deduped.Add($o) } $omaSettings = @($deduped) # Drop any OMA setting missing Value (Graph requires it) $omaSettings = @($omaSettings | ForEach-Object { $s = $_ if (-not ($s.ContainsKey("value"))) { return $null } if ($null -eq $s["value"]) { if ($s["@odata.type"] -eq "#microsoft.graph.omaSettingInteger") { $s["value"] = 0 } else { $s["value"] = "" } } $s } | Where-Object { $null -ne $_ }) # Pre-POST validation: every OMA setting must have value for ($i = 0; $i -lt $omaSettings.Count; $i++) { $o = $omaSettings[$i] if (-not $o.ContainsKey("value")) { throw "CIS validation failed: OmaSettings[$i] missing value. omaUri=$($o.omaUri). Fix before POST." } if ($null -eq $o["value"] -and $o["@odata.type"] -eq "#microsoft.graph.omaSettingInteger") { $o["value"] = 0 } elseif ($null -eq $o["value"]) { $o["value"] = "" } } if ($omaSettings.Count -gt 0) { if ($ValidateOnly) { Write-Host " [ValidateOnly] $categoryName OK ($($omaSettings.Count) settings, no dupes, all have Value)" -ForegroundColor Green $policyCount++ continue } try { $policyName = "NLBaseline - CIS Benchmark - $categoryName" if ($Level -ne "All") { $policyName += " - $Level" } $removed = Remove-IntunePolicyByDisplayName -DisplayName $policyName -PolicyType "Configuration" if ($removed) { Write-Host "Removed existing policy: $policyName" -ForegroundColor Yellow } # Rebuild OMA array: explicit "value" (lowercase), never null. Graph rejects empty string – skip those. $omaForJson = [System.Collections.ArrayList]::new() $skippedEmpty = 0 foreach ($o in $omaSettings) { $v = $o["value"] if ($null -eq $v) { $v = if ($o["@odata.type"] -eq "#microsoft.graph.omaSettingInteger") { [int]0 } else { [string]"" } } elseif ($o["@odata.type"] -eq "#microsoft.graph.omaSettingInteger" -and $v -isnot [int] -and $v -isnot [long]) { $v = [int]$v } else { $v = [string]$v } if ($v -eq "" -or ($v -is [string] -and [string]::IsNullOrWhiteSpace($v))) { $skippedEmpty++ Write-Verbose "Skipping OMA (empty value not allowed by Graph): $($o.omaUri)" continue } [void]$omaForJson.Add(@{ "@odata.type" = $o["@odata.type"] displayName = $o["displayName"] description = $o["description"] omaUri = $o["omaUri"] value = $v }) } if ($skippedEmpty -gt 0) { Write-Host " Skipped $skippedEmpty OMA setting(s) with empty value (Graph requires non-empty)." -ForegroundColor Yellow } $body = @{ "@odata.type" = "#microsoft.graph.windows10CustomConfiguration" displayName = $policyName description = "CIS Windows 11 Enterprise Benchmark - $categoryName ($Level)" omaSettings = @($omaForJson) } $res = Invoke-IntuneGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/deviceManagement/deviceConfigurations" -Body ($body | ConvertTo-Json -Depth 20) Write-Host "Created policy: $($res.displayName) (id: $($res.id))" -ForegroundColor Green $policyCount++ } catch { Write-Error "Failed to create CIS policy for $categoryName : $_" } } } if ($ValidateOnly) { Write-Host "`n[ValidateOnly] All categories passed. Total $policyCount policy payloads validated." -ForegroundColor Green } else { Write-Host "`nCreated $policyCount CIS Benchmark policies" -ForegroundColor Green Write-Host "Note: Some CIS recommendations may require manual configuration or Intune Scripts" -ForegroundColor Yellow } } |