Src/Private/Compliance-Helpers.ps1

#region --- Compliance Framework Loader ---
#
# Compliance-Helpers.ps1
# ---------------------
# Loads ACSC E8 and CIS M365 (Intune) compliance check definitions from JSON files in
# Src/Compliance/ and exposes helper functions used by each Get-AbrIntune* section.
#
# WHY JSON DEFINITIONS?
# - Control text, remediation guidance, and metadata live in one place.
# - When ACSC or CIS publishes an update, you only edit the JSON -- no PS1 changes needed.
# - Each check entry has a stable 'id' field for traceability across report versions.
# - 'staticStatus' null means the PS1 section will compute the status at runtime via
# the local variables already gathered; '[INFO]'/[OK]'/etc. means the check is always
# that status regardless of tenant data.
#
# USAGE (called once from Invoke-AsBuiltReport.Microsoft.Intune.ps1, before the tenant loop):
# Initialize-AbrIntuneComplianceFrameworks
#
# Then in each section, retrieve check definitions and resolve status:
# $E8Checks = Build-AbrIntuneComplianceChecks -Definitions (Get-AbrIntuneE8Checks -Section 'DeviceCompliance') -Framework E8 -CallerVariables $vars
# $CISChecks = Build-AbrIntuneComplianceChecks -Definitions (Get-AbrIntuneCISChecks -Section 'DeviceCompliance') -Framework CIS -CallerVariables $vars
#
#endregion

#region --- Loader ---

function Initialize-AbrIntuneComplianceFrameworks {
    <#
    .SYNOPSIS
    Loads ACSC E8 and CIS M365 Intune compliance definitions from JSON files.
    Populates $script:IntuneE8Definitions and $script:IntuneCISDefinitions.
    Call once before the tenant report loop.
    #>

    [CmdletBinding()]
    param()

    # Resolve the Compliance folder.
    # Strategy 1: walk from loaded module base
    # Strategy 2: walk from the function's own file
    $ModuleRoot = $null

    if ($script:IntuneModuleRoot -and (Test-Path $script:IntuneModuleRoot)) {
        $ModuleRoot = $script:IntuneModuleRoot
    }

    if (-not $ModuleRoot) {
        $thisFuncFile = $MyInvocation.MyCommand.Module.ModuleBase
        if ($thisFuncFile -and (Test-Path $thisFuncFile)) {
            $ModuleRoot = $thisFuncFile
        }
    }

    if (-not $ModuleRoot) {
        $loadedModule = Get-Module -Name 'AsBuiltReport.Microsoft.Intune' -ErrorAction SilentlyContinue
        if ($loadedModule -and $loadedModule.ModuleBase) {
            $ModuleRoot = $loadedModule.ModuleBase
        }
    }

    if (-not $ModuleRoot) {
        Write-Warning ' [COMPLIANCE] Cannot determine module root path -- compliance JSON will not load.'
        return
    }

    $ComplianceDir = Join-Path $ModuleRoot 'Src\Compliance'
    # Also try forward-slash variant for Linux/macOS
    if (-not (Test-Path $ComplianceDir)) {
        $ComplianceDir = Join-Path $ModuleRoot 'Src/Compliance'
    }
    Write-Host " - Loading compliance definitions from: $ComplianceDir" -ForegroundColor Cyan

    # --- ACSC Essential Eight ---
    $E8Path = Join-Path $ComplianceDir 'ACSC.E8.json'
    if (Test-Path $E8Path) {
        try {
            $script:IntuneE8Definitions = Get-Content -Path $E8Path -Raw -ErrorAction Stop | ConvertFrom-Json
            $E8SectionCount = @($script:IntuneE8Definitions.PSObject.Properties | Where-Object { $_.Name -notlike '_*' }).Count
            Write-Host " - ACSC E8 definitions loaded ($E8SectionCount sections)" -ForegroundColor Cyan
        } catch {
            Write-Warning " [COMPLIANCE] Failed to load ACSC E8 definitions from '$E8Path': $($_.Exception.Message)"
            $script:IntuneE8Definitions = $null
        }
    } else {
        Write-Warning " [COMPLIANCE] ACSC E8 definitions file not found at: $E8Path"
        $script:IntuneE8Definitions = $null
    }

    # --- CIS Microsoft 365 (Intune) ---
    $CISPath = Join-Path $ComplianceDir 'CIS.M365.Intune.json'
    if (Test-Path $CISPath) {
        try {
            $script:IntuneCISDefinitions = Get-Content -Path $CISPath -Raw -ErrorAction Stop | ConvertFrom-Json
            $CISSectionCount = @($script:IntuneCISDefinitions.PSObject.Properties | Where-Object { $_.Name -notlike '_*' }).Count
            Write-Host " - CIS M365 Intune definitions loaded ($CISSectionCount sections)" -ForegroundColor Cyan
        } catch {
            Write-Warning " [COMPLIANCE] Failed to load CIS definitions from '$CISPath': $($_.Exception.Message)"
            $script:IntuneCISDefinitions = $null
        }
    } else {
        Write-Warning " [COMPLIANCE] CIS M365 Intune definitions file not found at: $CISPath"
        $script:IntuneCISDefinitions = $null
    }
}

