modules/shared/FrameworkMapper.ps1
|
#requires -Version 7.0 <# .SYNOPSIS FrameworkMapper: enriches v2 FindingRow objects with CIS/NIST/PCI control mappings. .DESCRIPTION Loads tools/framework-mappings.json once and matches each finding by Source + Category (or RuleIdPrefix). Populates the finding's Frameworks[] and Controls[] fields. The mapping file is user-extensible — entries added by users merge with the defaults without modifying code. #> $script:__FrameworkMap = $null function Get-FrameworkMappings { [CmdletBinding()] param([string]$Path) if ($script:__FrameworkMap -and -not $Path) { return $script:__FrameworkMap } if (-not $Path) { $Path = Join-Path (Split-Path $PSScriptRoot -Parent) '..' 'tools' 'framework-mappings.json' } if (-not (Test-Path $Path)) { Write-Warning "framework-mappings.json not found at $Path" return $null } try { $json = Get-Content $Path -Raw | ConvertFrom-Json -Depth 20 } catch { Write-Warning "Failed to parse framework-mappings.json: $_" return $null } $script:__FrameworkMap = $json return $json } function Clear-FrameworkMappingCache { $script:__FrameworkMap = $null } function Test-MappingMatch { param( [Parameter(Mandatory)] $Finding, [Parameter(Mandatory)] $Match ) foreach ($key in $Match.PSObject.Properties.Name) { $want = $Match.$key switch ($key) { 'Category' { if ($Finding.Category -ne $want) { return $false } } 'RuleIdPrefix' { if (-not $Finding.RuleId -or -not $Finding.RuleId.ToString().StartsWith($want)) { return $false } } 'Check' { if ($Finding.Check -ne $want) { return $false } } default { return $false } } } return $true } function Add-FrameworkMapping { <# .SYNOPSIS Populates Frameworks[] and Controls[] on a v2 FindingRow based on source + category. .PARAMETER Finding A v2 FindingRow (PSCustomObject) — mutated in place. .PARAMETER FilterFramework Optional — if set (CIS/NIST/PCI), only controls for that framework are written. #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] $Finding, [string]$FilterFramework, [object]$MappingData ) process { if (-not $Finding) { return } $mappings = if ($MappingData) { $MappingData } else { Get-FrameworkMappings } if (-not $mappings) { return $Finding } $frameworks = [System.Collections.Generic.List[object]]::new() $controls = [System.Collections.Generic.List[string]]::new() foreach ($rule in $mappings.mappings) { if ($rule.source -ne $Finding.Source) { continue } if (-not (Test-MappingMatch -Finding $Finding -Match $rule.match)) { continue } foreach ($fw in $rule.controls.PSObject.Properties) { if ($FilterFramework -and $fw.Name -ne $FilterFramework) { continue } foreach ($ctl in $fw.Value) { $label = "$($fw.Name):$ctl" if ($controls -notcontains $label) { $controls.Add($label) | Out-Null } $existing = $frameworks | Where-Object { $_.framework -eq $fw.Name } if (-not $existing) { $frameworks.Add([pscustomobject]@{ framework = $fw.Name; controls = @($ctl) }) | Out-Null } else { if ($existing.controls -notcontains $ctl) { $existing.controls = @($existing.controls) + $ctl } } } } } if ($frameworks.Count -gt 0) { # Write back — mutate the object if it exposes Frameworks/Controls properties. if ($Finding.PSObject.Properties.Name -contains 'Frameworks') { $Finding.Frameworks = @($frameworks) } else { $Finding | Add-Member -NotePropertyName Frameworks -NotePropertyValue @($frameworks) -Force } if ($Finding.PSObject.Properties.Name -contains 'Controls') { $Finding.Controls = @($controls) } else { $Finding | Add-Member -NotePropertyName Controls -NotePropertyValue @($controls) -Force } } return $Finding } } function Get-FindingWafPillar { <# .SYNOPSIS Returns the wafPillar name for a finding, or $null if no mapping rule matches. .DESCRIPTION Walks the same source/match rules used by Add-FrameworkMapping and returns the first rule's wafPillar value. Pure read — does not mutate the finding. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Finding, [object]$MappingData ) if (-not $Finding) { return $null } $mappings = if ($MappingData) { $MappingData } else { Get-FrameworkMappings } if (-not $mappings) { return $null } foreach ($rule in $mappings.mappings) { if ($rule.source -ne $Finding.Source) { continue } if (-not (Test-MappingMatch -Finding $Finding -Match $rule.match)) { continue } if ($rule.PSObject.Properties['wafPillar'] -and $rule.wafPillar) { return [string]$rule.wafPillar } } return $null } function Get-WafPillarCoverage { <# .SYNOPSIS Aggregates per-WAF-pillar finding counts and a R/A/G health status. .DESCRIPTION For every pillar declared in framework-mappings.json (`wafPillars`), counts the non-compliant findings whose source/match maps to that pillar. Returns an array ordered by the pillar declaration order with: - Pillar (key, e.g. 'Security') - DisplayName (human label) - Color (hex tile color) - Total (mapped findings) - NonCompliant (mapped findings where Compliant -eq $false) - CriticalHigh (subset with Critical/High severity) - Status ('green' | 'amber' | 'red') Status thresholds: red when CriticalHigh > 0 amber when NonCompliant > 0 (no critical/high) green when NonCompliant -eq 0 #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Findings, [object]$MappingData ) $mappings = if ($MappingData) { $MappingData } else { Get-FrameworkMappings } if (-not $mappings) { return @() } $pillarOrder = @('Reliability','Security','CostOptimization','OperationalExcellence','PerformanceEfficiency') $pillarMeta = @{} if ($mappings.PSObject.Properties['wafPillars'] -and $mappings.wafPillars) { foreach ($p in $mappings.wafPillars.PSObject.Properties) { $pillarMeta[$p.Name] = $p.Value } } $totals = @{} foreach ($key in $pillarOrder) { $totals[$key] = [pscustomobject]@{ Pillar = $key DisplayName = if ($pillarMeta.ContainsKey($key) -and $pillarMeta[$key].name) { $pillarMeta[$key].name } else { $key } Color = if ($pillarMeta.ContainsKey($key) -and $pillarMeta[$key].PSObject.Properties['color']) { $pillarMeta[$key].color } else { '#666' } Total = 0 NonCompliant = 0 CriticalHigh = 0 Status = 'green' CoveragePercent = 100.0 } } foreach ($f in $Findings) { if (-not $f) { continue } $pillar = Get-FindingWafPillar -Finding $f -MappingData $mappings if (-not $pillar -or -not $totals.ContainsKey($pillar)) { continue } $row = $totals[$pillar] $row.Total++ $isNonCompliant = $f.PSObject.Properties['Compliant'] -and -not $f.Compliant if ($isNonCompliant) { $row.NonCompliant++ } $sev = if ($f.PSObject.Properties['Severity']) { [string]$f.Severity } else { '' } if ($isNonCompliant -and ($sev -match '^(?i)(critical|high)$')) { $row.CriticalHigh++ } } foreach ($key in $pillarOrder) { $row = $totals[$key] if ($row.CriticalHigh -gt 0) { $row.Status = 'red' } elseif ($row.NonCompliant -gt 0) { $row.Status = 'amber' } else { $row.Status = 'green' } if ($row.Total -gt 0) { $row.CoveragePercent = [math]::Round((($row.Total - $row.NonCompliant) / $row.Total) * 100, 1) } else { $row.CoveragePercent = 100.0 } } return @($pillarOrder | ForEach-Object { $totals[$_] }) } function Get-FrameworkCoverage { <# .SYNOPSIS Summarizes per-framework control coverage across a set of findings. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Findings, [object]$MappingData ) $mappings = if ($MappingData) { $MappingData } else { Get-FrameworkMappings } if (-not $mappings) { return @() } # Build the "universe" of controls referenced anywhere in the map (what COULD be touched). $universe = @{} foreach ($rule in $mappings.mappings) { foreach ($fw in $rule.controls.PSObject.Properties) { if (-not $universe.ContainsKey($fw.Name)) { $universe[$fw.Name] = @{} } foreach ($ctl in $fw.Value) { $universe[$fw.Name][$ctl] = $true } } } # Count controls actually touched by current findings. $touched = @{} foreach ($f in $Findings) { $frameworksProp = $f.PSObject.Properties['Frameworks'] if (-not $frameworksProp -or -not $frameworksProp.Value) { continue } foreach ($block in @($frameworksProp.Value)) { if (-not $touched.ContainsKey($block.framework)) { $touched[$block.framework] = @{} } foreach ($ctl in $block.controls) { $touched[$block.framework][$ctl] = $true } } } $out = @() foreach ($fwName in $universe.Keys | Sort-Object) { $total = $universe[$fwName].Count $hit = if ($touched.ContainsKey($fwName)) { $touched[$fwName].Count } else { 0 } $pct = if ($total -gt 0) { [math]::Round(($hit / $total) * 100, 1) } else { 0 } $status = if ($pct -ge 70) { 'green' } elseif ($pct -ge 30) { 'yellow' } else { 'red' } $meta = $mappings.frameworks.$fwName $out += [pscustomobject]@{ Framework = $fwName DisplayName = if ($meta) { $meta.name } else { $fwName } Version = if ($meta) { $meta.version } else { '' } ControlsTotal = $total ControlsHit = $hit PercentCovered = $pct Status = $status } } return $out } |