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
    }
}