#endregion

#region --- Check Accessors ---

function Get-AbrIntuneE8Checks {
    <#
    .SYNOPSIS
    Returns the array of ACSC E8 check definitions for a named section.
    Returns empty array if definitions are not loaded or section not found.
    .EXAMPLE
    $defs = Get-AbrIntuneE8Checks -Section 'DeviceCompliance'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Section
    )
    if (-not $script:IntuneE8Definitions) {
        Write-Warning " [COMPLIANCE] E8 definitions not loaded -- JSON file may be missing. Check Src\Compliance\ACSC.E8.json"
        return @()
    }
    $sectionDef = $script:IntuneE8Definitions.$Section
    if (-not $sectionDef) {
        Write-Warning " [COMPLIANCE] E8 section '$Section' not found in ACSC.E8.json"
        return @()
    }
    return $sectionDef.checks
}

function Get-AbrIntuneCISChecks {
    <#
    .SYNOPSIS
    Returns the array of CIS M365 check definitions for a named section.
    Returns empty array if definitions are not loaded or section not found.
    .EXAMPLE
    $defs = Get-AbrIntuneCISChecks -Section 'DeviceCompliance'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Section
    )
    if (-not $script:IntuneCISDefinitions) {
        Write-Warning " [COMPLIANCE] CIS definitions not loaded -- JSON file may be missing. Check Src\Compliance\CIS.M365.Intune.json"
        return @()
    }
    $sectionDef = $script:IntuneCISDefinitions.$Section
    if (-not $sectionDef) {
        Write-Warning " [COMPLIANCE] CIS section '$Section' not found in CIS.M365.Intune.json"
        return @()
    }
    return $sectionDef.checks
}

#endregion

#region --- Runtime Check Builder ---

function Build-AbrIntuneComplianceChecks {
    <#
    .SYNOPSIS
    Resolves a set of compliance check definitions into [pscustomobject] rows
    ready for New-AbrIntuneE8AssessmentTable or New-AbrIntuneCISAssessmentTable.

    Each definition entry has:
      - staticStatus: if non-null, this is the row's Status regardless of runtime data.
      - statusExpression: a PowerShell expression evaluated using CallerVariables when
        staticStatus is null.
      - detail / detailTemplate: detail text. Templates use {VariableName} placeholders
        resolved against CallerVariables.

    .PARAMETER Definitions
        Array of check definition objects from Get-AbrIntuneE8Checks or Get-AbrIntuneCISChecks.

    .PARAMETER Framework
        'E8' or 'CIS' -- controls which property names are used in the output object.

    .PARAMETER CallerVariables
        Hashtable of name=>value pairs from the calling scope for expression evaluation
        and template substitution.

    .EXAMPLE
        $vars = @{ TotalCompliancePolicies = 3; UnassignedPolicies = 0; NonCompliantDevices = 5; NonCompliantPct = 2 }
        $checks = Build-AbrIntuneComplianceChecks -Definitions (Get-AbrIntuneE8Checks 'DeviceCompliance') -Framework E8 -CallerVariables $vars
    #>

    [CmdletBinding()]
    param(
        [Parameter()][object[]]$Definitions,
        [Parameter(Mandatory)][ValidateSet('E8','CIS')][string]$Framework,
        [Parameter(Mandatory)][hashtable]$CallerVariables
    )

    if (-not $Definitions -or $Definitions.Count -eq 0) {
        Write-Warning " [COMPLIANCE] Build-AbrIntuneComplianceChecks: no definitions supplied for Framework=$Framework (JSON may not have loaded)"
        return @()
    }

    $result = [System.Collections.ArrayList]::new()

    foreach ($def in $Definitions) {
        # --- Resolve Status ---
        $Status = $null
        if ($def.staticStatus) {
            $Status = $def.staticStatus
        } elseif ($def.statusExpression) {
            try {
                $sb = [scriptblock]::Create($def.statusExpression)
                $Status = & {
                    foreach ($kv in $CallerVariables.GetEnumerator()) {
                        Set-Variable -Name $kv.Key -Value $kv.Value -Scope Local
                    }
                    & $sb
                }
            } catch {
                $Status = '[INFO]'
                Write-AbrDebugLog "Status expression eval failed for check '$($def.id)': $($_.Exception.Message)" 'WARN' 'COMPLIANCE'
            }
        } else {
            $Status = '[INFO]'
        }

        # --- Resolve Detail ---
        $Detail = ''
        $rawDetail = if ($def.detail) { $def.detail } elseif ($def.detailTemplate) { $def.detailTemplate } else { '' }
        if ($rawDetail) {
            $Detail = $rawDetail
            foreach ($kv in $CallerVariables.GetEnumerator()) {
                $Detail = $Detail -replace "\{$([regex]::Escape($kv.Key))\}", [string]$kv.Value
            }
        }

        # --- Build output object ---
        $row = if ($Framework -eq 'E8') {
            [pscustomobject]@{
                'ML'      = $def.ML
                'Control' = $def.control
                'Status'  = $Status
                'Detail'  = $Detail
            }
        } else {
            [pscustomobject]@{
                'CISControl' = $def.CISControl
                'Level'      = $def.Level
                'Status'     = $Status
                'Detail'     = $Detail
            }
        }

        $null = $result.Add($row)
    }

    return $result.ToArray()
}

