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 |