Private/Comparison.ps1
|
function Read-HLScanResult { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$InputObject ) if ($InputObject -is [string]) { $resolved = (Resolve-Path -LiteralPath ([string]$InputObject) -ErrorAction Stop).Path $json = Get-Content -LiteralPath $resolved -Raw -ErrorAction Stop $converter = Get-Command -Name ConvertFrom-Json -CommandType Cmdlet -ErrorAction Stop if ($converter.Parameters.ContainsKey('DateKind')) { return $json | ConvertFrom-Json -DateKind String } return $json | ConvertFrom-Json } return $InputObject } function Test-HLOpenStatus { [CmdletBinding()] param([Parameter(Mandatory)][string]$Status) return $Status -in @('Fail','Warning','Excepted','Error') } function ConvertTo-HLCanonicalJsonString { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) $builder = New-Object System.Text.StringBuilder [void]$builder.Append('"') foreach ($character in $Value.ToCharArray()) { $codePoint = [int][char]$character $escaped = switch ($codePoint) { 8 { '\b' } 9 { '\t' } 10 { '\n' } 12 { '\f' } 13 { '\r' } 34 { '\"' } 92 { '\\' } default { if ($codePoint -lt 32) { '\u{0:x4}' -f $codePoint } else { [string]$character } } } [void]$builder.Append($escaped) } [void]$builder.Append('"') return $builder.ToString() } function ConvertTo-HLCanonicalJson { [CmdletBinding()] param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return 'null' } # Test scalars before PSCustomObject because pipeline-decorated scalar values can # carry adapted PSObject properties while retaining their underlying CLR type. if ($Value -is [string] -or $Value -is [char]) { return (ConvertTo-HLCanonicalJsonString -Value ([string]$Value)) } if ($Value -is [bool]) { return $(if ([bool]$Value) { 'true' } else { 'false' }) } if ($Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or $Value -is [int64] -or $Value -is [uint64]) { return [Convert]::ToString($Value, [Globalization.CultureInfo]::InvariantCulture) } if ($Value -is [single] -or $Value -is [double]) { $number = [double]$Value if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) { throw 'Canonical JSON cannot represent NaN or infinity.' } return $number.ToString('R', [Globalization.CultureInfo]::InvariantCulture) } if ($Value -is [decimal]) { return ([decimal]$Value).ToString([Globalization.CultureInfo]::InvariantCulture) } if ($Value -is [datetime]) { return (ConvertTo-HLCanonicalJsonString -Value ([datetime]$Value).ToString('o', [Globalization.CultureInfo]::InvariantCulture)) } if ($Value -is [datetimeoffset]) { return (ConvertTo-HLCanonicalJsonString -Value ([datetimeoffset]$Value).ToString('o', [Globalization.CultureInfo]::InvariantCulture)) } if ($Value -is [guid]) { return (ConvertTo-HLCanonicalJsonString -Value ([guid]$Value).ToString()) } if ($Value -is [System.Collections.IDictionary]) { [string[]]$names = @($Value.Keys | ForEach-Object { [string]$_ }) [Array]::Sort($names, [StringComparer]::Ordinal) $members = foreach ($name in $names) { $encodedName = ConvertTo-HLCanonicalJsonString -Value $name '{0}:{1}' -f $encodedName, (ConvertTo-HLCanonicalJson -Value $Value[$name]) } return '{' + (@($members) -join ',') + '}' } if ($Value -is [pscustomobject]) { [string[]]$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name }) [Array]::Sort($names, [StringComparer]::Ordinal) $members = foreach ($name in $names) { $encodedName = ConvertTo-HLCanonicalJsonString -Value $name '{0}:{1}' -f $encodedName, (ConvertTo-HLCanonicalJson -Value $Value.PSObject.Properties[$name].Value) } return '{' + (@($members) -join ',') + '}' } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { $items = foreach ($item in $Value) { ConvertTo-HLCanonicalJson -Value $item } return '[' + (@($items) -join ',') + ']' } # Fallback for uncommon scalar CLR types not produced by JSON inputs. return (ConvertTo-Json -InputObject $Value -Depth 2 -Compress) } function Assert-HLComparableScanResult { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] [object]$ScanResult, [Parameter(Mandatory)] [ValidateSet('Reference', 'Difference')] [string]$InputName ) if ($null -eq $ScanResult) { throw "$InputName scan result cannot be null." } foreach ($propertyName in @('schemaVersion', 'scan', 'system', 'baseline', 'summary', 'results')) { if (-not (Test-HLProperty -InputObject $ScanResult -Name $propertyName)) { throw "$InputName scan result is missing required property '$propertyName'." } } $schemaVersion = if ($ScanResult.schemaVersion -is [string]) { [string]$ScanResult.schemaVersion } else { '' } if ($schemaVersion -cnotin @('1.0', '1.1')) { throw "$InputName scan result uses unsupported schemaVersion '$($ScanResult.schemaVersion)'. Expected '1.0' or '1.1'." } foreach ($contract in @( @{ Object = $ScanResult.scan; Name = 'scan'; Properties = @('id', 'collectedAt', 'moduleVersion') } @{ Object = $ScanResult.system; Name = 'system'; Properties = @('ComputerName') } @{ Object = $ScanResult.baseline; Name = 'baseline'; Properties = @('name', 'version') } @{ Object = $ScanResult.summary; Name = 'summary'; Properties = @('HardeningScore', 'EvidenceCoverage') } )) { if ($null -eq $contract.Object) { throw "$InputName scan result property '$($contract.Name)' cannot be null." } foreach ($propertyName in $contract.Properties) { if (-not (Test-HLProperty -InputObject $contract.Object -Name $propertyName)) { throw "$InputName scan result property '$($contract.Name)' is missing required property '$propertyName'." } } } if ($null -eq $ScanResult.results) { throw "$InputName scan result property 'results' cannot be null." } if ($ScanResult.results -is [string] -or $ScanResult.results -isnot [System.Collections.IEnumerable]) { throw "$InputName scan result property 'results' must be an array." } $seenControlIds = @{} foreach ($result in @($ScanResult.results)) { if ($null -eq $result) { throw "$InputName scan result contains a null result entry." } foreach ($propertyName in @('controlId', 'title', 'category', 'severity', 'status')) { if (-not (Test-HLProperty -InputObject $result -Name $propertyName)) { throw "$InputName scan result contains an entry missing required property '$propertyName'." } } $controlId = [string]$result.controlId if ([string]::IsNullOrWhiteSpace($controlId)) { throw "$InputName scan result contains an empty controlId." } if ($seenControlIds.ContainsKey($controlId)) { throw "$InputName scan result contains duplicate controlId '$controlId'." } $seenControlIds[$controlId] = $true if ($schemaVersion -ceq '1.1') { if (-not (Test-HLProperty -InputObject $result -Name 'probeDurationMs')) { throw "$InputName schema 1.1 scan result '$controlId' is missing required property 'probeDurationMs'." } if ($result.probeDurationMs -isnot [int] -and $result.probeDurationMs -isnot [long]) { throw "$InputName schema 1.1 scan result '$controlId' property 'probeDurationMs' must be an integer." } if ([int64]$result.probeDurationMs -lt 0) { throw "$InputName schema 1.1 scan result '$controlId' property 'probeDurationMs' cannot be negative." } } } if ($schemaVersion -ceq '1.1') { if (-not (Test-HLProperty -InputObject $ScanResult.scan -Name 'collectionDurationMs')) { throw "$InputName schema 1.1 scan result is missing required property 'scan.collectionDurationMs'." } if (($ScanResult.scan.collectionDurationMs -isnot [int] -and $ScanResult.scan.collectionDurationMs -isnot [long]) -or [int64]$ScanResult.scan.collectionDurationMs -lt 0) { throw "$InputName schema 1.1 property 'scan.collectionDurationMs' must be a non-negative integer." } if (-not (Test-HLProperty -InputObject $ScanResult -Name 'provenance') -or $null -eq $ScanResult.provenance) { throw "$InputName schema 1.1 scan result is missing required property 'provenance'." } foreach ($propertyName in @('catalogVersion', 'catalogDigest', 'baselineDigest', 'capabilities')) { if (-not (Test-HLProperty -InputObject $ScanResult.provenance -Name $propertyName)) { throw "$InputName schema 1.1 provenance is missing required property '$propertyName'." } } if ($ScanResult.provenance.catalogVersion -isnot [string] -or [string]$ScanResult.provenance.catalogVersion -cnotmatch '^\d+\.\d+\.\d+$') { throw "$InputName schema 1.1 property 'provenance.catalogVersion' must use semantic version form x.y.z." } foreach ($digestName in @('catalogDigest', 'baselineDigest')) { if ($ScanResult.provenance.$digestName -isnot [string] -or [string]$ScanResult.provenance.$digestName -cnotmatch '^[a-f0-9]{64}$') { throw "$InputName schema 1.1 property 'provenance.$digestName' must be a lowercase SHA-256 digest." } } if ((Test-HLProperty -InputObject $ScanResult.provenance -Name 'exceptionDigest') -and ($ScanResult.provenance.exceptionDigest -isnot [string] -or [string]$ScanResult.provenance.exceptionDigest -cnotmatch '^[a-f0-9]{64}$')) { throw "$InputName schema 1.1 property 'provenance.exceptionDigest' must be a lowercase SHA-256 digest." } if ($ScanResult.provenance.capabilities -is [string] -or $ScanResult.provenance.capabilities -isnot [System.Collections.IEnumerable]) { throw "$InputName schema 1.1 property 'provenance.capabilities' must be an array." } } } function Get-HLComparisonPropertyValue { [CmdletBinding()] param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory)] [string]$Name ) if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { if (-not $InputObject.Contains($Name)) { return $null } return $InputObject[$Name] } if ($null -eq $InputObject.PSObject.Properties[$Name]) { return $null } return $InputObject.PSObject.Properties[$Name].Value } function ConvertTo-HLNullableString { [CmdletBinding()] param([AllowNull()][object]$Value) if ($null -eq $Value) { return $null } return [string]$Value } function ConvertTo-HLComparisonTimestamp { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Value ) if ($Value -is [datetimeoffset]) { return ([datetimeoffset]$Value).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) } if ($Value -is [datetime]) { $timestamp = [datetime]$Value if ($timestamp.Kind -eq [DateTimeKind]::Unspecified) { $timestamp = [datetime]::SpecifyKind($timestamp, [DateTimeKind]::Utc) } else { $timestamp = $timestamp.ToUniversalTime() } return $timestamp.ToString('o', [Globalization.CultureInfo]::InvariantCulture) } return [string]$Value } function Get-HLResultStateSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Result ) return [pscustomobject][ordered]@{ Status = [string]$Result.status Severity = [string]$Result.severity Expected = $Result.expected Actual = $Result.actual Evidence = $Result.evidence Exception = $Result.exception } } function Get-HLChangedField { [CmdletBinding()] param( [AllowNull()] [object]$Before, [AllowNull()] [object]$After ) if ($null -eq $Before -or $null -eq $After) { return @('Presence') } $changed = New-Object System.Collections.Generic.List[string] foreach ($fieldName in @('Status', 'Severity', 'Expected', 'Actual', 'Evidence', 'Exception')) { $beforeValue = $Before.PSObject.Properties[$fieldName].Value $afterValue = $After.PSObject.Properties[$fieldName].Value if ((ConvertTo-HLCanonicalJson -Value $beforeValue) -cne (ConvertTo-HLCanonicalJson -Value $afterValue)) { $changed.Add($fieldName) } } return $changed.ToArray() } function Get-HLResultStateFingerprint { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Result ) # Keep collection timestamps and explanatory prose out of drift decisions. # The fingerprint represents effective assessment state, evidence, and governance. return (ConvertTo-HLCanonicalJson -Value (Get-HLResultStateSnapshot -Result $Result)) } function Compare-HLScanResult { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Reference, [Parameter(Mandatory)] [object]$Difference ) $referenceById = @{} foreach ($result in @($Reference.results)) { $referenceById[[string]$result.controlId] = $result } $differenceById = @{} foreach ($result in @($Difference.results)) { $differenceById[[string]$result.controlId] = $result } $allIds = @($referenceById.Keys + $differenceById.Keys | Sort-Object -Unique) $changes = New-Object System.Collections.Generic.List[object] foreach ($id in $allIds) { $before = if ($referenceById.ContainsKey($id)) { $referenceById[$id] } else { $null } $after = if ($differenceById.ContainsKey($id)) { $differenceById[$id] } else { $null } $beforeState = if ($null -ne $before) { Get-HLResultStateSnapshot -Result $before } else { $null } $afterState = if ($null -ne $after) { Get-HLResultStateSnapshot -Result $after } else { $null } $changedFields = @(Get-HLChangedField -Before $beforeState -After $afterState) $changeType = 'Unchanged' if ($null -eq $before) { $changeType = 'AddedControl' } elseif ($null -eq $after) { $changeType = 'RemovedControl' } else { $beforeOpen = Test-HLOpenStatus -Status ([string]$before.status) $afterOpen = Test-HLOpenStatus -Status ([string]$after.status) if (-not $beforeOpen -and $afterOpen) { $changeType = 'NewFinding' } elseif ($beforeOpen -and -not $afterOpen) { $changeType = 'Resolved' } elseif ($changedFields.Count -gt 0) { $changeType = 'Changed' } } $source = if ($null -ne $after) { $after } else { $before } $changes.Add([pscustomobject][ordered]@{ ControlId = $id Title = [string]$source.title Severity = [string]$source.severity Category = [string]$source.category ChangeType = $changeType ChangedFields = @($changedFields) Before = $beforeState After = $afterState BeforeStatus = if ($null -ne $beforeState) { [string]$beforeState.Status } else { $null } AfterStatus = if ($null -ne $afterState) { [string]$afterState.Status } else { $null } BeforeActual = if ($null -ne $beforeState) { $beforeState.Actual } else { $null } AfterActual = if ($null -ne $afterState) { $afterState.Actual } else { $null } }) } $referenceScore = if ($null -ne $Reference.summary.HardeningScore) { [double]$Reference.summary.HardeningScore } else { $null } $differenceScore = if ($null -ne $Difference.summary.HardeningScore) { [double]$Difference.summary.HardeningScore } else { $null } $scoreDelta = if ($null -ne $referenceScore -and $null -ne $differenceScore) { [math]::Round($differenceScore - $referenceScore, 1) } else { $null } $referenceCoverage = if ($null -ne $Reference.summary.EvidenceCoverage) { [double]$Reference.summary.EvidenceCoverage } else { $null } $differenceCoverage = if ($null -ne $Difference.summary.EvidenceCoverage) { [double]$Difference.summary.EvidenceCoverage } else { $null } $coverageDelta = if ($null -ne $referenceCoverage -and $null -ne $differenceCoverage) { [math]::Round($differenceCoverage - $referenceCoverage, 1) } else { $null } $referenceProvenance = Get-HLComparisonPropertyValue -InputObject $Reference -Name 'provenance' $differenceProvenance = Get-HLComparisonPropertyValue -InputObject $Difference -Name 'provenance' $referenceBaseline = [pscustomobject][ordered]@{ Name = [string]$Reference.baseline.name Version = [string]$Reference.baseline.version Digest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $referenceProvenance -Name 'baselineDigest') } $differenceBaseline = [pscustomobject][ordered]@{ Name = [string]$Difference.baseline.name Version = [string]$Difference.baseline.version Digest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $differenceProvenance -Name 'baselineDigest') } $referenceCatalog = [pscustomobject][ordered]@{ Version = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $referenceProvenance -Name 'catalogVersion') Digest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $referenceProvenance -Name 'catalogDigest') } $differenceCatalog = [pscustomobject][ordered]@{ Version = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $differenceProvenance -Name 'catalogVersion') Digest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $differenceProvenance -Name 'catalogDigest') } $referenceCollectionDuration = Get-HLComparisonPropertyValue -InputObject $Reference.scan -Name 'collectionDurationMs' $differenceCollectionDuration = Get-HLComparisonPropertyValue -InputObject $Difference.scan -Name 'collectionDurationMs' $referenceCapabilityValue = Get-HLComparisonPropertyValue -InputObject $referenceProvenance -Name 'capabilities' $differenceCapabilityValue = Get-HLComparisonPropertyValue -InputObject $differenceProvenance -Name 'capabilities' $referenceCapabilities = if ($null -ne $referenceCapabilityValue) { @($referenceCapabilityValue) } else { @() } $differenceCapabilities = if ($null -ne $differenceCapabilityValue) { @($differenceCapabilityValue) } else { @() } return [pscustomobject][ordered]@{ '$schema' = 'https://raw.githubusercontent.com/xGreeny/hardening-lens/v1.2.1/src/HardeningLens/Schema/comparison.schema.json' schemaVersion = '1.1' comparedAt = (Get-Date).ToUniversalTime().ToString('o') computerName = [string]$Difference.system.ComputerName baseline = [string]$Difference.baseline.name baselineContext = [pscustomobject][ordered]@{ Reference = $referenceBaseline Difference = $differenceBaseline Changed = [bool]((ConvertTo-HLCanonicalJson -Value $referenceBaseline) -cne (ConvertTo-HLCanonicalJson -Value $differenceBaseline)) } catalogContext = [pscustomobject][ordered]@{ Reference = $referenceCatalog Difference = $differenceCatalog Changed = [bool]((ConvertTo-HLCanonicalJson -Value $referenceCatalog) -cne (ConvertTo-HLCanonicalJson -Value $differenceCatalog)) } referenceScan = [pscustomobject][ordered]@{ Id = [string]$Reference.scan.id CollectedAt = ConvertTo-HLComparisonTimestamp -Value $Reference.scan.collectedAt ResultSchemaVersion = [string]$Reference.schemaVersion ModuleVersion = [string]$Reference.scan.moduleVersion Score = $referenceScore EvidenceCoverage = $referenceCoverage CollectionDurationMs = if ($null -ne $referenceCollectionDuration) { [int64]$referenceCollectionDuration } else { $null } ExceptionDigest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $referenceProvenance -Name 'exceptionDigest') Capabilities = @($referenceCapabilities) } differenceScan = [pscustomobject][ordered]@{ Id = [string]$Difference.scan.id CollectedAt = ConvertTo-HLComparisonTimestamp -Value $Difference.scan.collectedAt ResultSchemaVersion = [string]$Difference.schemaVersion ModuleVersion = [string]$Difference.scan.moduleVersion Score = $differenceScore EvidenceCoverage = $differenceCoverage CollectionDurationMs = if ($null -ne $differenceCollectionDuration) { [int64]$differenceCollectionDuration } else { $null } ExceptionDigest = ConvertTo-HLNullableString -Value (Get-HLComparisonPropertyValue -InputObject $differenceProvenance -Name 'exceptionDigest') Capabilities = @($differenceCapabilities) } summary = [pscustomobject][ordered]@{ ScoreDelta = $scoreDelta CoverageDelta = $coverageDelta NewFindings = @($changes | Where-Object ChangeType -eq 'NewFinding').Count Resolved = @($changes | Where-Object ChangeType -eq 'Resolved').Count Changed = @($changes | Where-Object ChangeType -eq 'Changed').Count AddedControls = @($changes | Where-Object ChangeType -eq 'AddedControl').Count RemovedControls = @($changes | Where-Object ChangeType -eq 'RemovedControl').Count Unchanged = @($changes | Where-Object ChangeType -eq 'Unchanged').Count } changes = $changes.ToArray() } } function ConvertTo-HLComparisonMarkdown { [CmdletBinding()] param([Parameter(Mandatory)][object]$Comparison) $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# Hardening Lens drift report') $lines.Add('') $lines.Add(('* **Host:** `{0}`' -f $Comparison.computerName)) $lines.Add(('* **Baseline:** `{0}`' -f $Comparison.baseline)) $lines.Add(('* **Reference:** `{0}` ({1})' -f $Comparison.referenceScan.Id, $Comparison.referenceScan.CollectedAt)) $lines.Add(('* **Current:** `{0}` ({1})' -f $Comparison.differenceScan.Id, $Comparison.differenceScan.CollectedAt)) $referenceBaselineDigest = if ($null -ne $Comparison.baselineContext.Reference.Digest) { [string]$Comparison.baselineContext.Reference.Digest } else { 'legacy/unavailable' } $differenceBaselineDigest = if ($null -ne $Comparison.baselineContext.Difference.Digest) { [string]$Comparison.baselineContext.Difference.Digest } else { 'legacy/unavailable' } $referenceCatalogVersion = if ($null -ne $Comparison.catalogContext.Reference.Version) { [string]$Comparison.catalogContext.Reference.Version } else { 'legacy/unavailable' } $differenceCatalogVersion = if ($null -ne $Comparison.catalogContext.Difference.Version) { [string]$Comparison.catalogContext.Difference.Version } else { 'legacy/unavailable' } $referenceCatalogDigest = if ($null -ne $Comparison.catalogContext.Reference.Digest) { [string]$Comparison.catalogContext.Reference.Digest } else { 'legacy/unavailable' } $differenceCatalogDigest = if ($null -ne $Comparison.catalogContext.Difference.Digest) { [string]$Comparison.catalogContext.Difference.Digest } else { 'legacy/unavailable' } $lines.Add(('* **Baseline provenance:** `{0}` `{1}` (`{2}`) -> `{3}` `{4}` (`{5}`)' -f $Comparison.baselineContext.Reference.Name, $Comparison.baselineContext.Reference.Version, $referenceBaselineDigest, $Comparison.baselineContext.Difference.Name, $Comparison.baselineContext.Difference.Version, $differenceBaselineDigest)) $lines.Add(('* **Catalog provenance:** `{0}` (`{1}`) -> `{2}` (`{3}`)' -f $referenceCatalogVersion, $referenceCatalogDigest, $differenceCatalogVersion, $differenceCatalogDigest)) $deltaText = if ($null -eq $Comparison.summary.ScoreDelta) { 'n/a' } else { '{0:+0.0;-0.0;0.0} points' -f [double]$Comparison.summary.ScoreDelta } $coverageDeltaText = if ($null -eq $Comparison.summary.CoverageDelta) { 'n/a' } else { '{0:+0.0;-0.0;0.0} points' -f [double]$Comparison.summary.CoverageDelta } $lines.Add(('* **Score delta:** {0}' -f $deltaText)) $lines.Add(('* **Evidence coverage delta:** {0}' -f $coverageDeltaText)) $lines.Add('') $lines.Add('| New findings | Resolved | Changed | Added controls | Removed controls |') $lines.Add('|---:|---:|---:|---:|---:|') $lines.Add(('| {0} | {1} | {2} | {3} | {4} |' -f $Comparison.summary.NewFindings,$Comparison.summary.Resolved,$Comparison.summary.Changed,$Comparison.summary.AddedControls,$Comparison.summary.RemovedControls)) $lines.Add('') $relevant = @($Comparison.changes | Where-Object { $_.ChangeType -ne 'Unchanged' } | Sort-Object @{Expression={Get-HLSeverityRank -Severity ([string]$_.Severity)};Descending=$true}, ControlId) if ($relevant.Count -eq 0) { $lines.Add('No control state or evidence changes were detected.') } else { $lines.Add('## Changes') $lines.Add('') $lines.Add('| Type | Control | Severity | Changed fields | Before | After |') $lines.Add('|---|---|---|---|---|---|') foreach ($change in $relevant) { $title = ([string]$change.Title).Replace('|','\|') $changedFieldText = @($change.ChangedFields) -join ', ' $lines.Add(('| {0} | `{1}` - {2} | {3} | {4} | {5} | {6} |' -f $change.ChangeType,$change.ControlId,$title,$change.Severity,$changedFieldText,$change.BeforeStatus,$change.AfterStatus)) } } $lines.Add('') $lines.Add('Generated by Hardening Lens. Review operational context before treating drift as a security incident or approved change.') return ($lines.ToArray() -join [Environment]::NewLine) } |