Private/Write-CwHtmlReport.ps1
|
function Write-CwHtmlReport { <# .SYNOPSIS Renders one or more snapshots into a self-contained HTML report string. .DESCRIPTION No CDN, no external assets: inline CSS, vanilla JS (table sort/filter) and hand-built SVG charts. Sections: summary tiles, 90-day expiry timeline, per-server certificate tables (grouped by tenant), CRL/CA health panel and an optional trend section against a baseline snapshot. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object[]]$Snapshot, [object]$Trend, [string]$Title = 'cert-watchtower report' ) function Encode([object]$Value) { [System.Net.WebUtility]::HtmlEncode([string]$Value) } $sevColor = @{ OK = '#2e7d32'; WARNING = '#ed6c02'; CRITICAL = '#c62828'; UNKNOWN = '#616161' } $now = Get-Date $allCerts = foreach ($snap in $Snapshot) { foreach ($cert in @($snap.Certificates)) { [pscustomobject]@{ Tenant = if ($snap.PSObject.Properties['Tenant'] -and $snap.Tenant) { $snap.Tenant } else { 'default' } Host = $snap.Host Subject = $cert.Subject Role = $cert.Role NotAfter = [datetime]$cert.NotAfter DaysRemaining = [double]$cert.DaysRemaining Severity = $cert.Severity Thumbprint = $cert.Thumbprint } } } $allCerts = @($allCerts) $critCount = @($allCerts | Where-Object Severity -eq 'CRITICAL').Count $warnCount = @($allCerts | Where-Object Severity -eq 'WARNING').Count $next = $allCerts | Sort-Object DaysRemaining | Select-Object -First 1 $allCrls = @($Snapshot | ForEach-Object { @($_.Crls) } | Where-Object { $_ }) $crlUnhealthy = @($allCrls | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count $crlLight = if ($allCrls.Count -eq 0) { @('UNKNOWN', 'no CRL data') } elseif ($crlUnhealthy -eq 0) { @('OK', "$($allCrls.Count) CRLs fresh") } else { @('CRITICAL', "$crlUnhealthy of $($allCrls.Count) CRLs unhealthy") } $allCas = @($Snapshot | ForEach-Object { $_.CaHealth } | Where-Object { $_ -and $_.IsCa }) $caUnhealthy = @($allCas | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count $caLight = if ($allCas.Count -eq 0) { @('UNKNOWN', 'no CA in scope') } elseif ($caUnhealthy -eq 0) { @('OK', "$($allCas.Count) CA(s) healthy") } else { @('CRITICAL', "$caUnhealthy of $($allCas.Count) CA(s) unhealthy") } $score = if ($critCount -gt 0 -or $crlLight[0] -eq 'CRITICAL' -or $caLight[0] -eq 'CRITICAL') { 'CRITICAL' } elseif ($warnCount -gt 0) { 'WARNING' } else { 'OK' } # --- SVG timeline: expiries in the next 90 days, one bar per week -------- $weeks = for ($w = 0; $w -lt 13; $w++) { $lo = $w * 7; $hi = $lo + 7 $bucket = @($allCerts | Where-Object { $_.DaysRemaining -ge $lo -and $_.DaysRemaining -lt $hi }) [pscustomobject]@{ Label = "W$($w + 1)" Crit = @($bucket | Where-Object Severity -eq 'CRITICAL').Count Warn = @($bucket | Where-Object Severity -eq 'WARNING').Count Ok = @($bucket | Where-Object Severity -eq 'OK').Count } } $maxBar = (@($weeks | ForEach-Object { $_.Crit + $_.Warn + $_.Ok }) | Measure-Object -Maximum).Maximum if (-not $maxBar) { $maxBar = 1 } $svgBars = New-Object System.Text.StringBuilder $chartH = 160; $barW = 44; $gap = 10 for ($w = 0; $w -lt 13; $w++) { $x = 30 + $w * ($barW + $gap) $y = $chartH foreach ($part in @(@('Crit', 'CRITICAL'), @('Warn', 'WARNING'), @('Ok', 'OK'))) { $count = $weeks[$w].($part[0]) if ($count -gt 0) { $h = [math]::Max(3, [math]::Round($count / $maxBar * ($chartH - 20))) $y -= $h [void]$svgBars.Append("<rect x='$x' y='$y' width='$barW' height='$h' fill='$($sevColor[$part[1]])' rx='2'><title>$count $($part[1]) in week $($w + 1)</title></rect>") } } $total = $weeks[$w].Crit + $weeks[$w].Warn + $weeks[$w].Ok if ($total -gt 0) { [void]$svgBars.Append("<text x='$($x + $barW / 2)' y='$($y - 5)' text-anchor='middle' class='barlabel'>$total</text>") } [void]$svgBars.Append("<text x='$($x + $barW / 2)' y='$($chartH + 16)' text-anchor='middle' class='axis'>$($weeks[$w].Label)</text>") } # --- per-tenant / per-server certificate tables -------------------------- $tables = New-Object System.Text.StringBuilder $tenantGroups = $Snapshot | Group-Object { if ($_.PSObject.Properties['Tenant'] -and $_.Tenant) { $_.Tenant } else { 'default' } } | Sort-Object Name foreach ($tenant in $tenantGroups) { [void]$tables.Append("<section class='tenant'><h2>Tenant: $(Encode $tenant.Name)</h2>") foreach ($snap in ($tenant.Group | Sort-Object Host)) { $certs = @($snap.Certificates) [void]$tables.Append("<details open><summary><strong>$(Encode $snap.Host)</strong> · $($certs.Count) certificates · scanned $(Encode $snap.Timestamp)</summary>") [void]$tables.Append("<input type='text' class='filter' placeholder='Filter...' onkeyup='cwFilter(this)'>") [void]$tables.Append("<table class='certs sortable'><thead><tr><th onclick='cwSort(this,0)'>Severity</th><th onclick='cwSort(this,1)'>Role</th><th onclick='cwSort(this,2)'>Subject</th><th onclick='cwSort(this,3)' data-num='1'>Days left</th><th onclick='cwSort(this,4)'>Expires</th><th onclick='cwSort(this,5)'>Thumbprint</th></tr></thead><tbody>") foreach ($cert in ($certs | Sort-Object DaysRemaining)) { $sev = if ($cert.Severity) { $cert.Severity } else { 'OK' } $color = $sevColor[[string]$sev] $notAfter = ([datetime]$cert.NotAfter).ToString('yyyy-MM-dd') [void]$tables.Append("<tr><td><span class='pill' style='background:$color'>$(Encode $sev)</span></td><td>$(Encode $cert.Role)</td><td>$(Encode $cert.Subject)</td><td data-v='$($cert.DaysRemaining)'>$([math]::Round([double]$cert.DaysRemaining, 1))</td><td>$notAfter</td><td class='mono'>$(Encode $cert.Thumbprint)</td></tr>") } [void]$tables.Append('</tbody></table></details>') } [void]$tables.Append('</section>') } # --- CRL / CA panel ------------------------------------------------------ $crlPanel = New-Object System.Text.StringBuilder foreach ($snap in $Snapshot) { foreach ($crl in @($snap.Crls)) { if (-not $crl) { continue } $sev = if ($crl.Severity) { $crl.Severity } else { 'UNKNOWN' } $cdp = @($crl.CdpResults) | ForEach-Object { "$(Encode $_.Url) $(if ($_.Reachable) { 'OK' } else { 'FAIL' })" } [void]$crlPanel.Append("<tr><td><span class='pill' style='background:$($sevColor[[string]$sev])'>$(Encode $sev)</span></td><td>$(Encode $snap.Host)</td><td>$(Encode $crl.Type)</td><td>$(Encode $crl.Issuer)</td><td>$(Encode $crl.NextUpdate)</td><td>$(if ($null -ne $crl.HoursRemaining) { [math]::Round([double]$crl.HoursRemaining, 1) })</td><td>$($cdp -join '<br>')</td><td>$(Encode $crl.SignatureValid)</td></tr>") } } $caPanel = New-Object System.Text.StringBuilder foreach ($snap in $Snapshot) { $ca = $snap.CaHealth if (-not ($ca -and $ca.IsCa)) { continue } $sev = if ($ca.Severity) { $ca.Severity } else { 'UNKNOWN' } [void]$caPanel.Append("<tr><td><span class='pill' style='background:$($sevColor[[string]$sev])'>$(Encode $sev)</span></td><td>$(Encode $snap.Host)</td><td>$(Encode $ca.CaName)</td><td>$(Encode $ca.CaType)</td><td>$(Encode $ca.ServiceStatus)</td><td>$(Encode $ca.PendingRequests)</td><td>$(Encode $ca.FailedRequests24h)</td><td>$(Encode $ca.CaCertNotAfter)</td></tr>") } # --- trend section ------------------------------------------------------- $trendHtml = '' if ($Trend) { $sb = New-Object System.Text.StringBuilder [void]$sb.Append("<section><h2>Trend vs. baseline</h2>") foreach ($block in @( @('New certificates', $Trend.NewCertificates), @('Removed certificates', $Trend.RemovedCertificates), @('Approaching expiry (severity escalated)', $Trend.Approaching))) { $items = @($block[1]) [void]$sb.Append("<h3>$($block[0]) ($($items.Count))</h3>") if ($items.Count -gt 0) { [void]$sb.Append('<ul>') foreach ($item in $items) { $extra = if ($item.PSObject.Properties['DaysRemaining']) { " · $([math]::Round([double]$item.DaysRemaining, 1))d left" } else { '' } [void]$sb.Append("<li><code>$(Encode $item.Host)</code> $(Encode $item.Role) $(Encode $item.Subject)$extra</li>") } [void]$sb.Append('</ul>') } else { [void]$sb.Append("<p class='muted'>none</p>") } } [void]$sb.Append('</section>') $trendHtml = $sb.ToString() } $style = @' <style> :root{--fg:#1c2430;--muted:#68737f;--bg:#f4f6f8;--card:#ffffff;--line:#dde3e9} *{box-sizing:border-box}body{font-family:'Segoe UI',system-ui,sans-serif;color:var(--fg);background:var(--bg);margin:0;padding:24px} h1{margin:0 0 4px;font-size:26px}h2{font-size:19px;margin:28px 0 10px}h3{font-size:15px;margin:14px 0 6px} .sub{color:var(--muted);margin-bottom:20px}.muted{color:var(--muted)} .tiles{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0} .tile{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:150px;flex:1} .tile .v{font-size:24px;font-weight:600}.tile .l{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em} .pill{color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;font-weight:600;display:inline-block} table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px;margin:8px 0 18px} th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line)}th{background:#eef1f4;cursor:pointer;user-select:none;white-space:nowrap} tr:last-child td{border-bottom:none}.mono{font-family:Consolas,monospace;font-size:12px} .filter{margin:8px 0 2px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:260px} details{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 14px;margin:10px 0} summary{cursor:pointer}section.tenant{margin-top:10px} svg{background:var(--card);border:1px solid var(--line);border-radius:10px} .axis{font-size:11px;fill:#68737f}.barlabel{font-size:11px;fill:#1c2430;font-weight:600} .legend span{display:inline-block;margin-right:14px;font-size:12px}.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px} </style> '@ $script = @' <script> function cwFilter(inp){ var q=inp.value.toLowerCase(), tb=inp.nextElementSibling.tBodies[0]; for(var r of tb.rows){r.style.display=r.textContent.toLowerCase().indexOf(q)>=0?'':'none';} } function cwSort(th,idx){ var table=th.closest('table'), tb=table.tBodies[0], rows=Array.from(tb.rows); var num=th.hasAttribute('data-num'), asc=th.dataset.asc!=='1'; table.querySelectorAll('th').forEach(function(h){h.dataset.asc='';}); th.dataset.asc=asc?'1':'0'; rows.sort(function(a,b){ var x=a.cells[idx], y=b.cells[idx]; var xv=x.dataset.v!==undefined?parseFloat(x.dataset.v):x.textContent.trim(); var yv=y.dataset.v!==undefined?parseFloat(y.dataset.v):y.textContent.trim(); if(num||typeof xv==='number'){xv=parseFloat(xv)||0;yv=parseFloat(yv)||0;return asc?xv-yv:yv-xv;} return asc?String(xv).localeCompare(String(yv)):String(yv).localeCompare(String(xv)); }); rows.forEach(function(r){tb.appendChild(r);}); } </script> '@ $nextTile = if ($next) { "$([math]::Round($next.DaysRemaining, 1))d<div class='l'>$(Encode $next.Role) on $(Encode $next.Host)</div>" } else { '–' } $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>$(Encode $Title)</title> $style </head> <body> <h1>$(Encode $Title)</h1> <div class="sub">Generated $($now.ToString('yyyy-MM-dd HH:mm:ss')) · $($Snapshot.Count) server(s) · $($allCerts.Count) certificates · cert-watchtower</div> <div class="tiles"> <div class="tile"><div class="l">Overall</div><div class="v"><span class="pill" style="background:$($sevColor[$score])">$score</span></div></div> <div class="tile"><div class="l">Critical</div><div class="v" style="color:$($sevColor['CRITICAL'])">$critCount</div></div> <div class="tile"><div class="l">Warning</div><div class="v" style="color:$($sevColor['WARNING'])">$warnCount</div></div> <div class="tile"><div class="l">Next expiry</div><div class="v">$nextTile</div></div> <div class="tile"><div class="l">CRL health</div><div class="v"><span class="pill" style="background:$($sevColor[$crlLight[0]])">$($crlLight[0])</span></div><div class="l">$(Encode $crlLight[1])</div></div> <div class="tile"><div class="l">CA health</div><div class="v"><span class="pill" style="background:$($sevColor[$caLight[0]])">$($caLight[0])</span></div><div class="l">$(Encode $caLight[1])</div></div> </div> <h2>Expiry timeline – next 90 days</h2> <div class="legend"><span><i style="background:$($sevColor['CRITICAL'])"></i>CRITICAL</span><span><i style="background:$($sevColor['WARNING'])"></i>WARNING</span><span><i style="background:$($sevColor['OK'])"></i>OK</span></div> <svg viewBox="0 0 740 190" width="740" height="190" role="img" aria-label="Certificate expiries per week">$($svgBars.ToString())</svg> $trendHtml $($tables.ToString()) <h2>CRL health</h2> <table><thead><tr><th>Severity</th><th>Host</th><th>Type</th><th>Issuer</th><th>NextUpdate</th><th>Hours left</th><th>CDP reachability</th><th>Signature valid</th></tr></thead> <tbody>$(if ($crlPanel.Length) { $crlPanel.ToString() } else { "<tr><td colspan='8' class='muted'>no CRL data collected</td></tr>" })</tbody></table> <h2>ADCS CA health</h2> <table><thead><tr><th>Severity</th><th>Host</th><th>CA</th><th>Type</th><th>CertSvc</th><th>Pending</th><th>Failed 24h</th><th>CA cert expires</th></tr></thead> <tbody>$(if ($caPanel.Length) { $caPanel.ToString() } else { "<tr><td colspan='8' class='muted'>no CA in scope</td></tr>" })</tbody></table> $script </body> </html> "@ $html } |