Public/Invoke-CISAzureBenchmark.ps1
|
function Invoke-CISAzureBenchmark { <# .SYNOPSIS Runs the CIS Microsoft Azure Foundations Benchmark v5.0.0 compliance checks. .DESCRIPTION Evaluates one or more Azure subscriptions against all 155 CIS controls (93 Automated + 62 Manual) from CIS Microsoft Azure Foundations Benchmark v5.0.0. Generates HTML dashboard, JSON, and CSV reports showing compliance status. .PARAMETER Section Filter by section(s). Examples: '5', '7', '8.1', 'Identity Services' .PARAMETER AssessmentStatus Filter by assessment status: 'Automated', 'Manual', or 'All' (default). .PARAMETER ProfileLevel Maximum profile level to include. Level 2 (default) includes both Level 1 and Level 2. .PARAMETER ControlId Run specific controls by ID. Examples: '7.1', '8.3.5', '9.3.8' .PARAMETER ExcludeControlId Exclude specific controls by ID. .PARAMETER OutputDirectory Directory for report output. Defaults to current directory. .PARAMETER OutputFormat Report format(s): 'HTML', 'JSON', 'CSV', or 'All' (default). .PARAMETER ReportName Base name for report files. Auto-generated if not specified. .PARAMETER SubscriptionId Target subscription. Uses current context if not specified. .PARAMETER AllSubscriptions Scan all available subscriptions (this is now the default behavior). Kept for backwards compatibility. .PARAMETER SkipModuleCheck Skip Azure module validation. .EXAMPLE Invoke-CISAzureBenchmark -OutputDirectory ./reports .EXAMPLE Invoke-CISAzureBenchmark -AllSubscriptions -OutputDirectory ./reports .EXAMPLE Invoke-CISAzureBenchmark -Section '7','8' -AssessmentStatus Automated .PARAMETER Parallel When used with -AllSubscriptions (or the default multi-subscription mode), scans subscriptions concurrently using ForEach-Object -Parallel (PowerShell 7+ only). Falls back to sequential scanning with a warning on PowerShell 5.1. .PARAMETER ThrottleLimit Maximum number of subscriptions to scan concurrently when -Parallel is specified. Defaults to 5. Only effective when -Parallel is also specified. .EXAMPLE Invoke-CISAzureBenchmark -ProfileLevel 1 -OutputFormat HTML .EXAMPLE Invoke-CISAzureBenchmark -Parallel -ThrottleLimit 3 -OutputDirectory ./reports #> [CmdletBinding()] param( [Parameter()] [string[]]$Section, [Parameter()] [ValidateSet('Automated', 'Manual', 'All')] [string]$AssessmentStatus = 'All', [Parameter()] [ValidateSet(1, 2)] [int]$ProfileLevel = 2, [Parameter()] [string[]]$ControlId, [Parameter()] [string[]]$ExcludeControlId, [Parameter()] [string]$OutputDirectory = '.', [Parameter()] [ValidateSet('HTML', 'JSON', 'CSV', 'SARIF', 'All')] [string[]]$OutputFormat = @('All'), [Parameter()] [string]$ReportName, [Parameter()] [string]$SubscriptionId, [Parameter()] [switch]$AllSubscriptions, [Parameter()] [switch]$Parallel, [Parameter()] [ValidateRange(1, 20)] [int]$ThrottleLimit = 5, [Parameter()] [switch]$SkipModuleCheck, [Parameter()] [string]$ConfigPath, [Parameter()] [hashtable]$ExcludeResourceTag, [Parameter()] [switch]$NoAutoOpen ) $ErrorActionPreference = 'Continue' $startTime = Get-Date # Suppress noisy Azure module deprecation/breaking change warnings $savedEnvVar = $env:SuppressAzurePowerShellBreakingChangeWarnings $env:SuppressAzurePowerShellBreakingChangeWarnings = 'true' $savedWarningPref = $WarningPreference $WarningPreference = 'SilentlyContinue' try { Update-AzConfig -DisplayBreakingChangeWarning $false -ErrorAction SilentlyContinue | Out-Null } catch { } # Reset progress timing for accurate ETA in this run $script:CISProgressTimes = [System.Collections.Generic.List[double]]::new() $script:CISProgressLastCheck = $null try { # Load config overrides if specified if ($ConfigPath) { Set-CISConfigOverride -ConfigPath $ConfigPath } # Banner Write-Host '' Write-Host ' +============================================================+' -ForegroundColor DarkCyan Write-Host ' | |' -ForegroundColor DarkCyan Write-Host ' |' -ForegroundColor DarkCyan -NoNewline Write-Host ' CIS Microsoft Azure Foundations Benchmark ' -ForegroundColor Cyan -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' |' -ForegroundColor DarkCyan -NoNewline $versionStr = "$($script:CISBenchmarkVersion) | 155 Controls" $versionPad = ' ' * [math]::Max(0, 45 - $versionStr.Length) Write-Host " $versionStr$versionPad" -ForegroundColor White -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' | |' -ForegroundColor DarkCyan Write-Host ' |' -ForegroundColor DarkCyan -NoNewline Write-Host ' powershellnerd.com ' -ForegroundColor Yellow -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' | |' -ForegroundColor DarkCyan Write-Host ' +============================================================+' -ForegroundColor DarkCyan Write-Host '' # ---------------------------------------------------------------- # 1. Load control definitions # ---------------------------------------------------------------- $defPath = Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'Data') 'ControlDefinitions.psd1' if (-not (Test-Path $defPath)) { Write-Error "Control definitions file not found: $defPath" return } try { $definitions = Import-PowerShellDataFile -Path $defPath } catch { Write-Error "Failed to parse control definitions file '$defPath': $($_.Exception.Message)" return } $allControls = $definitions.Controls if (-not $allControls -or $allControls.Count -eq 0) { Write-Error "Control definitions file is missing or has an empty 'Controls' key." return } Write-Host " Loaded $($allControls.Count) control definitions" -ForegroundColor Green # ---------------------------------------------------------------- # 2. Filter controls # ---------------------------------------------------------------- $controls = $allControls $controls = $controls | Where-Object { $_.ProfileLevel -le $ProfileLevel } if ($AssessmentStatus -ne 'All') { $controls = $controls | Where-Object { $_.AssessmentStatus -eq $AssessmentStatus } } if ($Section) { $controls = $controls | Where-Object { $ctrl = $_ $Section | Where-Object { $ctrl.Section -like "*$_*" -or $ctrl.ControlId -eq $_ -or $ctrl.ControlId.StartsWith("$_.") -or $ctrl.Subsection -like "*$_*" } } } if ($ControlId) { $controls = $controls | Where-Object { $_.ControlId -in $ControlId } } if ($ExcludeControlId) { $controls = $controls | Where-Object { $_.ControlId -notin $ExcludeControlId } } $automatedCount = ($controls | Where-Object { $_.AssessmentStatus -eq 'Automated' }).Count $manualCount = ($controls | Where-Object { $_.AssessmentStatus -eq 'Manual' }).Count Write-Host " Running $($controls.Count) checks`($automatedCount automated, $manualCount manual`)" -ForegroundColor Yellow Write-Host "" if ($controls.Count -eq 0) { Write-Warning "No controls match the specified filters." return } # ---------------------------------------------------------------- # 3. Determine subscriptions to scan # ---------------------------------------------------------------- # Default: scan ALL enabled subscriptions automatically. # Use -SubscriptionId to target a single subscription instead. # Ensure Azure is connected before enumerating subscriptions $azContext = $null try { $azContext = Get-AzContext -ErrorAction Stop } catch { $azContext = $null } if (-not $azContext -or -not $azContext.Subscription) { Write-Host ' Azure is not connected. Launching interactive login...' -ForegroundColor Yellow try { Connect-AzAccount -ErrorAction Stop | Out-Null $azContext = Get-AzContext -ErrorAction Stop Write-Host ' Azure connected successfully.' -ForegroundColor Green Write-Host '' } catch { Write-Error "Failed to connect to Azure: $($_.Exception.Message)" return } } $subscriptionsToScan = @() if ($SubscriptionId) { # User explicitly chose a single subscription $subscriptionsToScan = @($null) } else { # Scan all enabled subscriptions by default $AllSubscriptions = $true $subscriptionsToScan = @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) if ($subscriptionsToScan.Count -eq 0) { Write-Error 'No enabled subscriptions found.' return } Write-Host " Scanning $($subscriptionsToScan.Count) subscription`(s`):" -ForegroundColor Cyan foreach ($sub in $subscriptionsToScan) { Write-Host " - $($sub.Name)" -ForegroundColor White } Write-Host '' } # ---------------------------------------------------------------- # 4. Run scans (single or multi-subscription) # ---------------------------------------------------------------- $multiSubData = @{} $combinedResults = [System.Collections.Generic.List[PSCustomObject]]::new() $primaryMetadata = $null $subIndex = 0 # Determine whether to use parallel scanning $useParallel = $false if ($Parallel -and $AllSubscriptions -and $subscriptionsToScan.Count -gt 1) { if ($PSVersionTable.PSVersion.Major -ge 7) { $useParallel = $true Write-Host " Parallel scanning enabled (ThrottleLimit: $ThrottleLimit)" -ForegroundColor Cyan } else { Write-Warning "Parallel scanning requires PowerShell 7+. Current version: $($PSVersionTable.PSVersion). Falling back to sequential scanning." } } if ($useParallel) { # ============================================================ # PARALLEL multi-subscription scan (PowerShell 7+ only) # ============================================================ # Use a thread-safe ConcurrentDictionary for per-sub data and ConcurrentBag for combined results $parallelMultiSubData = [System.Collections.Concurrent.ConcurrentDictionary[string, hashtable]]::new() $parallelCombinedResults = [System.Collections.Concurrent.ConcurrentBag[PSCustomObject]]::new() $parallelPrimaryMeta = [System.Collections.Concurrent.ConcurrentDictionary[string, hashtable]]::new() # Capture the module base path so parallel runspaces can import the module $moduleBasePath = $PSScriptRoot | Split-Path -Parent $psd1Path = Join-Path $moduleBasePath 'CISAzureBenchmark.psd1' # Separate tenant-level (Identity Services) from subscription-level controls. # Identity checks query Microsoft Graph at the tenant level and produce identical # results regardless of which subscription is active — run them once on the main thread. $identityControls = @($controls | Where-Object { $_.Section -eq 'Identity Services' }) $subscriptionControls = @($controls | Where-Object { $_.Section -ne 'Identity Services' }) $identityResults = @() if ($identityControls.Count -gt 0) { Write-Host " Running $($identityControls.Count) tenant-level identity check(s) (once for all subscriptions)..." -ForegroundColor Yellow $firstSub = $subscriptionsToScan[0] Set-AzContext -SubscriptionId $firstSub.Id -ErrorAction SilentlyContinue | Out-Null $idEnvInfo = Initialize-CISEnvironment ` -SubscriptionId $firstSub.Id ` -SkipModuleCheck:$SkipModuleCheck ` -ControlsToRun $identityControls $primaryMetadata = @{ SubscriptionName = $idEnvInfo.SubscriptionName SubscriptionId = $idEnvInfo.SubscriptionId TenantId = $idEnvInfo.TenantId ScannedBy = $idEnvInfo.ScannedBy ScanTimestamp = $idEnvInfo.ScanTimestamp } $idCache = Initialize-CISResourceCache -ControlsToRun $identityControls foreach ($ctrl in $identityControls) { $result = Invoke-CISCheckSafely ` -ControlDef $ctrl ` -ResourceCache $idCache ` -EnvironmentInfo $idEnvInfo if ($result) { $identityResults += $result $combinedResults.Add($result) } } Write-Host " Identity checks complete ($($identityResults.Count) results)" -ForegroundColor Green Write-Host "" } Write-Host " Launching parallel scans for $($subscriptionsToScan.Count) subscriptions..." -ForegroundColor Yellow Write-Host "" # Pre-serialize identity results so parallel runspaces can include them $identityResultsForParallel = $identityResults $subscriptionsToScan | ForEach-Object -Parallel { $targetSub = $_ $subId = $targetSub.Id $subName = $targetSub.Name # Access thread-safe collections via $using: scope $bag = $using:parallelCombinedResults $subDataDict = $using:parallelMultiSubData $metaDict = $using:parallelPrimaryMeta $controlDefs = $using:subscriptionControls $idResults = $using:identityResultsForParallel $skipMod = $using:SkipModuleCheck $excludeTag = $using:ExcludeResourceTag $modPath = $using:psd1Path # Import the module in this runspace so all private functions are available Import-Module $modPath -Force -ErrorAction Stop # Suppress Azure deprecation warnings in this runspace $env:SuppressAzurePowerShellBreakingChangeWarnings = 'true' $WarningPreference = 'SilentlyContinue' try { Update-AzConfig -DisplayBreakingChangeWarning $false -ErrorAction SilentlyContinue | Out-Null } catch { } # Each parallel runspace must set its own Az context Set-AzContext -SubscriptionId $subId -ErrorAction Stop | Out-Null # Initialize environment for this subscription $envInfo = Initialize-CISEnvironment ` -SubscriptionId $subId ` -SkipModuleCheck:$skipMod ` -ControlsToRun $controlDefs if (-not $envInfo.IsValid) { Write-Warning "[$subName] Skipping - initialization failed" foreach ($err in $envInfo.Errors) { Write-Warning " [$subName] $err" } return # continue in parallel context } # Store primary metadata from the first subscription to complete [void]$metaDict.TryAdd('first', @{ SubscriptionName = $envInfo.SubscriptionName SubscriptionId = $envInfo.SubscriptionId TenantId = $envInfo.TenantId ScannedBy = $envInfo.ScannedBy ScanTimestamp = $envInfo.ScanTimestamp }) Write-Host " [$subName] Pre-fetching Azure resources..." -ForegroundColor Yellow $cacheParams = @{ ControlsToRun = $controlDefs } if ($excludeTag) { $cacheParams.ExcludeResourceTag = $excludeTag } $resourceCache = Initialize-CISResourceCache @cacheParams Write-Host " [$subName] Resource cache ready" -ForegroundColor Green # Execute subscription-level checks only (identity checks already ran on main thread) Write-Host " [$subName] Running compliance checks..." -ForegroundColor Yellow $subResults = [System.Collections.Generic.List[PSCustomObject]]::new() # Include the shared identity results in this subscription's results foreach ($idResult in $idResults) { $subResults.Add($idResult) } foreach ($ctrl in $controlDefs) { $result = Invoke-CISCheckSafely ` -ControlDef $ctrl ` -ResourceCache $resourceCache ` -EnvironmentInfo $envInfo if ($result) { $subResults.Add($result) $bag.Add($result) } } # Post-process: convert false PASSes from failed cache fetches to WARNING if ($resourceCache.FailedResourceTypes -and $resourceCache.FailedResourceTypes.Count -gt 0) { $resourceDependencyMap = @{ 'Network Security Groups' = @('NSGPortCheck') 'Storage Accounts' = @('StorageAccountProperty', 'StorageBlobProperty', 'StorageFileProperty') 'Key Vaults' = @('KeyVaultProperty', 'KeyVaultKeyExpiry', 'KeyVaultSecretExpiry') 'Activity Log Alerts' = @('ActivityLogAlert') 'Application Gateways' = @('_section:Networking Services') 'Virtual Networks' = @('_section:Networking Services') 'Network Watchers' = @('_section:Networking Services') 'Databricks Workspaces' = @('_section:Analytics Services') } $affectedPatterns = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $affectedSections = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($failedType in $resourceCache.FailedResourceTypes) { if ($resourceDependencyMap.ContainsKey($failedType)) { foreach ($dep in $resourceDependencyMap[$failedType]) { if ($dep.StartsWith('_section:')) { [void]$affectedSections.Add($dep.Substring(9)) } else { [void]$affectedPatterns.Add($dep) } } } } foreach ($r in $subResults) { if ($r.Status -eq 'PASS' -and $r.TotalResources -eq 0) { $c = $controlDefs | Where-Object { $_.ControlId -eq $r.ControlId } | Select-Object -First 1 if ($c -and (($c.CheckPattern -and $affectedPatterns.Contains($c.CheckPattern)) -or ($c.Section -and $affectedSections.Contains($c.Section)))) { $r.Status = 'WARNING' $r.Details = "Resource fetch failed for this check's dependencies. Result may be inaccurate. Original: $($r.Details)" } } } } # Store per-subscription data (includes identity results for per-sub reports) [void]$subDataDict.TryAdd($envInfo.SubscriptionId, @{ Name = $envInfo.SubscriptionName TenantId = $envInfo.TenantId Results = $subResults.ToArray() }) # Per-subscription summary $subPass = ($subResults | Where-Object Status -eq 'PASS').Count $subFail = ($subResults | Where-Object Status -eq 'FAIL').Count $subInfo = ($subResults | Where-Object Status -eq 'INFO').Count $subWarn = ($subResults | Where-Object Status -eq 'WARNING').Count $subError = ($subResults | Where-Object Status -eq 'ERROR').Count $subDenom = $subResults.Count - $subInfo - $subWarn $subScore = if ($subDenom -gt 0) { [math]::Round(($subPass / $subDenom) * 100, 1) } else { 0 } $scoreColor = if ($subScore -ge 80) { 'Green' } elseif ($subScore -ge 50) { 'Yellow' } else { 'Red' } Write-Host "" Write-Host " [$subName] Score: $subScore% | Pass: $subPass | Fail: $subFail | Error: $subError" -ForegroundColor $scoreColor Write-Host "" } -ThrottleLimit $ThrottleLimit # Transfer parallel results back to the main-thread collections foreach ($kvp in $parallelMultiSubData.GetEnumerator()) { $multiSubData[$kvp.Key] = $kvp.Value } foreach ($r in $parallelCombinedResults) { $combinedResults.Add($r) } if (-not $primaryMetadata -and $parallelPrimaryMeta.ContainsKey('first')) { $primaryMetadata = $parallelPrimaryMeta['first'] } } else { # ============================================================ # SEQUENTIAL scan (default, or PS 5.1 fallback) # ============================================================ # Track tenant-level (Identity Services) results from the first subscription # to avoid duplicate checks in multi-sub mode (they query Graph, not per-sub resources) $tenantLevelResults = $null foreach ($targetSub in $subscriptionsToScan) { $subIndex++ # Switch subscription context if multi-sub mode $currentSubId = $SubscriptionId if ($AllSubscriptions -and $targetSub) { $currentSubId = $targetSub.Id Write-Host " [$subIndex/$($subscriptionsToScan.Count)] Switching to: $($targetSub.Name)" -ForegroundColor Yellow Set-AzContext -SubscriptionId $targetSub.Id -ErrorAction SilentlyContinue | Out-Null } # Initialize environment Write-CISProgress -Activity "Initializing" -Status "Validating environment..." $envInfo = Initialize-CISEnvironment ` -SubscriptionId $currentSubId ` -SkipModuleCheck:$SkipModuleCheck ` -ControlsToRun $controls if (-not $envInfo.IsValid) { if ($AllSubscriptions) { Write-Warning "Skipping subscription $currentSubId - initialization failed" foreach ($err in $envInfo.Errors) { Write-Warning " $err" } continue } else { foreach ($err in $envInfo.Errors) { Write-Error $err } return } } foreach ($warn in $envInfo.Warnings) { Write-Warning $warn } if (-not $primaryMetadata) { $primaryMetadata = @{ SubscriptionName = $envInfo.SubscriptionName SubscriptionId = $envInfo.SubscriptionId TenantId = $envInfo.TenantId ScannedBy = $envInfo.ScannedBy ScanTimestamp = $envInfo.ScanTimestamp } } Write-Host " Subscription: $($envInfo.SubscriptionName) ($($envInfo.SubscriptionId))" -ForegroundColor Cyan Write-Host " Tenant: $($envInfo.TenantId)" -ForegroundColor Cyan Write-Host "" # Pre-fetch resources Write-Host " Pre-fetching Azure resources..." -ForegroundColor Yellow $cacheParams = @{ ControlsToRun = $controls } if ($ExcludeResourceTag) { $cacheParams.ExcludeResourceTag = $ExcludeResourceTag } $resourceCache = Initialize-CISResourceCache @cacheParams if ($resourceCache.FetchWarnings -and $resourceCache.FetchWarnings.Count -gt 0) { # Temporarily restore warning preference to show critical cache warnings $WarningPreference = $savedWarningPref foreach ($fw in $resourceCache.FetchWarnings) { Write-Warning $fw } $WarningPreference = 'SilentlyContinue' } Write-Host " Resource cache ready" -ForegroundColor Green Write-Host "" # Execute checks Write-Host " Running compliance checks..." -ForegroundColor Yellow $subResults = [System.Collections.Generic.List[PSCustomObject]]::new() $checkCount = 0 foreach ($ctrl in $controls) { $checkCount++ $prefix = if ($AllSubscriptions) { "[$subIndex/$($subscriptionsToScan.Count)] " } else { "" } Write-CISProgress -Activity "${prefix}Running checks" ` -Status "$($ctrl.ControlId) - $($ctrl.Title)" ` -Current $checkCount -Total $controls.Count # Skip tenant-level (Identity Services) checks on subsequent subscriptions — # they query Microsoft Graph at the tenant level and produce identical results if ($AllSubscriptions -and $subIndex -gt 1 -and $ctrl.Section -eq 'Identity Services' -and $null -ne $tenantLevelResults) { $cachedResult = $tenantLevelResults[$ctrl.ControlId] if ($cachedResult) { $subResults.Add($cachedResult) $combinedResults.Add($cachedResult) continue } } $result = Invoke-CISCheckSafely ` -ControlDef $ctrl ` -ResourceCache $resourceCache ` -EnvironmentInfo $envInfo if ($result) { $subResults.Add($result) $combinedResults.Add($result) } } # Cache tenant-level results from first subscription for reuse if ($AllSubscriptions -and $subIndex -eq 1 -and $null -eq $tenantLevelResults) { $tenantLevelResults = @{} foreach ($r in $subResults) { $ctrlDef = $controls | Where-Object { $_.ControlId -eq $r.ControlId } | Select-Object -First 1 if ($ctrlDef -and $ctrlDef.Section -eq 'Identity Services') { $tenantLevelResults[$r.ControlId] = $r } } if ($tenantLevelResults.Count -gt 0) { Write-Verbose " Cached $($tenantLevelResults.Count) tenant-level identity results for reuse across subscriptions" } } Write-Progress -Activity "CIS Azure Benchmark $($script:CISBenchmarkVersion)" -Completed # Post-process: convert false PASSes from failed cache fetches to WARNING if ($resourceCache.FailedResourceTypes -and $resourceCache.FailedResourceTypes.Count -gt 0) { # Map resource types to check patterns/sections that depend on them $resourceDependencyMap = @{ 'Network Security Groups' = @('NSGPortCheck') 'Storage Accounts' = @('StorageAccountProperty', 'StorageBlobProperty', 'StorageFileProperty') 'Key Vaults' = @('KeyVaultProperty', 'KeyVaultKeyExpiry', 'KeyVaultSecretExpiry') 'Activity Log Alerts' = @('ActivityLogAlert') 'Application Gateways' = @('_section:Networking Services') 'Virtual Networks' = @('_section:Networking Services') 'Network Watchers' = @('_section:Networking Services') 'Databricks Workspaces' = @('_section:Analytics Services') } $affectedPatterns = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $affectedSections = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($failedType in $resourceCache.FailedResourceTypes) { if ($resourceDependencyMap.ContainsKey($failedType)) { foreach ($dep in $resourceDependencyMap[$failedType]) { if ($dep.StartsWith('_section:')) { [void]$affectedSections.Add($dep.Substring(9)) } else { [void]$affectedPatterns.Add($dep) } } } } foreach ($r in $subResults) { if ($r.Status -eq 'PASS' -and $r.TotalResources -eq 0) { $ctrl = $controls | Where-Object { $_.ControlId -eq $r.ControlId } | Select-Object -First 1 if ($ctrl -and (($ctrl.CheckPattern -and $affectedPatterns.Contains($ctrl.CheckPattern)) -or ($ctrl.Section -and $affectedSections.Contains($ctrl.Section)))) { $r.Status = 'WARNING' $r.Details = "Resource fetch failed for this check's dependencies. Result may be inaccurate. Original: $($r.Details)" } } } } # Store per-subscription data for multi-sub HTML if ($AllSubscriptions -and $targetSub) { $multiSubData[$envInfo.SubscriptionId] = @{ Name = $envInfo.SubscriptionName TenantId = $envInfo.TenantId Results = $subResults.ToArray() } # Per-subscription summary $subPass = ($subResults | Where-Object Status -eq 'PASS').Count $subFail = ($subResults | Where-Object Status -eq 'FAIL').Count $subInfo = ($subResults | Where-Object Status -eq 'INFO').Count $subWarn = ($subResults | Where-Object Status -eq 'WARNING').Count $subError = ($subResults | Where-Object Status -eq 'ERROR').Count $subDenom = $subResults.Count - $subInfo - $subWarn $subScore = if ($subDenom -gt 0) { [math]::Round(($subPass / $subDenom) * 100, 1) } else { 0 } Write-Host "" Write-Host " [$($envInfo.SubscriptionName)] Score: $subScore% | Pass: $subPass | Fail: $subFail | Error: $subError" -ForegroundColor $(if ($subScore -ge 80) { 'Green' } elseif ($subScore -ge 50) { 'Yellow' } else { 'Red' }) Write-Host "" } } } # Use combined results for everything from here $allResults = $combinedResults # ---------------------------------------------------------------- # 5. Summary # ---------------------------------------------------------------- # Single-pass counting for efficiency $passCount = 0; $failCount = 0; $warningCount = 0; $infoCount = 0; $errorCount = 0 foreach ($r in $allResults) { switch ($r.Status) { 'PASS' { $passCount++ } 'FAIL' { $failCount++ } 'WARNING' { $warningCount++ } 'INFO' { $infoCount++ } 'ERROR' { $errorCount++ } } } # Score excludes INFO (manual checks), WARNING (indeterminate), and ERROR (broken checks) from denominator $scoreDenom = $allResults.Count - $infoCount - $warningCount - $errorCount $score = if ($scoreDenom -gt 0) { [math]::Round(($passCount / $scoreDenom) * 100, 1) } else { -1 } Write-Host "" Write-Host " ============================================================" -ForegroundColor Cyan Write-Host " RESULTS SUMMARY$(if ($AllSubscriptions) { " (All $($multiSubData.Count) Subscriptions Combined)" })" -ForegroundColor Cyan Write-Host " ============================================================" -ForegroundColor Cyan $scoreDisplay = if ($score -lt 0) { 'N/A (no evaluated controls)' } else { "$score%" } $scoreColor = if ($score -lt 0) { 'DarkGray' } elseif ($score -ge 80) { 'Green' } elseif ($score -ge 50) { 'Yellow' } else { 'Red' } Write-Host " Automated Checks Score: $scoreDisplay" -ForegroundColor $scoreColor Write-Host " (Based on $scoreDenom evaluated controls, excludes $infoCount manual + $warningCount indeterminate)" -ForegroundColor DarkGray Write-Host "" Write-Host " PASS: $passCount" -ForegroundColor Green Write-Host " FAIL: $failCount" -ForegroundColor Red Write-Host " WARNING: $warningCount (Indeterminate)" -ForegroundColor Yellow Write-Host " INFO: $infoCount (Manual checks - require human review)" -ForegroundColor Blue Write-Host " ERROR: $errorCount" -ForegroundColor Gray Write-Host " Total: $($allResults.Count)" -ForegroundColor White Write-Host "" # ---------------------------------------------------------------- # 6. Generate reports # ---------------------------------------------------------------- # Validate output path $resolvedOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) if ($resolvedOutput -match '^\\\\') { Write-Warning "Output directory is a UNC path ($resolvedOutput). Reports contain sensitive data - ensure the share is secured." } if (-not (Test-Path $OutputDirectory)) { New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null } $metadata = if ($primaryMetadata) { $primaryMetadata } else { @{ SubscriptionName = 'Unknown' SubscriptionId = 'Unknown' TenantId = 'Unknown' ScannedBy = '' ScanTimestamp = [DateTime]::UtcNow.ToString('o') } } if ($AllSubscriptions) { $metadata.SubscriptionName = "All Subscriptions ($($multiSubData.Count))" } if (-not $ReportName) { $subName = if ($metadata.SubscriptionName) { $metadata.SubscriptionName } else { 'Unknown' } $safeSub = if ($AllSubscriptions) { "AllSubscriptions" } else { $subName -replace '[^\w\-]', '_' } $dateStamp = (Get-Date).ToString('yyyyMMdd-HHmmss') $ReportName = "CIS-Azure-$($script:CISBenchmarkVersion)_${safeSub}_${dateStamp}" } $formats = if ('All' -in $OutputFormat) { @('HTML', 'JSON', 'CSV', 'SARIF') } else { $OutputFormat } $reportPaths = @{} if ('HTML' -in $formats) { $htmlPath = Join-Path $OutputDirectory "$ReportName.html" Write-Host " Generating HTML report..." -ForegroundColor Yellow $htmlParams = @{ Results = $allResults OutputPath = $htmlPath Metadata = $metadata } if ($multiSubData.Count -gt 0) { $htmlParams.MultiSubscriptionData = $multiSubData } $reportPaths.HTML = New-CISHtmlReport @htmlParams Write-Host " HTML: $htmlPath" -ForegroundColor Green } if ('JSON' -in $formats) { $jsonPath = Join-Path $OutputDirectory "$ReportName.json" Write-Host " Generating JSON report..." -ForegroundColor Yellow $reportPaths.JSON = New-CISJsonReport -Results $allResults -OutputPath $jsonPath -Metadata $metadata Write-Host " JSON: $jsonPath" -ForegroundColor Green } if ('CSV' -in $formats) { $csvPath = Join-Path $OutputDirectory "$ReportName.csv" Write-Host " Generating CSV report..." -ForegroundColor Yellow $reportPaths.CSV = New-CISCsvReport -Results $allResults -OutputPath $csvPath -Metadata $metadata Write-Host " CSV: $csvPath" -ForegroundColor Green } if ('SARIF' -in $formats) { $sarifPath = Join-Path $OutputDirectory "$ReportName.sarif" Write-Host " Generating SARIF report..." -ForegroundColor Yellow $reportPaths.SARIF = New-CISSarifReport -Results $allResults -OutputPath $sarifPath -Metadata $metadata Write-Host " SARIF: $sarifPath" -ForegroundColor Green } # ---------------------------------------------------------------- # 7. Done # ---------------------------------------------------------------- # Restore warning preference $WarningPreference = $savedWarningPref $elapsed = (Get-Date) - $startTime $elapsedDisplay = if ($elapsed.TotalMinutes -ge 1) { "{0}m {1}s" -f [math]::Floor($elapsed.TotalMinutes), $elapsed.Seconds } else { "$([math]::Round($elapsed.TotalSeconds, 1))s" } $scoreColor = if ($score -ge 80) { 'Green' } elseif ($score -ge 50) { 'Yellow' } else { 'Red' } $scoreDisplay = if ($score -eq -1) { "N/A" } else { "$score%" } Write-Host '' Write-Host ' +============================================================+' -ForegroundColor DarkCyan Write-Host ' | SCAN COMPLETE |' -ForegroundColor DarkCyan Write-Host ' +============================================================+' -ForegroundColor DarkCyan $boxWidth = 60 # inner width between | and | Write-Host ' |' -ForegroundColor DarkCyan -NoNewline $scoreContent = " Score: $scoreDisplay" Write-Host $scoreContent -ForegroundColor $scoreColor -NoNewline Write-Host (' ' * [math]::Max(0, $boxWidth - $scoreContent.Length)) -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' |' -ForegroundColor DarkCyan -NoNewline Write-Host " PASS: $passCount " -ForegroundColor Green -NoNewline Write-Host "FAIL: $failCount " -ForegroundColor Red -NoNewline Write-Host "WARN: $warningCount " -ForegroundColor Yellow -NoNewline Write-Host "INFO: $infoCount " -ForegroundColor Gray -NoNewline Write-Host "ERR: $errorCount" -ForegroundColor Magenta -NoNewline $statLine = " PASS: $passCount FAIL: $failCount WARN: $warningCount INFO: $infoCount ERR: $errorCount" Write-Host (' ' * [math]::Max(0, $boxWidth - $statLine.Length)) -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' |' -ForegroundColor DarkCyan -NoNewline $durContent = " Duration: $elapsedDisplay" Write-Host $durContent -ForegroundColor White -NoNewline Write-Host (' ' * [math]::Max(0, $boxWidth - $durContent.Length)) -NoNewline Write-Host '|' -ForegroundColor DarkCyan Write-Host ' +------------------------------------------------------------+' -ForegroundColor DarkCyan Write-Host ' | Report Files: |' -ForegroundColor DarkCyan foreach ($fmt in $reportPaths.Keys) { $fullPath = (Resolve-Path $reportPaths[$fmt]).Path $line = " $($fmt.PadRight(6)) $fullPath" if ($line.Length -gt $boxWidth) { $line = $line.Substring(0, $boxWidth - 3) + '...' } $linePad = [math]::Max(0, $boxWidth - $line.Length) Write-Host ' |' -ForegroundColor DarkCyan -NoNewline Write-Host "$line" -ForegroundColor White -NoNewline Write-Host (' ' * $linePad) -NoNewline Write-Host '|' -ForegroundColor DarkCyan } Write-Host ' +============================================================+' -ForegroundColor DarkCyan Write-Host '' Write-Host ' Note: Reports contain sensitive data. Do not commit to source control.' -ForegroundColor DarkYellow Write-Host '' # Auto-open HTML report if (-not $NoAutoOpen -and $reportPaths.ContainsKey('HTML') -and (Test-Path $reportPaths.HTML)) { try { Start-Process (Resolve-Path $reportPaths.HTML).Path } catch { Write-Verbose "Could not auto-open HTML report: $($_.Exception.Message)" } } $resultObj = [PSCustomObject]@{ PSTypeName = 'CISBenchmarkReport' Score = $score TotalControls = $allResults.Count Passed = $passCount Failed = $failCount Warning = $warningCount Info = $infoCount Error = $errorCount Results = $allResults ReportPaths = $reportPaths Metadata = $metadata Duration = $elapsed MultiSubscriptionData = $multiSubData } # Set default display properties so the console shows a clean summary (not the full Results array) $defaultProps = @('Score', 'TotalControls', 'Passed', 'Failed', 'Warning', 'Info', 'Error', 'Duration') $defaultSet = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', [string[]]$defaultProps) $resultObj | Add-Member MemberSet PSStandardMembers ([System.Management.Automation.PSMemberInfo[]]@($defaultSet)) return $resultObj } # end try finally { # Always restore warning preference and env vars, even on Ctrl+C or early return $WarningPreference = $savedWarningPref if ($null -eq $savedEnvVar) { Remove-Item Env:\SuppressAzurePowerShellBreakingChangeWarnings -ErrorAction SilentlyContinue } else { $env:SuppressAzurePowerShellBreakingChangeWarnings = $savedEnvVar } } } |