Src/Private/Compliance-Helpers.ps1

#region --- Compliance Framework Loader ---
#
# Compliance-Helpers.ps1
# ---------------------
# Loads ACSC E8 and CIS M365 compliance check definitions from JSON files in
# Src/Compliance/ and exposes helper functions used by each Get-AbrEntraID* 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.EntraID.ps1, before the tenant loop):
# Initialize-AbrComplianceFrameworks
#
# Then in each section, retrieve a check definition and resolve its status:
# $checks = Get-AbrE8Checks -Section 'MFA'
# # Evaluate and build the runtime [pscustomobject] array as before, but using the
# # definition fields for 'Control', 'Detail', and 'ML'/'CISControl'/'Level' rather
# # than hardcoding them inline.
#
#endregion

#region --- Loader ---

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

    [CmdletBinding()]
    param()

    # Resolve the Compliance folder.
    # Strategy 1: $script:EntraIDModuleRoot set by .psm1 (most reliable)
    # Strategy 2: Walk up from this function's source file path
    # Strategy 3: Walk up from the loaded module's path
    $ModuleRoot = $null

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

    if (-not $ModuleRoot) {
        # Find where this function was defined -- works even when dot-sourced
        $thisFuncFile = $MyInvocation.MyCommand.Module.ModuleBase
        if ($thisFuncFile -and (Test-Path $thisFuncFile)) {
            $ModuleRoot = $thisFuncFile
        }
    }

    if (-not $ModuleRoot) {
        # Fallback: find the loaded module's base path
        $loadedModule = Get-Module -Name 'AsBuiltReport.Microsoft.EntraID' -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'
    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:E8Definitions = Get-Content -Path $E8Path -Raw -ErrorAction Stop | ConvertFrom-Json
            $E8SectionCount = @($script:E8Definitions.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:E8Definitions = $null
        }
    } else {
        Write-Warning " [COMPLIANCE] ACSC E8 definitions file not found at: $E8Path"
        $script:E8Definitions = $null
    }

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

#endregion

#region --- Check Accessors ---

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

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

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

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

#endregion

#region --- Runtime Check Builder ---

function Build-AbrComplianceChecks {
    <#
    .SYNOPSIS
    Resolves a set of compliance check definitions into [pscustomobject] rows
    ready for New-AbrE8AssessmentTable or New-AbrCISAssessmentTable.
 
    Each definition entry has:
      - staticStatus: if non-null, this is the row's Status regardless of runtime data.
      - statusExpression: a PowerShell expression string evaluated in the CALLER's scope
        to compute Status when staticStatus is null.
      - detail / detailTemplate: detail text. Templates use {VariableName} placeholders
        which are resolved against variables in the caller's scope.
 
    .PARAMETER Definitions
        Array of check definition objects from Get-AbrE8Checks or Get-AbrCISChecks.
 
    .PARAMETER Framework
        'E8' or 'CIS' -- controls which property names are used for the output object.
 
    .PARAMETER CallerVariables
        A hashtable of name=>value pairs from the calling scope used for expression
        evaluation and template substitution. Pass as:
            Build-AbrComplianceChecks -Definitions $defs -Framework 'E8' `
                -CallerVariables (Get-AbrCallerVars 'MfaPct','TotalMfaUsers','NoMfaCount',...)
 
    .EXAMPLE
        $vars = @{ MfaPct = $MfaPct; TotalMfaUsers = $TotalMfaUsers; ... }
        $checks = Build-AbrComplianceChecks -Definitions (Get-AbrE8Checks 'MFA') -Framework E8 -CallerVariables $vars
    #>

    [CmdletBinding()]
    param(
        [Parameter()][object[]]$Definitions,   # Non-mandatory: returns empty array if null/empty
        [Parameter(Mandatory)][ValidateSet('E8','CIS')][string]$Framework,
        [Parameter(Mandatory)][hashtable]$CallerVariables
    )

    # Guard: if JSON failed to load, return empty array rather than crashing
    if (-not $Definitions -or $Definitions.Count -eq 0) {
        Write-Warning " [COMPLIANCE] Build-AbrComplianceChecks: 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 {
                # Inject caller variables into a child scope and evaluate
                $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) {
            # Replace {VariableName} placeholders with caller variable values
            $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 --- Convenience Wrapper: New-AbrE8AssessmentTable (JSON-driven version) ---

function New-AbrE8AssessmentTable {
    <#
    .SYNOPSIS
    Renders a standardised ACSC Essential Eight Maturity Level assessment table.
    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 }  # JSON not loaded

    $null = (& {
        if ($HealthCheck.EntraID.MFA -or $HealthCheck.EntraID.Roles -or $HealthCheck.EntraID.ConditionalAccess) {
            $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
}

#endregion

#region --- Convenience Wrapper: New-AbrCISAssessmentTable (JSON-driven version) ---

function New-AbrCISAssessmentTable {
    <#
    .SYNOPSIS
    Renders a standardised CIS Microsoft 365 Foundations Benchmark assessment table.
    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. "1.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 }  # JSON not loaded

    $null = (& {
        if ($HealthCheck.EntraID.MFA -or $HealthCheck.EntraID.Roles -or $HealthCheck.EntraID.ConditionalAccess) {
            $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