Common/Export-FrameworkCatalog.ps1
|
function Export-FrameworkCatalog { <# .SYNOPSIS Produces framework-specific catalog output from assessment findings. .DESCRIPTION Dispatches on the framework's scoring method to parse controlId strings into structural groups and compute coverage/pass rates per group. Returns a uniform GroupedResult structure for Grouped mode. .PARAMETER Findings Array of finding objects from the assessment collectors. .PARAMETER Framework Framework hashtable from Import-FrameworkDefinitions (includes scoringMethod, scoringData, profiles, totalControls, etc.). .PARAMETER ControlRegistry Hashtable of checkId -> registry entry, used as fallback for framework mapping. .PARAMETER Mode Rendering mode: Inline (embed in report), Grouped (return data structure), or Standalone (full HTML page). .PARAMETER OutputPath Output file path for Standalone mode. .PARAMETER TenantName Tenant display name for Standalone mode headers. .OUTPUTS System.Collections.Hashtable GroupedResult with Groups array and Summary for Grouped mode. System.String placeholder for Inline and Standalone modes. #> [CmdletBinding()] param( [Parameter(Mandatory)][PSCustomObject[]]$Findings, [Parameter(Mandatory)][hashtable]$Framework, [Parameter(Mandatory)][hashtable]$ControlRegistry, [Parameter(Mandatory)][ValidateSet('Inline','Grouped','Standalone')][string]$Mode, [Parameter()][string]$OutputPath, [Parameter()][string]$TenantName ) # --- Common: resolve framework mappings and score --- $scoredResult = Invoke-FrameworkScoring -Findings $Findings -Framework $Framework -ControlRegistry $ControlRegistry if ($Mode -eq 'Grouped') { return $scoredResult } if ($Mode -eq 'Inline') { return ConvertTo-CatalogInlineHtml -Framework $Framework -ScoredResult $scoredResult -MappedFindings $scoredResult.MappedFindings } # --- Standalone mode: write complete HTML file --- if (-not $OutputPath) { throw 'Standalone mode requires the -OutputPath parameter.' } if (-not $TenantName) { $TenantName = 'Unknown' } $standaloneContent = ConvertTo-CatalogStandaloneHtml -Framework $Framework -ScoredResult $scoredResult -MappedFindings $scoredResult.MappedFindings -TenantName $TenantName $standaloneContent | Set-Content -Path $OutputPath -Encoding UTF8 -Force return $OutputPath } # --------------------------------------------------------------------------- # Private: run scoring engine and return GroupedResult + MappedFindings # --------------------------------------------------------------------------- function Invoke-FrameworkScoring { [CmdletBinding()] param( [PSCustomObject[]]$Findings, [hashtable]$Framework, [hashtable]$ControlRegistry ) $fwId = $Framework.frameworkId $scoringMethod = $Framework.scoringMethod Write-Verbose "Export-FrameworkCatalog: Processing '$fwId' with scoring method '$scoringMethod'" # Resolve framework mapping for each finding $mappedFindings = [System.Collections.Generic.List[hashtable]]::new() foreach ($finding in $Findings) { $fwMapping = $null if ($finding.Frameworks -and $finding.Frameworks.PSObject.Properties.Name -contains $fwId) { $fwMapping = $finding.Frameworks.$fwId } elseif ($finding.Frameworks -is [hashtable] -and $finding.Frameworks.ContainsKey($fwId)) { $fwMapping = $finding.Frameworks[$fwId] } if (-not $fwMapping -and $finding.CheckId -and $ControlRegistry.ContainsKey($finding.CheckId)) { $regEntry = $ControlRegistry[$finding.CheckId] if ($regEntry.frameworks -and $regEntry.frameworks.PSObject.Properties.Name -contains $fwId) { $fwMapping = $regEntry.frameworks.$fwId } elseif ($regEntry.frameworks -is [hashtable] -and $regEntry.frameworks.ContainsKey($fwId)) { $fwMapping = $regEntry.frameworks[$fwId] } } if ($fwMapping) { $controlId = '' if ($fwMapping -is [hashtable] -and $fwMapping.ContainsKey('controlId')) { $controlId = [string]$fwMapping.controlId } elseif ($fwMapping.PSObject.Properties.Name -contains 'controlId') { $controlId = [string]$fwMapping.controlId } $profiles = @() if ($fwMapping -is [hashtable] -and $fwMapping.ContainsKey('profiles')) { $profiles = @($fwMapping.profiles) } elseif ($fwMapping.PSObject -and $fwMapping.PSObject.Properties.Name -contains 'profiles') { $profiles = @($fwMapping.profiles) } $mappedFindings.Add(@{ Finding = $finding ControlId = $controlId Profiles = $profiles }) } } Write-Verbose "Export-FrameworkCatalog: Mapped $($mappedFindings.Count) of $($Findings.Count) findings to '$fwId'" # Validate scoring method $validMethods = @( 'profile-compliance', 'function-coverage', 'control-coverage', 'technique-coverage', 'maturity-level', 'severity-coverage', 'requirement-compliance', 'criteria-coverage', 'policy-compliance' ) if (-not $scoringMethod -or $scoringMethod -notin $validMethods) { Write-Warning "Unknown scoring method '$scoringMethod' for framework '$fwId'; falling back to control-coverage." $scoringMethod = 'control-coverage' } # Dispatch to scoring handler $groups = switch ($scoringMethod) { 'profile-compliance' { Invoke-ProfileCompliance -Framework $Framework -MappedFindings $mappedFindings } 'function-coverage' { Invoke-FunctionCoverage -Framework $Framework -MappedFindings $mappedFindings } 'control-coverage' { Invoke-ControlCoverage -Framework $Framework -MappedFindings $mappedFindings } 'technique-coverage' { Invoke-TechniqueCoverage -Framework $Framework -MappedFindings $mappedFindings } 'maturity-level' { Invoke-MaturityLevel -Framework $Framework -MappedFindings $mappedFindings } 'severity-coverage' { Invoke-SeverityCoverage -Framework $Framework -MappedFindings $mappedFindings } 'requirement-compliance' { Invoke-RequirementCompliance -Framework $Framework -MappedFindings $mappedFindings } 'criteria-coverage' { Invoke-CriteriaCoverage -Framework $Framework -MappedFindings $mappedFindings } 'policy-compliance' { Invoke-PolicyCompliance -Framework $Framework -MappedFindings $mappedFindings } } # Sort groups by key for consistent display order # Scoring data key order maps: numeric (1,2,3), alpha-numeric (L1,L2,ML1,ML2), Roman (CAT-I,CAT-II) $groups = @($groups | Sort-Object -Property { Get-GroupSortKey -Key $_.Key }) # Build summary $totalMapped = ($mappedFindings | ForEach-Object { $_.Finding.CheckId } | Select-Object -Unique).Count $totalPassed = ($mappedFindings | Where-Object { $_.Finding.Status -eq 'Pass' } | ForEach-Object { $_.Finding.CheckId } | Select-Object -Unique).Count $passRate = if ($totalMapped -gt 0) { [math]::Round($totalPassed / $totalMapped, 2) } else { 0 } # Deduplicate covered controls across all groups by unique framework controlId # (profiles overlap -- E5-L1 includes E3-L1 -- so summing per-group would double-count) $allCoveredIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($mf in $mappedFindings) { if ($mf.ControlId) { foreach ($cid in ($mf.ControlId -split ';')) { [void]$allCoveredIds.Add($cid.Trim()) } } } $totalCovered = $allCoveredIds.Count return @{ Groups = @($groups) Summary = @{ TotalControls = [int]$Framework.totalControls MappedControls = $totalMapped CoveredControls = $totalCovered PassRate = $passRate } MappedFindings = $mappedFindings } } # --------------------------------------------------------------------------- # Private: render Inline HTML fragment for a single framework catalog # --------------------------------------------------------------------------- function ConvertTo-CatalogInlineHtml { [CmdletBinding()] param( [hashtable]$Framework, [hashtable]$ScoredResult, [System.Collections.Generic.List[hashtable]]$MappedFindings ) $fwId = $Framework.frameworkId $fwLabel = $Framework.label $fwCss = if ($Framework.css) { $Framework.css } else { 'fw-default' } $summary = $ScoredResult.Summary $groups = $ScoredResult.Groups $html = [System.Text.StringBuilder]::new(4096) # Outer collapsible section $null = $html.AppendLine("<details class='section catalog-section' data-fw='$fwId'>") $null = $html.AppendLine("<summary><h3><span class='fw-tag $fwCss'>$fwLabel</span> Framework Catalog</h3></summary>") # Zero-mapped placeholder if ($summary.MappedControls -eq 0) { $null = $html.AppendLine("<p class='catalog-empty'>No assessed findings map to this framework.</p>") $null = $html.AppendLine("</details>") return $html.ToString() } # Overall summary bar $passRatePct = [math]::Round($summary.PassRate * 100, 1) $passClass = if ($passRatePct -ge 80) { 'success' } elseif ($passRatePct -ge 60) { 'warning' } else { 'danger' } $coveredCount = if ($summary.CoveredControls) { $summary.CoveredControls } else { $summary.MappedControls } $coveragePct = if ($summary.TotalControls -gt 0) { [math]::Min(100, [math]::Round(($coveredCount / $summary.TotalControls) * 100, 0)) } else { 0 } $null = $html.AppendLine("<div class='catalog-summary'>") $null = $html.AppendLine("<div class='catalog-stats'>") $null = $html.AppendLine("<span class='catalog-stat'><strong>Pass Rate:</strong> <span class='badge badge-$passClass'>$passRatePct%</span></span>") if ($summary.TotalControls -gt 0) { $null = $html.AppendLine("<span class='catalog-stat'><strong>Coverage:</strong> $coveredCount of $($summary.TotalControls) controls</span>") } $null = $html.AppendLine("<span class='catalog-stat'><strong>Findings:</strong> $($summary.MappedControls) assessed</span>") $null = $html.AppendLine("<span class='catalog-stat'><strong>Scoring:</strong> $($Framework.scoringMethod)</span>") $null = $html.AppendLine("</div>") if ($summary.TotalControls -gt 0) { $null = $html.AppendLine("<div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div>") $null = $html.AppendLine("<div class='coverage-label'>$coveragePct% coverage</div>") } $null = $html.AppendLine("</div>") # Group breakdown table $null = $html.AppendLine("<table class='catalog-groups'><thead><tr>") $null = $html.AppendLine("<th>Group</th><th>Label</th><th>Coverage</th><th>Findings</th><th>Passed</th><th>Failed</th><th>Other</th><th>Pass Rate</th>") $null = $html.AppendLine("</tr></thead><tbody>") foreach ($group in $groups) { $grpPassRate = if ($group.Mapped -gt 0) { [math]::Round(($group.Passed / $group.Mapped) * 100, 1) } else { 0 } $grpClass = if ($group.Mapped -eq 0) { '' } elseif ($grpPassRate -ge 80) { 'success' } elseif ($grpPassRate -ge 60) { 'warning' } else { 'danger' } $null = $html.AppendLine("<tr>") $null = $html.AppendLine("<td><span class='fw-tag $fwCss'>$($group.Key)</span></td>") $null = $html.AppendLine("<td>$($group.Label)</td>") $coverageDisplay = if ($group.Total -gt 0) { "$($group.Covered)/$($group.Total)" } else { "$($group.Covered)" } $null = $html.AppendLine("<td>$coverageDisplay</td>") $null = $html.AppendLine("<td>$($group.Mapped)</td>") $null = $html.AppendLine("<td>$($group.Passed)</td>") $null = $html.AppendLine("<td>$($group.Failed)</td>") $null = $html.AppendLine("<td>$($group.Other)</td>") $passDisplay = if ($group.Mapped -gt 0) { "$grpPassRate%" } else { '—' } $badgeCss = switch ($grpClass) { 'success' { 'badge-success' } 'warning' { 'badge-warning' } 'danger' { 'badge-failed' } default { 'badge-neutral' } } $null = $html.AppendLine("<td><span class='badge $badgeCss'>$passDisplay</span></td>") $null = $html.AppendLine("</tr>") } $null = $html.AppendLine("</tbody></table>") # Findings detail table (collapsible) $null = $html.AppendLine("<details class='catalog-findings-detail'>") $null = $html.AppendLine("<summary><strong>Detailed Findings ($($summary.MappedControls) mapped)</strong></summary>") $null = $html.AppendLine("<table class='cis-table catalog-findings'><thead><tr>") $null = $html.AppendLine("<th>Status</th><th>Check ID</th><th>Setting</th><th>Control ID</th><th>Severity</th>") $null = $html.AppendLine("</tr></thead><tbody>") foreach ($mf in $MappedFindings) { $finding = $mf.Finding $statusBadge = switch ($finding.Status) { 'Pass' { 'badge-success' } 'Fail' { 'badge-failed' } 'Warning' { 'badge-warning' } 'Review' { 'badge-info' } 'Info' { 'badge-neutral' } default { 'badge-neutral' } } $severityBadge = switch ($finding.RiskSeverity) { 'Critical' { 'badge-critical' } 'High' { 'badge-failed' } 'Medium' { 'badge-warning' } 'Low' { 'badge-info' } default { 'badge-neutral' } } $controlDisplay = $mf.ControlId -replace ';', '; ' $rowClass = if ($finding.Status -eq 'Pass') { 'cis-row-pass' } elseif ($finding.Status -eq 'Fail') { 'cis-row-fail' } else { '' } $null = $html.AppendLine("<tr class='$rowClass'>") $null = $html.AppendLine("<td><span class='badge $statusBadge'>$($finding.Status)</span></td>") $null = $html.AppendLine("<td class='cis-id'>$($finding.CheckId)</td>") $null = $html.AppendLine("<td>$($finding.Setting)</td>") $null = $html.AppendLine("<td><span class='fw-tag $fwCss'>$controlDisplay</span></td>") $null = $html.AppendLine("<td><span class='badge $severityBadge'>$($finding.RiskSeverity)</span></td>") $null = $html.AppendLine("</tr>") } $null = $html.AppendLine("</tbody></table>") $null = $html.AppendLine("</details>") $null = $html.AppendLine("</details>") return $html.ToString() } # --------------------------------------------------------------------------- # Private: render Standalone HTML document for a single framework catalog # --------------------------------------------------------------------------- function ConvertTo-CatalogStandaloneHtml { [CmdletBinding()] param( [hashtable]$Framework, [hashtable]$ScoredResult, [System.Collections.Generic.List[hashtable]]$MappedFindings, [string]$TenantName ) $fwLabel = $Framework.label $fwCss = if ($Framework.css) { $Framework.css } else { 'fw-default' } $summary = $ScoredResult.Summary $groups = $ScoredResult.Groups $assessmentDate = Get-Date -Format 'yyyy-MM-dd HH:mm' # Get the inline body content (reuse the inline renderer's table logic) $passRatePct = [math]::Round($summary.PassRate * 100, 1) $passClass = if ($passRatePct -ge 80) { 'success' } elseif ($passRatePct -ge 60) { 'warning' } else { 'danger' } $coveredCount = if ($summary.CoveredControls) { $summary.CoveredControls } else { $summary.MappedControls } $coveragePct = if ($summary.TotalControls -gt 0) { [math]::Min(100, [math]::Round(($coveredCount / $summary.TotalControls) * 100, 0)) } else { 0 } $body = [System.Text.StringBuilder]::new(8192) # Cover / header section $null = $body.AppendLine("<div class='catalog-header'>") $null = $body.AppendLine("<h1><span class='fw-tag $fwCss' style='font-size: 0.9em; padding: 4px 12px;'>$fwLabel</span> Framework Catalog</h1>") $null = $body.AppendLine("<p class='catalog-meta'>Tenant: <strong>$TenantName</strong> • Generated: $assessmentDate • Scoring: $($Framework.scoringMethod)</p>") $null = $body.AppendLine("</div>") # Summary stats $null = $body.AppendLine("<div class='catalog-summary'>") $null = $body.AppendLine("<div class='catalog-stats'>") $null = $body.AppendLine("<span class='catalog-stat'><strong>Pass Rate:</strong> <span class='badge badge-$passClass'>$passRatePct%</span></span>") if ($summary.TotalControls -gt 0) { $null = $body.AppendLine("<span class='catalog-stat'><strong>Coverage:</strong> $coveredCount of $($summary.TotalControls) controls</span>") } $null = $body.AppendLine("<span class='catalog-stat'><strong>Findings:</strong> $($summary.MappedControls) assessed</span>") $null = $body.AppendLine("</div>") if ($summary.TotalControls -gt 0) { $null = $body.AppendLine("<div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div>") $null = $body.AppendLine("<div class='coverage-label'>$coveragePct% coverage</div>") } $null = $body.AppendLine("</div>") # Group breakdown table $null = $body.AppendLine("<h2>Group Breakdown</h2>") $null = $body.AppendLine("<table class='catalog-groups'><thead><tr>") $null = $body.AppendLine("<th>Group</th><th>Label</th><th>Coverage</th><th>Findings</th><th>Passed</th><th>Failed</th><th>Other</th><th>Pass Rate</th>") $null = $body.AppendLine("</tr></thead><tbody>") foreach ($group in $groups) { $grpPassRate = if ($group.Mapped -gt 0) { [math]::Round(($group.Passed / $group.Mapped) * 100, 1) } else { 0 } $grpClass = if ($group.Mapped -eq 0) { '' } elseif ($grpPassRate -ge 80) { 'success' } elseif ($grpPassRate -ge 60) { 'warning' } else { 'danger' } $null = $body.AppendLine("<tr>") $null = $body.AppendLine("<td><span class='fw-tag $fwCss'>$($group.Key)</span></td>") $null = $body.AppendLine("<td>$($group.Label)</td>") $coverageDisplay = if ($group.Total -gt 0) { "$($group.Covered)/$($group.Total)" } else { "$($group.Covered)" } $null = $body.AppendLine("<td>$coverageDisplay</td>") $null = $body.AppendLine("<td>$($group.Mapped)</td>") $null = $body.AppendLine("<td>$($group.Passed)</td>") $null = $body.AppendLine("<td>$($group.Failed)</td>") $null = $body.AppendLine("<td>$($group.Other)</td>") $passDisplay = if ($group.Mapped -gt 0) { "$grpPassRate%" } else { '—' } $badgeCss = switch ($grpClass) { 'success' { 'badge-success' } 'warning' { 'badge-warning' } 'danger' { 'badge-failed' } default { 'badge-neutral' } } $null = $body.AppendLine("<td><span class='badge $badgeCss'>$passDisplay</span></td>") $null = $body.AppendLine("</tr>") } $null = $body.AppendLine("</tbody></table>") # Findings detail table if ($MappedFindings.Count -gt 0) { $null = $body.AppendLine("<h2>Detailed Findings ($($summary.MappedControls) mapped)</h2>") $null = $body.AppendLine("<table class='cis-table catalog-findings'><thead><tr>") $null = $body.AppendLine("<th>Status</th><th>Check ID</th><th>Setting</th><th>Control ID</th><th>Severity</th>") $null = $body.AppendLine("</tr></thead><tbody>") foreach ($mf in $MappedFindings) { $finding = $mf.Finding $statusBadge = switch ($finding.Status) { 'Pass' { 'badge-success' } 'Fail' { 'badge-failed' } 'Warning' { 'badge-warning' } 'Review' { 'badge-info' } 'Info' { 'badge-neutral' } default { 'badge-neutral' } } $severityBadge = switch ($finding.RiskSeverity) { 'Critical' { 'badge-critical' } 'High' { 'badge-failed' } 'Medium' { 'badge-warning' } 'Low' { 'badge-info' } default { 'badge-neutral' } } $controlDisplay = $mf.ControlId -replace ';', '; ' $rowClass = if ($finding.Status -eq 'Pass') { 'cis-row-pass' } elseif ($finding.Status -eq 'Fail') { 'cis-row-fail' } else { '' } $null = $body.AppendLine("<tr class='$rowClass'>") $null = $body.AppendLine("<td><span class='badge $statusBadge'>$($finding.Status)</span></td>") $null = $body.AppendLine("<td class='cis-id'>$($finding.CheckId)</td>") $null = $body.AppendLine("<td>$($finding.Setting)</td>") $null = $body.AppendLine("<td><span class='fw-tag $fwCss'>$controlDisplay</span></td>") $null = $body.AppendLine("<td><span class='badge $severityBadge'>$($finding.RiskSeverity)</span></td>") $null = $body.AppendLine("</tr>") } $null = $body.AppendLine("</tbody></table>") } else { $null = $body.AppendLine("<p class='catalog-empty'>No assessed findings map to this framework.</p>") } $bodyContent = $body.ToString() # Assemble full HTML document with embedded CSS return @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$fwLabel Catalog - $TenantName</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet"> <style> :root { --m365a-primary: #2563EB; --m365a-dark-primary: #1D4ED8; --m365a-accent: #60A5FA; --m365a-dark: #0F172A; --m365a-dark-gray: #1E293B; --m365a-medium-gray: #64748B; --m365a-light-gray: #F1F5F9; --m365a-border: #CBD5E1; --m365a-white: #ffffff; --m365a-success: #2ecc71; --m365a-warning: #f39c12; --m365a-danger: #e74c3c; --m365a-info: #3498db; --m365a-success-bg: #d4edda; --m365a-warning-bg: #fff3cd; --m365a-danger-bg: #f8d7da; --m365a-info-bg: #d1ecf1; --m365a-neutral: #6b7280; --m365a-neutral-bg: #f3f4f6; --m365a-body-bg: #ffffff; --m365a-text: #1E293B; --m365a-card-bg: #ffffff; --m365a-hover-bg: #e8f4f8; } body.dark-theme { --m365a-primary: #60A5FA; --m365a-dark-primary: #93C5FD; --m365a-accent: #3B82F6; --m365a-dark: #F1F5F9; --m365a-dark-gray: #E2E8F0; --m365a-medium-gray: #94A3B8; --m365a-light-gray: #1E293B; --m365a-border: #334155; --m365a-white: #0F172A; --m365a-body-bg: #0F172A; --m365a-text: #E2E8F0; --m365a-card-bg: #1E293B; --m365a-hover-bg: #1E3A5F; --m365a-success: #34D399; --m365a-warning: #FBBF24; --m365a-danger: #F87171; --m365a-info: #60A5FA; --m365a-success-bg: #064E3B; --m365a-warning-bg: #78350F; --m365a-danger-bg: #7F1D1D; --m365a-info-bg: #1E3A5F; --m365a-neutral: #9ca3af; --m365a-neutral-bg: #374151; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Inter', 'Segoe UI', Arial, sans-serif; font-size: 13pt; line-height: 1.5; color: var(--m365a-text); background: var(--m365a-body-bg); padding: 40px; max-width: 1200px; margin: 0 auto; } h1 { font-size: 1.8em; margin-bottom: 8px; color: var(--m365a-dark); } h2 { font-size: 1.3em; margin: 24px 0 12px; color: var(--m365a-dark); border-bottom: 2px solid var(--m365a-primary); padding-bottom: 6px; } table { width: 100%; border-collapse: collapse; margin-bottom: 20px; font-size: 10pt; } th { background: var(--m365a-dark); color: #fff; padding: 10px 12px; text-align: left; font-weight: 600; font-size: 9pt; } td { padding: 8px 12px; border-bottom: 1px solid var(--m365a-border); vertical-align: top; } tr:nth-child(even) { background: var(--m365a-light-gray); } tr:hover { background: var(--m365a-hover-bg); } .badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .badge-success { background: var(--m365a-success-bg); color: #155724; } .badge-failed { background: var(--m365a-danger-bg); color: #721c24; } .badge-warning { background: var(--m365a-warning-bg); color: #856404; } .badge-info { background: var(--m365a-info-bg); color: #0c5460; } .badge-neutral { background-color: var(--m365a-neutral-bg); color: var(--m365a-neutral); } .badge-critical { background: #991b1b; color: #fef2f2; } .fw-tag { display: inline-block; padding: 1px 5px; margin: 1px; border-radius: 3px; font-size: 0.72em; font-family: 'Consolas', 'Courier New', monospace; } .fw-cis { background: #e8f0fe; color: #1a56db; } .fw-cis-l2 { background: #dbeafe; color: #1e40af; } .fw-nist { background: #e8f0fe; color: #1a56db; } .fw-nist-high { background: #dbeafe; color: #1e40af; } .fw-nist-privacy { background: #ede9fe; color: #5b21b6; } .fw-csf { background: #fef3c7; color: #92400e; } .fw-iso { background: #ecfdf5; color: #065f46; } .fw-stig { background: #f3e8ff; color: #6b21a8; } .fw-pci { background: #fef2f2; color: #991b1b; } .fw-cmmc { background: #f0fdfa; color: #134e4a; } .fw-hipaa { background: #fdf2f8; color: #9d174d; } .fw-scuba { background: #fff7ed; color: #9a3412; } .fw-soc2 { background: #eff6ff; color: #1e3a5f; } .fw-fedramp { background: #fef3c7; color: #78350f; } .fw-essential8 { background: #ecfdf5; color: #14532d; } .fw-mitre { background: #fef2f2; color: #7f1d1d; } .fw-cisv8 { background: #e0f2fe; color: #0c4a6e; } .fw-default { background: #e2e8f0; color: #334155; } .cis-id { font-family: 'Consolas', 'Courier New', monospace; font-size: 0.9em; white-space: nowrap; } .cis-row-pass { border-left: 3px solid var(--m365a-success); background-color: var(--m365a-success-bg); } .cis-row-fail { border-left: 3px solid var(--m365a-danger); background-color: var(--m365a-danger-bg); } .cis-row-pass:nth-child(even), .cis-row-fail:nth-child(even) { background-image: linear-gradient(rgba(0,0,0,0.06), rgba(0,0,0,0.06)); } .coverage-bar { margin-top: 6px; background: var(--m365a-border); border-radius: 4px; height: 6px; overflow: hidden; } .coverage-fill { height: 100%; border-radius: 4px; transition: width 0.3s; } .catalog-summary .coverage-fill { background: var(--m365a-primary); } .coverage-label { font-size: 0.65em; color: var(--m365a-medium-gray); margin-top: 2px; } .catalog-header { margin-bottom: 24px; } .catalog-meta { font-size: 0.85em; color: var(--m365a-medium-gray); } .catalog-summary { margin-bottom: 20px; padding: 16px; background: var(--m365a-card-bg); border: 1px solid var(--m365a-border); border-radius: 6px; } .catalog-stats { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 4px; } .catalog-stat { font-size: 0.9em; } .catalog-empty { color: var(--m365a-medium-gray); font-style: italic; padding: 20px; } .theme-toggle { position: fixed; top: 16px; right: 16px; background: var(--m365a-card-bg); border: 1px solid var(--m365a-border); border-radius: 50%; width: 36px; height: 36px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; z-index: 100; } .theme-toggle:hover { transform: scale(1.1); } body:not(.dark-theme) .theme-icon-dark { display: none; } body.dark-theme .theme-icon-light { display: none; } body.dark-theme th { background: #1E3A5F; color: #E2E8F0; } body.dark-theme .badge-success { background: #065F46; color: #6EE7B7; } body.dark-theme .badge-failed { background: #7F1D1D; color: #FCA5A5; } body.dark-theme .badge-warning { background: #78350F; color: #FCD34D; } body.dark-theme .badge-info { background: #1E3A5F; color: #93C5FD; } body.dark-theme .badge-neutral { background-color: var(--m365a-neutral-bg); color: var(--m365a-neutral); } body.dark-theme .fw-cis { background: #1E3A5F; color: #93C5FD; } body.dark-theme .fw-cis-l2 { background: #1E3A5F; color: #60A5FA; } body.dark-theme .fw-nist { background: #1E3A5F; color: #93C5FD; } body.dark-theme .fw-nist-high { background: #1E3A5F; color: #60A5FA; } body.dark-theme .fw-nist-privacy { background: #2E1065; color: #C4B5FD; } body.dark-theme .fw-csf { background: #78350F; color: #FCD34D; } body.dark-theme .fw-iso { background: #064E3B; color: #6EE7B7; } body.dark-theme .fw-stig { background: #3B0764; color: #C4B5FD; } body.dark-theme .fw-pci { background: #7F1D1D; color: #FCA5A5; } body.dark-theme .fw-cmmc { background: #134E4A; color: #5EEAD4; } body.dark-theme .fw-hipaa { background: #831843; color: #F9A8D4; } body.dark-theme .fw-scuba { background: #7C2D12; color: #FDBA74; } body.dark-theme .fw-soc2 { background: #1E3A5F; color: #60A5FA; } body.dark-theme .fw-fedramp { background: #78350F; color: #FCD34D; } body.dark-theme .fw-essential8 { background: #064E3B; color: #6EE7B7; } body.dark-theme .fw-mitre { background: #7F1D1D; color: #FCA5A5; } body.dark-theme .fw-cisv8 { background: #164E63; color: #67E8F9; } body.dark-theme .fw-default { background: #334155; color: #94A3B8; } @media print { .theme-toggle { display: none; } body { padding: 20px; } } </style> </head> <body> <button class="theme-toggle" onclick="document.body.classList.toggle('dark-theme')" title="Toggle dark theme"> <span class="theme-icon-light">☾</span> <span class="theme-icon-dark">☼</span> </button> $bodyContent <footer style="margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--m365a-border); font-size: 0.75em; color: var(--m365a-medium-gray);"> Generated by M365-Assess Framework Catalog Engine </footer> </body> </html> "@ } # --------------------------------------------------------------------------- # Private helper: build a group hashtable from a bucket of findings # --------------------------------------------------------------------------- function New-ScoringGroup { [CmdletBinding()] param( [string]$Key, [string]$Label, [int]$Total, [System.Collections.Generic.List[PSCustomObject]]$GroupFindings, [int]$Covered = -1 ) $unique = @($GroupFindings | Select-Object -Property CheckId -Unique) $passed = @($GroupFindings | Where-Object { $_.Status -eq 'Pass' } | Select-Object -Property CheckId -Unique) $failed = @($GroupFindings | Where-Object { $_.Status -eq 'Fail' } | Select-Object -Property CheckId -Unique) $other = $unique.Count - $passed.Count - $failed.Count if ($other -lt 0) { $other = 0 } # Covered = unique framework controls with findings (if tracked by scorer) # Falls back to Mapped when scorer doesn't track coverage $coveredCount = if ($Covered -ge 0) { $Covered } else { $unique.Count } @{ Key = $Key Label = $Label Total = $Total Mapped = $unique.Count Covered = $coveredCount Passed = $passed.Count Failed = $failed.Count Other = $other Findings = @($GroupFindings) } } # --------------------------------------------------------------------------- # Private helper: resolve the scoring data sub-object by trying common keys # --------------------------------------------------------------------------- function Get-ScoringSubObject { [CmdletBinding()] param( [hashtable]$Framework, [string]$Key ) $sd = $Framework.scoringData if (-not $sd) { return $null } # scoringData is a hashtable; try direct key lookup if ($sd -is [hashtable] -and $sd.ContainsKey($Key)) { $val = $sd[$Key] } elseif ($sd.PSObject -and $sd.PSObject.Properties.Name -contains $Key) { $val = $sd.$Key } else { return $null } # Convert PSCustomObject to hashtable for consistent .Keys usage if ($val -is [System.Management.Automation.PSCustomObject]) { $ht = @{} foreach ($prop in $val.PSObject.Properties) { $ht[$prop.Name] = $prop.Value } return $ht } return $val } # --------------------------------------------------------------------------- # Private helper: generate a sortable key for group ordering # Handles: numeric (1,2,3), alpha-numeric (L1,L2,ML1,GV,ID,PR), Roman (CAT-I,CAT-II) # --------------------------------------------------------------------------- function Get-GroupSortKey { [CmdletBinding()] param([string]$Key) # Roman numeral suffix (CAT-I, CAT-II, CAT-III) $romanMap = @{ 'I' = 1; 'II' = 2; 'III' = 3; 'IV' = 4; 'V' = 5 } if ($Key -match '-([IV]+)$') { $prefix = $Key -replace '-[IV]+$', '' $romanVal = if ($romanMap.ContainsKey($Matches[1])) { $romanMap[$Matches[1]] } else { 99 } return '{0}-{1:D3}' -f $prefix, $romanVal } # Alpha prefix + numeric suffix (L1, L2, ML1, ML2, IG1, IG2) if ($Key -match '^([A-Za-z]+)(\d+)$') { return '{0}{1:D3}' -f $Matches[1], [int]$Matches[2] } # Pure numeric (5, 6, 7, 8) if ($Key -match '^\d+$') { return '{0:D5}' -f [int]$Key } # CSF function order (canonical: GV=1, ID=2, PR=3, DE=4, RS=5, RC=6) $csfOrder = @{ 'GV' = 1; 'ID' = 2; 'PR' = 3; 'DE' = 4; 'RS' = 5; 'RC' = 6 } if ($csfOrder.ContainsKey($Key)) { return '{0:D3}' -f $csfOrder[$Key] } # Fallback: alphabetic return $Key } # --------------------------------------------------------------------------- # 1. profile-compliance # --------------------------------------------------------------------------- function Invoke-ProfileCompliance { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $profileDefs = $Framework.profiles if (-not $profileDefs -or $profileDefs.Count -eq 0) { # Fallback: try scoringData.profiles $profileDefs = Get-ScoringSubObject -Framework $Framework -Key 'profiles' } if (-not $profileDefs -or $profileDefs.Count -eq 0) { return @(New-ScoringGroup -Key 'All' -Label 'All Controls' -Total ([int]$Framework.totalControls) -GroupFindings ([System.Collections.Generic.List[PSCustomObject]]::new())) } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($profileKey in $profileDefs.Keys) { $profileInfo = $profileDefs[$profileKey] $label = if ($profileInfo -is [hashtable] -and $profileInfo.ContainsKey('label')) { $profileInfo.label } else { $profileKey } $controlCount = if ($profileInfo -is [hashtable] -and $profileInfo.ContainsKey('controlCount')) { [int]$profileInfo.controlCount } else { 0 } $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredControlIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($mf in $MappedFindings) { # If finding has profiles array, check membership; otherwise include in all profiles $inProfile = $false if ($mf.Profiles -and $mf.Profiles.Count -gt 0) { if ($profileKey -in $mf.Profiles) { $inProfile = $true } } else { $inProfile = $true } if ($inProfile) { $bucket.Add($mf.Finding) # Track unique framework controlIds (e.g. CIS "1.1.1") not CheckIds # to avoid inflating coverage when multiple checks map to same control if ($mf.ControlId) { foreach ($cid in ($mf.ControlId -split ';')) { [void]$coveredControlIds.Add($cid.Trim()) } } } } $groups.Add((New-ScoringGroup -Key $profileKey -Label $label -Total $controlCount -GroupFindings $bucket -Covered $coveredControlIds.Count)) } return @($groups) } # --------------------------------------------------------------------------- # 2. function-coverage (NIST CSF) # --------------------------------------------------------------------------- function Invoke-FunctionCoverage { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $functions = Get-ScoringSubObject -Framework $Framework -Key 'functions' if (-not $functions) { return @(New-ScoringGroup -Key 'All' -Label 'All Functions' -Total ([int]$Framework.totalControls) -GroupFindings ([System.Collections.Generic.List[PSCustomObject]]::new())) } # Build buckets keyed by function code + track unique controlIds per group $buckets = @{} $coveredIds = @{} foreach ($key in $functions.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() if ($trimmed -match '^([A-Z]{2})\.') { $funcKey = $Matches[1] if ($buckets.ContainsKey($funcKey)) { $buckets[$funcKey].Add($mf.Finding) [void]$coveredIds[$funcKey].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $functions.Keys) { $funcInfo = $functions[$key] $label = if ($funcInfo.label) { $funcInfo.label } else { $key } $total = if ($funcInfo.subcategories) { [int]$funcInfo.subcategories } else { 0 } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } # --------------------------------------------------------------------------- # 3. control-coverage (ISO 27001) # --------------------------------------------------------------------------- function Invoke-ControlCoverage { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $themes = Get-ScoringSubObject -Framework $Framework -Key 'themes' if (-not $themes) { # Generic fallback: single group $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Controls' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $buckets = @{} $coveredIds = @{} foreach ($key in $themes.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() # Pattern: A.{clause}.{control} -- extract clause number at index 1 $segments = $trimmed -split '\.' if ($segments.Count -ge 2) { $clauseKey = $segments[1] if ($buckets.ContainsKey($clauseKey)) { $buckets[$clauseKey].Add($mf.Finding) [void]$coveredIds[$clauseKey].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $themes.Keys) { $themeInfo = $themes[$key] $label = if ($themeInfo.label) { $themeInfo.label } else { $key } $total = if ($themeInfo.controlCount) { [int]$themeInfo.controlCount } else { 0 } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } # --------------------------------------------------------------------------- # 4. technique-coverage (MITRE ATT&CK) # --------------------------------------------------------------------------- function Invoke-TechniqueCoverage { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $tactics = Get-ScoringSubObject -Framework $Framework -Key 'tactics' # Load technique-to-tactic map $mapPath = Join-Path -Path $PSScriptRoot -ChildPath '../controls/mitre-technique-map.json' $techMap = @{} if (Test-Path -Path $mapPath) { $mapRaw = Get-Content -Path $mapPath -Raw | ConvertFrom-Json if ($mapRaw.map) { foreach ($prop in $mapRaw.map.PSObject.Properties) { $techMap[$prop.Name] = $prop.Value } } } else { Write-Warning "MITRE technique map not found at: $mapPath" } if (-not $tactics) { $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Techniques' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $buckets = @{} $coveredIds = @{} foreach ($key in $tactics.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } $buckets['Unmapped'] = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() if ($techMap.ContainsKey($trimmed)) { $tacticCode = $techMap[$trimmed] if ($buckets.ContainsKey($tacticCode)) { $buckets[$tacticCode].Add($mf.Finding) [void]$coveredIds[$tacticCode].Add($trimmed) } else { $buckets['Unmapped'].Add($mf.Finding) } } else { $buckets['Unmapped'].Add($mf.Finding) } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $tactics.Keys) { $tacticInfo = $tactics[$key] $label = if ($tacticInfo.label) { $tacticInfo.label } else { $key } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } # Add Unmapped group only if it has findings if ($buckets['Unmapped'].Count -gt 0) { $groups.Add((New-ScoringGroup -Key 'Unmapped' -Label 'Unmapped Techniques' -Total 0 -GroupFindings $buckets['Unmapped'])) } return @($groups) } # --------------------------------------------------------------------------- # 5. maturity-level (Essential Eight, CMMC) # --------------------------------------------------------------------------- function Invoke-MaturityLevel { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $levels = Get-ScoringSubObject -Framework $Framework -Key 'maturityLevels' if (-not $levels) { $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Levels' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $fwId = $Framework.frameworkId $buckets = @{} $coveredIds = @{} foreach ($key in $levels.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } if ($fwId -eq 'essential-eight') { foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() $levelKey = ($trimmed -split '-')[0] if ($buckets.ContainsKey($levelKey)) { $buckets[$levelKey].Add($mf.Finding) [void]$coveredIds[$levelKey].Add($trimmed) } } } } elseif ($fwId -eq 'cmmc') { # Cumulative: all findings count toward each level foreach ($key in $levels.Keys) { foreach ($mf in $MappedFindings) { $buckets[$key].Add($mf.Finding) $parts = $mf.ControlId -split ';' foreach ($part in $parts) { [void]$coveredIds[$key].Add($part.Trim()) } } } } else { foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() $levelKey = ($trimmed -split '-')[0] if ($buckets.ContainsKey($levelKey)) { $buckets[$levelKey].Add($mf.Finding) [void]$coveredIds[$levelKey].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $levels.Keys) { $levelInfo = $levels[$key] $label = if ($levelInfo.label) { $levelInfo.label } else { $key } $total = if ($levelInfo.practiceCount) { [int]$levelInfo.practiceCount } else { 0 } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } # --------------------------------------------------------------------------- # 6. severity-coverage (STIG) # --------------------------------------------------------------------------- function Invoke-SeverityCoverage { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $categories = Get-ScoringSubObject -Framework $Framework -Key 'categories' if (-not $categories -or $categories.Count -eq 0) { # Single "All" group $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Findings' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } # STIG V-numbers don't encode severity category, so distribute to all categories $buckets = @{} foreach ($key in $categories.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $buckets[$key].Add($mf.Finding) } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $categories.Keys) { $catInfo = $categories[$key] $label = if ($catInfo.label) { $catInfo.label } else { $key } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key])) } return @($groups) } # --------------------------------------------------------------------------- # 7. requirement-compliance (PCI DSS) # --------------------------------------------------------------------------- function Invoke-RequirementCompliance { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $requirements = Get-ScoringSubObject -Framework $Framework -Key 'requirements' if (-not $requirements) { $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Requirements' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $buckets = @{} $coveredIds = @{} foreach ($key in $requirements.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() $segments = $trimmed -split '\.' if ($segments.Count -ge 1) { $reqKey = $segments[0] if ($buckets.ContainsKey($reqKey)) { $buckets[$reqKey].Add($mf.Finding) [void]$coveredIds[$reqKey].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $requirements.Keys) { $reqInfo = $requirements[$key] $label = if ($reqInfo.label) { $reqInfo.label } else { "Requirement $key" } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } # --------------------------------------------------------------------------- # 8. criteria-coverage (SOC 2, HIPAA) # --------------------------------------------------------------------------- function Invoke-CriteriaCoverage { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $criteria = Get-ScoringSubObject -Framework $Framework -Key 'criteria' if (-not $criteria) { $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Criteria' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $fwId = $Framework.frameworkId $buckets = @{} $coveredIds = @{} foreach ($key in $criteria.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() if ($fwId -eq 'soc2') { if ($buckets.ContainsKey($trimmed)) { $buckets[$trimmed].Add($mf.Finding) [void]$coveredIds[$trimmed].Add($trimmed) } else { foreach ($cKey in $criteria.Keys) { if ($trimmed.StartsWith($cKey) -or $cKey.StartsWith($trimmed)) { $buckets[$cKey].Add($mf.Finding) [void]$coveredIds[$cKey].Add($trimmed) } } } } elseif ($fwId -eq 'hipaa') { $section = ($trimmed -split '\(')[0] if ($buckets.ContainsKey($section)) { $buckets[$section].Add($mf.Finding) [void]$coveredIds[$section].Add($trimmed) } } else { if ($buckets.ContainsKey($trimmed)) { $buckets[$trimmed].Add($mf.Finding) [void]$coveredIds[$trimmed].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $criteria.Keys) { $critInfo = $criteria[$key] $label = if ($critInfo.label) { $critInfo.label } else { $key } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } # --------------------------------------------------------------------------- # 9. policy-compliance (CISA SCuBA) # --------------------------------------------------------------------------- function Invoke-PolicyCompliance { [CmdletBinding()] param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings) $products = Get-ScoringSubObject -Framework $Framework -Key 'products' if (-not $products) { $bucket = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) } return @(New-ScoringGroup -Key 'All' -Label 'All Products' -Total ([int]$Framework.totalControls) -GroupFindings $bucket) } $buckets = @{} $coveredIds = @{} foreach ($key in $products.Keys) { $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new() $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new() } foreach ($mf in $MappedFindings) { $parts = $mf.ControlId -split ';' foreach ($part in $parts) { $trimmed = $part.Trim() $segments = $trimmed -split '\.' if ($segments.Count -ge 2) { $productKey = $segments[1] if ($buckets.ContainsKey($productKey)) { $buckets[$productKey].Add($mf.Finding) [void]$coveredIds[$productKey].Add($trimmed) } } } } $groups = [System.Collections.Generic.List[hashtable]]::new() foreach ($key in $products.Keys) { $prodInfo = $products[$key] $label = if ($prodInfo.label) { $prodInfo.label } else { $key } $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count)) } return @($groups) } |