#endregion

#region --- Table Renderers ---

function New-AbrIntuneE8AssessmentTable {
    <#
    .SYNOPSIS
    Renders a standardised ACSC Essential Eight Maturity Level assessment table for Intune.
    Only renders if Options.ComplianceFrameworks.ACSCe8 = true in the report JSON.
    Pass an array of [pscustomobject] with properties: ML, Control, Status, Detail
    Status values: [OK], [FAIL], [WARN], [INFO]
    #>

    [CmdletBinding()]
    param(
        [Parameter()][AllowNull()][AllowEmptyCollection()][object[]]$Checks,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$TenantId
    )

    if (-not $script:IncludeACSCe8) { return }
    if (-not $Checks -or $Checks.Count -eq 0) { return }

    $null = (& {
        if ($HealthCheck.Intune.DeviceCompliance -or $HealthCheck.Intune.EndpointSecurity -or $HealthCheck.Intune.Devices) {
            $null = ($Checks | Where-Object { $_.'Status' -eq '[FAIL]' } | Set-Style -Style Critical | Out-Null)
            $null = ($Checks | Where-Object { $_.'Status' -eq '[WARN]' } | Set-Style -Style Warning  | Out-Null)
            $null = ($Checks | Where-Object { $_.'Status' -eq '[OK]'   } | Set-Style -Style OK       | Out-Null)
        }
    })

    $TableParams = @{ Name = "ACSC E8 $Name - $TenantId"; List = $false; ColumnWidths = 8, 32, 9, 51 }
    if ($Report.ShowTableCaptions) { $TableParams['Caption'] = "- $($TableParams.Name)" }
    $Checks | Table @TableParams
}

function New-AbrIntuneCISAssessmentTable {
    <#
    .SYNOPSIS
    Renders a standardised CIS Microsoft 365 Foundations Benchmark assessment table for Intune.
    Only renders if Options.ComplianceFrameworks.CISBaseline = true in the report JSON.
    Pass an array of [pscustomobject] with properties: CISControl, Level, Status, Detail
    CISControl: CIS control ID e.g. "6.1.1"
    Level: L1 or L2
    Status: [OK], [FAIL], [WARN], [INFO]
    #>

    [CmdletBinding()]
    param(
        [Parameter()][AllowNull()][AllowEmptyCollection()][object[]]$Checks,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$TenantId
    )

    if (-not $script:IncludeCISBaseline) { return }
    if (-not $Checks -or $Checks.Count -eq 0) { return }

    $null = (& {
        if ($HealthCheck.Intune.DeviceCompliance -or $HealthCheck.Intune.EndpointSecurity -or $HealthCheck.Intune.Devices) {
            $null = ($Checks | Where-Object { $_.'Status' -eq '[FAIL]' } | Set-Style -Style Critical | Out-Null)
            $null = ($Checks | Where-Object { $_.'Status' -eq '[WARN]' } | Set-Style -Style Warning  | Out-Null)
            $null = ($Checks | Where-Object { $_.'Status' -eq '[OK]'   } | Set-Style -Style OK       | Out-Null)
        }
    })

    $TableParams = @{ Name = "CIS Baseline $Name - $TenantId"; List = $false; ColumnWidths = 10, 7, 9, 74 }
    if ($Report.ShowTableCaptions) { $TableParams['Caption'] = "- $($TableParams.Name)" }
    $Checks | Table @TableParams
}

#endregion