Classes/ComplianceResult.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS ComplianceResult class — the core data model for all check outcomes. .DESCRIPTION Strongly-typed result class that every check returns. Supports severity, evidence, remediation guidance, and conversion to SARIF/JUnit/JSON. #> # Enums already loaded by module before this file is sourced # (Severity.psm1 is loaded first in BSI.AzCompliance.psm1) class ComplianceResult { [string] $ControlId [string] $ControlTitle [string] $Category [BsiCheckMode] $Mode [BsiCheckStatus] $Status [BsiSeverity] $Severity [BsiBaseline] $Baseline [string] $Description [string] $Details [string] $Evidence [string] $Remediation [string] $RemediationUrl [string] $BsiReference [string] $CheckFunction [string] $SourceFile [int] $LineNumber [datetime] $Timestamp [hashtable] $Metadata ComplianceResult() { $this.Severity = [BsiSeverity]::Medium $this.Baseline = [BsiBaseline]::B $this.Status = [BsiCheckStatus]::Fail $this.Mode = [BsiCheckMode]::Remote $this.Timestamp = [datetime]::UtcNow $this.Metadata = @{} $this.BsiReference = '' $this.RemediationUrl = '' $this.CheckFunction = '' $this.SourceFile = '' $this.LineNumber = 0 } ComplianceResult( [string]$controlId, [string]$title, [string]$category, [BsiCheckMode]$mode, [BsiCheckStatus]$status, [BsiSeverity]$severity, [string]$details ) { $this.ControlId = $controlId $this.ControlTitle = $title $this.Category = $category $this.Mode = $mode $this.Status = $status $this.Severity = $severity $this.Details = $details $this.Baseline = [BsiBaseline]::B $this.Timestamp = [datetime]::UtcNow $this.Metadata = @{} } [bool] IsPass() { return $this.Status -eq [BsiCheckStatus]::Pass } [string] ToSarifResult() { $level = switch ($this.Status) { ([BsiCheckStatus]::Pass) { 'none' } ([BsiCheckStatus]::Skip) { 'none' } default { if ($this.Severity -in @([BsiSeverity]::Critical, [BsiSeverity]::High)) { 'error' } else { 'warning' } } } $msg = $this.Details if ($this.Evidence) { $msg += " | Evidence: $($this.Evidence)" } $props = @( ('"controlId": "' + $this.ControlId + '"'), ('"category": "' + $this.Category + '"'), ('"severity": "' + $this.Severity.ToString() + '"'), ('"baseline": "' + $this.Baseline.ToString() + '"'), ('"bsiReference": "' + ($this.BsiReference -replace '"', '\"') + '"') ) $locBlock = '' if ($this.SourceFile) { $locBlock = @" , "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "$($this.SourceFile)" }, "region": { "startLine": $($this.LineNumber) } } }] "@ } return @" { "ruleId": "$($this.ControlId)", "level": "$level", "message": { "text": "$($msg -replace '"', '\"')" }, "properties": { $($props -join ', ') }$locBlock } "@ } [string] ToJUnitElement() { $escaped = [System.Security.SecurityElement]::Escape($this.Details) $name = "$($this.ControlId) - $($this.ControlTitle)" $escapedName = [System.Security.SecurityElement]::Escape($name) if ($this.IsPass()) { return " <testcase name=`"$escapedName`" classname=`"$($this.Category)`" time=`"0`" />" } else { $msg = if ($this.Evidence) { "$escaped | $($this.Evidence)" } else { $escaped } $escapedMsg = [System.Security.SecurityElement]::Escape($msg) return " <testcase name=`"$escapedName`" classname=`"$($this.Category)`" time=`"0`"> <failure message=`"BSI control $($this.ControlId) failed`">$escapedMsg</failure> </testcase>" } } } # --- Global result store --- if (-not (Get-Variable -Name 'script:BsiResults' -Scope 'Script' -ErrorAction SilentlyContinue)) { Set-Variable -Name 'script:BsiResults' -Scope 'Script' -Value ([System.Collections.ArrayList]::new()) Set-Variable -Name 'script:BsiPassed' -Scope 'Script' -Value 0 Set-Variable -Name 'script:BsiFailed' -Scope 'Script' -Value 0 Set-Variable -Name 'script:BsiSkipped' -Scope 'Script' -Value 0 } function Reset-BsiResults { $script:BsiResults = [System.Collections.ArrayList]::new() $script:BsiPassed = 0 $script:BsiFailed = 0 $script:BsiSkipped = 0 } function Add-BsiResult { <# .SYNOPSIS Records a single compliance check result. #> param( [Parameter(Mandatory)][string]$ControlId, [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Category, [Parameter(Mandatory)][BsiCheckMode]$Mode, [Parameter(Mandatory)][BsiCheckStatus]$Status, [Parameter(Mandatory)][BsiSeverity]$Severity = [BsiSeverity]::Medium, [string]$Details = '', [string]$Evidence = '', [string]$Remediation = '', [string]$RemediationUrl = '', [string]$BsiReference = '', [string]$CheckFunction = '', [string]$SourceFile = '', [int]$LineNumber = 0 ) $result = [ComplianceResult]::new($ControlId, $Title, $Category, $Mode, $Status, $Severity, $Details) $result.Evidence = $Evidence $result.Remediation = $Remediation $result.RemediationUrl = $RemediationUrl $result.BsiReference = $BsiReference $result.CheckFunction = $CheckFunction $result.SourceFile = $SourceFile $result.LineNumber = $LineNumber $null = $script:BsiResults.Add($result) switch ($Status) { ([BsiCheckStatus]::Pass) { $script:BsiPassed++ } ([BsiCheckStatus]::Fail) { $script:BsiFailed++ } ([BsiCheckStatus]::Skip) { $script:BsiSkipped++ } ([BsiCheckStatus]::Error) { $script:BsiFailed++ } } $statusStr = $Status.ToString().ToUpper() $color = switch ($Status) { ([BsiCheckStatus]::Pass) { 'Green' } ([BsiCheckStatus]::Fail) { 'Red' } ([BsiCheckStatus]::Skip) { 'Yellow' } ([BsiCheckStatus]::Error) { 'Magenta' } } Write-Host ("[{0}] [{1}] [{2}] {3} > {4}" -f $statusStr, $Severity.ToString().ToUpper(), $Mode, $Category, $Title) -ForegroundColor $color if ($Details) { Write-Host (" {0}" -f $Details) -ForegroundColor DarkGray } if ($Evidence) { Write-Host (" Evidence: {0}" -f $Evidence) -ForegroundColor DarkCyan } if ($Remediation) { Write-Host (" -> {0}" -f $Remediation) -ForegroundColor Yellow } } function Add-BsiResultObject { <# .SYNOPSIS Records a pre-built ComplianceResult object and prints it. .DESCRIPTION Convenience wrapper: takes a [ComplianceResult] object directly, appends it to the global store, and prints the formatted line. #> param( [Parameter(Mandatory)][ComplianceResult]$Result ) $null = $script:BsiResults.Add($Result) switch ($Result.Status) { ([BsiCheckStatus]::Pass) { $script:BsiPassed++ } ([BsiCheckStatus]::Fail) { $script:BsiFailed++ } ([BsiCheckStatus]::Skip) { $script:BsiSkipped++ } ([BsiCheckStatus]::Error) { $script:BsiFailed++ } } $statusStr = $Result.Status.ToString().ToUpper() $color = switch ($Result.Status) { ([BsiCheckStatus]::Pass) { 'Green' } ([BsiCheckStatus]::Fail) { 'Red' } ([BsiCheckStatus]::Skip) { 'Yellow' } ([BsiCheckStatus]::Error) { 'Magenta' } } Write-Host ("[{0}] [{1}] [{2}] {3} > {4}" -f $statusStr, $Result.Severity.ToString().ToUpper(), $Result.Mode, $Result.Category, $Result.ControlTitle) -ForegroundColor $color if ($Result.Details) { Write-Host (" {0}" -f $Result.Details) -ForegroundColor DarkGray } if ($Result.Evidence) { Write-Host (" Evidence: {0}" -f $Result.Evidence) -ForegroundColor DarkCyan } if ($Result.Remediation) { Write-Host (" -> {0}" -f $Result.Remediation) -ForegroundColor Yellow } } function New-BsiComplianceResult { <# .SYNOPSIS Creates a new ComplianceResult object. Exported helper for external callers that cannot use `using module` (e.g., Pester tests). #> param( [string]$ControlId = '', [string]$Title = '', [string]$Category = '', [BsiCheckMode]$Mode = [BsiCheckMode]::Remote, [BsiCheckStatus]$Status = [BsiCheckStatus]::Fail, [BsiSeverity]$Severity = [BsiSeverity]::Medium, [string]$Details = '' ) return [ComplianceResult]::new($ControlId, $Title, $Category, $Mode, $Status, $Severity, $Details) } function Get-BsiSummary { $total = $script:BsiPassed + $script:BsiFailed + $script:BsiSkipped return [PSCustomObject]@{ Total = $total Passed = $script:BsiPassed Failed = $script:BsiFailed Skipped = $script:BsiSkipped Results = $script:BsiResults Score = if ($total -gt 0) { [math]::Round(($script:BsiPassed / $total) * 100, 1) } else { 0 } } } |