Private/TrendReport.ps1
|
function Get-HLTrendSeries { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$ScanResults ) if (@($ScanResults).Count -lt 2) { throw 'A trend needs at least two scan results of the same computer.' } foreach ($scanResult in $ScanResults) { Assert-HLComparableScanResult -ScanResult $scanResult -InputName Reference } $computerNames = @($ScanResults | ForEach-Object { ([string]$_.system.ComputerName).ToUpperInvariant() } | Sort-Object -Unique) if ($computerNames.Count -gt 1) { throw ("A trend covers one computer; found results for: {0}." -f (@($computerNames) -join ', ')) } $baselineNames = @($ScanResults | ForEach-Object { [string]$_.baseline.name } | Sort-Object -Unique) if ($baselineNames.Count -gt 1) { throw ("A trend covers one baseline; found: {0}." -f (@($baselineNames) -join ', ')) } $ordered = @($ScanResults | Sort-Object -Property @{ Expression = { [datetime]$_.scan.collectedAt } }) $scanIds = @($ordered | ForEach-Object { [string]$_.scan.id } | Sort-Object -Unique) if ($scanIds.Count -ne $ordered.Count) { throw 'The trend input contains duplicate scan IDs.' } $rows = New-Object System.Collections.Generic.List[object] $previousScore = $null foreach ($scanResult in $ordered) { $score = $scanResult.summary.HardeningScore $delta = if ($null -ne $previousScore -and $null -ne $score) { [math]::Round([double]$score - [double]$previousScore, 1) } else { $null } $rows.Add([pscustomobject][ordered]@{ CollectedAt = [string]$scanResult.scan.collectedAt ScanId = [string]$scanResult.scan.id ModuleVersion = [string]$scanResult.scan.moduleVersion BaselineVersion = [string]$scanResult.baseline.version HardeningScore = $score ScoreDelta = $delta EvidenceCoverage = $scanResult.summary.EvidenceCoverage Pass = [int]$scanResult.summary.Pass Fail = [int]$scanResult.summary.Fail Warning = [int]$scanResult.summary.Warning Excepted = [int]$scanResult.summary.Excepted Unknown = [int]$scanResult.summary.Unknown Error = [int]$scanResult.summary.Error }) if ($null -ne $score) { $previousScore = $score } } return [pscustomobject][ordered]@{ ComputerName = [string]$ordered[-1].system.ComputerName BaselineName = [string]$ordered[-1].baseline.name Rows = $rows.ToArray() } } function ConvertTo-HLTrendMarkdown { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Series ) $lines = New-Object System.Collections.Generic.List[string] $lines.Add('# Hardening Lens trend report') $lines.Add('') $lines.Add(('* **Host:** `{0}`' -f $Series.ComputerName)) $lines.Add(('* **Baseline:** `{0}`' -f $Series.BaselineName)) $lines.Add(('* **Scans:** {0}' -f @($Series.Rows).Count)) $lines.Add('') $lines.Add('| Collected | Score | Delta | Coverage | Pass | Fail | Warning | Excepted | Unknown | Error | Module | Baseline |') $lines.Add('|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|') foreach ($row in @($Series.Rows)) { $score = if ($null -ne $row.HardeningScore) { '{0:N1}%' -f [double]$row.HardeningScore } else { 'n/a' } $delta = if ($null -ne $row.ScoreDelta) { '{0:+0.0;-0.0;0.0}' -f [double]$row.ScoreDelta } else { '' } $coverage = if ($null -ne $row.EvidenceCoverage) { '{0:N1}%' -f [double]$row.EvidenceCoverage } else { 'n/a' } $lines.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} | {9} | {10} | {11} |' -f $row.CollectedAt, $score, $delta, $coverage, $row.Pass, $row.Fail, $row.Warning, $row.Excepted, $row.Unknown, $row.Error, $row.ModuleVersion, $row.BaselineVersion)) } $lines.Add('') $lines.Add('Generated by Hardening Lens. Score changes reflect configuration, baseline, and catalog evolution; review the drift reports for field-level causes.') return ($lines.ToArray() -join [Environment]::NewLine) } function ConvertTo-HLTrendHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Series ) $builder = New-Object Text.StringBuilder [void]$builder.AppendLine('<!doctype html>') [void]$builder.AppendLine('<html lang="en"><head><meta charset="utf-8">') [void]$builder.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">') [void]$builder.AppendLine('<meta http-equiv="Content-Security-Policy" content="default-src ''none''; style-src ''unsafe-inline''; img-src data:; base-uri ''none''; form-action ''none''">') [void]$builder.AppendLine('<title>Hardening Lens Trend Report</title>') [void]$builder.AppendLine(@' <style> :root{--bg:#07110d;--panel:#0c1b14;--line:#244434;--text:#e7f3ec;--muted:#9eb7aa;--accent:#55e69d;--fail:#ff6b6b} *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -10%,#123c28 0,transparent 32%),var(--bg);color:var(--text);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif} code,pre,.mono{font-family:"Cascadia Code","SFMono-Regular",Consolas,monospace}.wrap{max-width:1200px;margin:0 auto;padding:32px}.hero{border:1px solid var(--line);background:linear-gradient(135deg,rgba(85,230,157,.09),rgba(12,27,20,.96));border-radius:18px;padding:28px}.eyebrow{color:var(--accent);letter-spacing:.16em;text-transform:uppercase;font:700 12px/1.4 monospace}.hero h1{font-size:clamp(28px,5vw,46px);margin:7px 0 8px}.subtitle{color:var(--muted);margin:0}.section{margin-top:28px}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:var(--panel)}table{width:100%;border-collapse:collapse;min-width:900px}th,td{padding:11px 13px;text-align:left;border-bottom:1px solid rgba(36,68,52,.72)}th{background:#0e2017;color:#bcd1c5;font-size:12px;text-transform:uppercase;letter-spacing:.06em}.bar{background:#06100b;border:1px solid var(--line);border-radius:6px;height:10px;min-width:140px;overflow:hidden}.bar span{display:block;height:100%;background:var(--accent)}.delta-up{color:var(--accent);font-weight:700}.delta-down{color:var(--fail);font-weight:700}.small{font-size:12px;color:var(--muted)}.method{color:var(--muted)}.footer{color:var(--muted);text-align:center;margin:28px 0 8px;font-size:12px} @media print{body{background:#fff;color:#111}.hero,.table-wrap{background:#fff;border-color:#bbb}th{background:#eee;color:#111}} </style> '@) [void]$builder.AppendLine('</head><body><main class="wrap">') [void]$builder.AppendLine('<section class="hero">') [void]$builder.AppendLine('<div class="eyebrow">xGreeny / Windows Security Engineering</div>') [void]$builder.AppendLine('<h1>Hardening Lens Trend</h1>') [void]$builder.AppendLine(('<p class="subtitle">Posture over {0} scans of <strong>{1}</strong> against <strong>{2}</strong>.</p>' -f @($Series.Rows).Count,(ConvertTo-HLHtmlEncoded $Series.ComputerName),(ConvertTo-HLHtmlEncoded $Series.BaselineName))) [void]$builder.AppendLine('</section>') [void]$builder.AppendLine('<section class="section"><div class="table-wrap"><table><thead><tr><th>Collected</th><th>Score</th><th></th><th>Delta</th><th>Coverage</th><th>Pass</th><th>Fail</th><th>Warning</th><th>Excepted</th><th>Unknown</th><th>Error</th><th>Module / baseline</th></tr></thead><tbody>') foreach ($row in @($Series.Rows)) { $scoreValue = if ($null -ne $row.HardeningScore) { [double]$row.HardeningScore } else { $null } $scoreText = if ($null -ne $scoreValue) { '{0:N1}%' -f $scoreValue } else { 'n/a' } $barWidth = if ($null -ne $scoreValue) { [int][math]::Max(0, [math]::Min(100, $scoreValue)) } else { 0 } $deltaText = if ($null -ne $row.ScoreDelta) { '{0:+0.0;-0.0;0.0}' -f [double]$row.ScoreDelta } else { '' } $deltaClass = if ($null -ne $row.ScoreDelta -and [double]$row.ScoreDelta -gt 0) { 'delta-up' } elseif ($null -ne $row.ScoreDelta -and [double]$row.ScoreDelta -lt 0) { 'delta-down' } else { '' } $collected = try { ([datetime]$row.CollectedAt).ToString('yyyy-MM-dd HH:mm') } catch { [string]$row.CollectedAt } [void]$builder.AppendLine(('<tr><td class="mono">{0}</td><td class="mono">{1}</td><td><div class="bar"><span style="width:{2}%"></span></div></td><td class="mono {3}">{4}</td><td class="mono">{5}</td><td>{6}</td><td>{7}</td><td>{8}</td><td>{9}</td><td>{10}</td><td>{11}</td><td class="small">{12} / {13}</td></tr>' -f (ConvertTo-HLHtmlEncoded $collected),(ConvertTo-HLHtmlEncoded $scoreText),$barWidth,$deltaClass,(ConvertTo-HLHtmlEncoded $deltaText),(ConvertTo-HLHtmlEncoded $(if ($null -ne $row.EvidenceCoverage) { '{0:N1}%' -f [double]$row.EvidenceCoverage } else { 'n/a' })),(ConvertTo-HLHtmlEncoded $row.Pass),(ConvertTo-HLHtmlEncoded $row.Fail),(ConvertTo-HLHtmlEncoded $row.Warning),(ConvertTo-HLHtmlEncoded $row.Excepted),(ConvertTo-HLHtmlEncoded $row.Unknown),(ConvertTo-HLHtmlEncoded $row.Error),(ConvertTo-HLHtmlEncoded $row.ModuleVersion),(ConvertTo-HLHtmlEncoded $row.BaselineVersion))) } [void]$builder.AppendLine('</tbody></table></div></section>') [void]$builder.AppendLine('<section class="section"><p class="method">Score changes reflect configuration, baseline, and catalog evolution together; use the drift reports for field-level causes. Hardening Lens is a read-only technical posture assessment and does not certify compliance with any framework.</p></section>') [void]$builder.AppendLine('<div class="footer mono">hardening-lens / xGreeny | trend over result schema 1.0/1.1</div>') [void]$builder.AppendLine('</main></body></html>') return $builder.ToString() } |