Private/Report.ps1
|
# HTML report generator — self-contained single-file output # Matches the layout and style of the Python CIS Azure Audit Tool report. Add-Type -AssemblyName System.Web function New-CISHtmlReport { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification='Factory function that constructs and returns an object; does not modify system state.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Diff', Justification='False positive - Diff is consumed when building the run-diff block.')] param( [Parameter(Mandatory)][object[]]$Results, [Parameter(Mandatory)][string]$OutputPath, [string]$ScopeLabel = "", [object[]]$History = @(), [hashtable]$ScopeInfo = @{}, [hashtable]$SubTimestamps = @{}, [object]$Diff = $null ) # ── Status styles: bg, text color, HTML entity icon ────────────────────── $sStyle = @{ 'PASS' = @{ Bg='#f0fdf4'; Col='#16a34a'; Icon='✅' } 'FAIL' = @{ Bg='#fef2f2'; Col='#dc2626'; Icon='❌' } 'ERROR' = @{ Bg='#fff7ed'; Col='#ea580c'; Icon='⚠️' } 'INFO' = @{ Bg='#eff6ff'; Col='#2563eb'; Icon='ℹ️' } 'MANUAL' = @{ Bg='#f5f3ff'; Col='#7c3aed'; Icon='📋' } 'SUPPRESSED' = @{ Bg='#f1f5f9'; Col='#64748b'; Icon='🔇' } } # ── Counts & score ──────────────────────────────────────────────────────── $counts = Get-AuditCounts -Results $Results $overallTotal = Get-AssessedCount -Counts $counts $score = Get-AuditScore -Counts $counts $scoreCol = if ($score -ge 80) { '#16a34a' } elseif ($score -ge 60) { '#d97706' } else { '#dc2626' } # ── L1 / L2 breakdown ───────────────────────────────────────────────────── $l1 = Get-AuditCounts -Results @($Results | Where-Object { $_.Level -eq 1 }) $l2 = Get-AuditCounts -Results @($Results | Where-Object { $_.Level -eq 2 }) $l1Total = Get-AssessedCount -Counts $l1 $l2Total = Get-AssessedCount -Counts $l2 $l1Score = Get-AuditScore -Counts $l1 $l2Score = Get-AuditScore -Counts $l2 $l1Col = if ($l1Score -ge 80) { '#16a34a' } elseif ($l1Score -ge 60) { '#d97706' } else { '#dc2626' } $l2Col = if ($l2Score -ge 80) { '#16a34a' } elseif ($l2Score -ge 60) { '#d97706' } else { '#dc2626' } # ── Group results by section ────────────────────────────────────────────── $sectionMap = @{} foreach ($r in $Results) { $sec = $r.Section if (-not $sectionMap.ContainsKey($sec)) { $sectionMap[$sec] = [System.Collections.Generic.List[object]]::new() } $sectionMap[$sec].Add($r) } # ── Per-section data for JS charts ──────────────────────────────────────── $secData = @{} foreach ($sec in $sectionMap.Keys) { $grp = $sectionMap[$sec] $sp = @($grp | Where-Object { $_.Status -eq 'PASS' }).Count $sf = @($grp | Where-Object { $_.Status -eq 'FAIL' }).Count $se = @($grp | Where-Object { $_.Status -eq 'ERROR' }).Count $ss = if (($sp+$sf+$se) -gt 0) { [math]::Round(100.0*$sp/($sp+$sf+$se),1) } else { 0 } $secData[$sec] = [ordered]@{ pass=$sp; fail=$sf; error=$se; score=$ss } } $secDataJson = $secData | ConvertTo-Json -Compress -Depth 3 # ── Build table rows ────────────────────────────────────────────────────── $rows = [System.Text.StringBuilder]::new() $secIdx = 0 $sortedSecs = @($sectionMap.Keys | Sort-Object { Get-ControlSortKey ($_ -split ' ' | Select-Object -First 1) }) foreach ($sec in $sortedSecs) { $secId = "sec-$secIdx"; $secIdx++ $grp = @($sectionMap[$sec]) $sp = @($grp | Where-Object { $_.Status -eq 'PASS' }).Count $secH = [System.Web.HttpUtility]::HtmlEncode($sec) [void]$rows.Append( "<tr class=`"sh`" data-sec-id=`"$secId`">" + "<td colspan=`"6`">" + "<span class=`"sec-arrow open`">▼</span>" + "<b>$secH</b>" + "<span class=`"ss`">$sp of $($grp.Count) checks passed</span>" + "</td></tr>`n") $sortedGrp = @($grp | Sort-Object { Get-ControlSortKey $_.ControlId }, { $_.SubscriptionName }, { $_.Resource }) foreach ($r in $sortedGrp) { $st = if ($sStyle.ContainsKey($r.Status)) { $sStyle[$r.Status] } else { @{ Bg='#f9fafb'; Col='#374151'; Icon='?' } } $subCell = if ($r.SubscriptionName) { "<div class=`"sub-name`">📋 $([System.Web.HttpUtility]::HtmlEncode($r.SubscriptionName))</div>" } else { "<div class=`"sub-name`" style=`"color:#94a3b8`">Tenant-wide</div>" } if ($r.Resource) { $subCell += "<div class=`"res-name`">🔹 <code>$([System.Web.HttpUtility]::HtmlEncode($r.Resource))</code></div>" } $fix = '' if ($r.Remediation -and $r.Status -eq 'FAIL') { $fix = "<div class=`"fix`">💡 $([System.Web.HttpUtility]::HtmlEncode($r.Remediation))</div>" } # Hover tooltip on the control id: page reference into the benchmark PDF. # Guarded so results loaded from an older report JSON (-ReportOnly) with a # control id no longer in the catalog still render, just without the tooltip. $pageAttr = '' try { $ctlPage = (Get-ControlMeta -ControlId $r.ControlId).Page if ($ctlPage -gt 0) { $pageAttr = " title=`"CIS Microsoft Azure Foundations Benchmark v6.0.0, page $ctlPage`"" } } catch { $pageAttr = '' } [void]$rows.Append( "<tr style=`"background:$($st.Bg)`" " + "data-sec=`"$secId`" " + "data-sec-name=`"$([System.Web.HttpUtility]::HtmlEncode($sec))`" " + "data-status=`"$($r.Status)`" " + "data-level=`"L$($r.Level)`" " + "data-sub=`"$([System.Web.HttpUtility]::HtmlEncode($r.SubscriptionName))`">" + "<td><code$pageAttr>$([System.Web.HttpUtility]::HtmlEncode($r.ControlId))</code></td>" + "<td><span class=`"lv`">L$($r.Level)</span></td>" + "<td>$([System.Web.HttpUtility]::HtmlEncode($r.Title))</td>" + "<td class=`"sub-col`">$subCell</td>" + "<td><span class=`"badge`" style=`"color:$($st.Col)`">$($st.Icon) $($r.Status)</span></td>" + "<td>$([System.Web.HttpUtility]::HtmlEncode($r.Details))$fix</td>" + "</tr>`n") } } # ── Subscription summary ────────────────────────────────────────────────── $subStats = @{} foreach ($r in $Results) { $sn = $r.SubscriptionName if (-not $sn) { continue } if (-not $subStats.ContainsKey($sn)) { $subStats[$sn] = @{ PASS=0; FAIL=0; ERROR=0; INFO=0; MANUAL=0; SUPPRESSED=0 } } if ($subStats[$sn].ContainsKey($r.Status)) { $subStats[$sn][$r.Status]++ } } $subRowsHtml = [System.Text.StringBuilder]::new() $sortedSubs = @($subStats.Keys | Sort-Object { $s = $subStats[$_]; $d = $s.PASS+$s.FAIL+$s.ERROR if ($d -gt 0) { $s.PASS / $d * 100 } else { 0 } }) foreach ($sn in $sortedSubs) { $st = $subStats[$sn] $scored = [math]::Max($st.PASS+$st.FAIL+$st.ERROR, 1) # bar-width base (3-segment visual) $pctBase = $st.PASS + $st.FAIL + $st.ERROR $pct = if ($pctBase -gt 0) { [math]::Round($st.PASS / $pctBase * 100, 1) } else { 0 } $col = if ($pct -ge 80) { '#16a34a' } elseif ($pct -ge 60) { '#d97706' } else { '#dc2626' } $passW = [math]::Round($st.PASS / $scored * 100) $failW = [math]::Round($st.FAIL / $scored * 100) $errW = [math]::Max(0, 100-$passW-$failW) $bar = "<div class=`"sbar`">" + "<span style=`"width:$passW%;background:#16a34a`"></span>" + "<span style=`"width:$failW%;background:#dc2626`"></span>" + "<span style=`"width:$errW%;background:#ea580c`"></span></div>" $snH = [System.Web.HttpUtility]::HtmlEncode($sn) # Audited cell — show date and staleness relative to today $auditedCell = '<td>—</td>' if ($SubTimestamps.ContainsKey($sn)) { try { $aud = ([DateTimeOffset]::Parse($SubTimestamps[$sn])).Date $today2 = [DateTimeOffset]::UtcNow.Date $age = ($today2 - $aud).Days $aLbl = if ($age -eq 0) { 'today' } elseif ($age -eq 1) { 'yesterday' } else { "${age}d ago" } $aCol = if ($age -le 1) { '#64748b' } elseif ($age -le 30) { '#d97706' } else { '#dc2626' } # Invariant culture: month abbreviations must not follow the machine locale # (a Danish host rendered "jul. 17" into the report). $aDt = $aud.ToString('MMM dd', [System.Globalization.CultureInfo]::InvariantCulture) $auditedCell = "<td style=`"color:$aCol;white-space:nowrap`">$aDt<br><small>$aLbl</small></td>" } catch { $null = $_ <# Graceful: if date parsing fails, omit the 'last audited' cell #> } } [void]$subRowsHtml.Append( "<tr class=`"sub-row`" data-sub=`"$snH`">" + "<td style=`"font-weight:600`">$snH</td>" + "<td><span style=`"color:$col;font-weight:700`">$pct%</span></td>" + "<td>$bar</td>" + "<td style=`"color:#16a34a;font-weight:600`">$($st.PASS)</td>" + "<td style=`"color:#dc2626;font-weight:600`">$($st.FAIL)</td>" + "<td style=`"color:#ea580c`">$($st.ERROR)</td>" + "<td style=`"color:#64748b`">$($st.INFO)</td>" + "<td style=`"color:#7c3aed`">$($st.MANUAL)</td>" + "<td style=`"color:#64748b`">$($st.SUPPRESSED)</td>" + $auditedCell + "</tr>`n") } $subTable = '' if ($subRowsHtml.Length -gt 0) { $subTable = @" <div class="sub-summary-wrap"> <h2 id="sub-summary-toggle" style="cursor:pointer;user-select:none"><span class="sec-arrow open" id="sub-summary-arrow">▼</span> Subscription Summary <small>(click a row to filter the table below)</small></h2> <div id="sub-summary-body"> <table class="sub-summary"><thead><tr> <th>Subscription</th><th>Score</th><th>Breakdown</th> <th>✓ Pass</th><th>✗ Fail</th><th>⚠ Error</th> <th>Info</th><th>Manual</th><th>🔇 Suppressed</th> <th>Audited</th> </tr></thead><tbody>$($subRowsHtml.ToString())</tbody></table> </div></div> "@ } # ── Scope info block ────────────────────────────────────────────────────── $scopeBlock = '' $scopeRows = [System.Text.StringBuilder]::new() if ($ScopeInfo.tenant) { [void]$scopeRows.Append("<tr><th>Tenant</th><td>$([System.Web.HttpUtility]::HtmlEncode([string]$ScopeInfo.tenant))</td></tr>`n") } if ($ScopeInfo.user) { $callerLabel = if ([string]$ScopeInfo.caller_type -eq 'servicePrincipal') { 'service principal' } else { 'user' } [void]$scopeRows.Append("<tr><th>Audited by</th><td>$([System.Web.HttpUtility]::HtmlEncode([string]$ScopeInfo.user)) <span style=`"color:#94a3b8;font-size:0.85em`">($callerLabel)</span></td></tr>`n") } # Scope row — always shown (mirrors Python scope_label) $sl = if ($ScopeInfo.scope_label) { [string]$ScopeInfo.scope_label } elseif ($ScopeLabel) { $ScopeLabel } else { $null } if ($sl) { [void]$scopeRows.Append("<tr><th>Scope</th><td>$([System.Web.HttpUtility]::HtmlEncode($sl))</td></tr>`n") } # Subscriptions row — shown when the list is available if ($ScopeInfo.subscriptions -and @($ScopeInfo.subscriptions).Count -gt 0) { $siSubs = @($ScopeInfo.subscriptions) $subsHtml = ($siSubs | ForEach-Object { [System.Web.HttpUtility]::HtmlEncode([string]$_) }) -join ', ' [void]$scopeRows.Append("<tr><th>Subscriptions ($($siSubs.Count))</th><td>$subsHtml</td></tr>`n") } if ($ScopeInfo.level_filter -and [string]$ScopeInfo.level_filter -ne 'both') { [void]$scopeRows.Append("<tr><th>Level filter</th><td>Level $([System.Web.HttpUtility]::HtmlEncode([string]$ScopeInfo.level_filter)) only</td></tr>`n") } if ($scopeRows.Length -gt 0) { $scopeBlock = "<div class=`"scope-info`"><table>$($scopeRows.ToString())</table></div>" } # ── Trend chart ─────────────────────────────────────────────────────────── $trendBlock = '' if (@($History).Count -ge 2) { # Filter out 0% entries and deduplicate same-day same-score pairs $seen = @{} $fh = @($History | Where-Object { $_.score -gt 0 } | Where-Object { $day = if ($_.timestamp) { ([string]$_.timestamp) -replace 'T.*','' } else { '' } $key = "$day|$($_.score)" if (-not $seen.ContainsKey($key)) { $seen[$key] = $true; $true } else { $false } }) | Select-Object -Last 30 if ($fh.Count -ge 2) { $trendJson = ($fh | ForEach-Object { $ts2 = if ($_.timestamp) { [string]$_.timestamp -replace 'T.*','' } else { '' } [PSCustomObject]@{ ts=$ts2; score=$_.score } } | ConvertTo-Json -Compress) $trendBlock = @" <details class="trend-box"> <summary>📈 Compliance Trend — last $($fh.Count) run(s)</summary> <div class="trend-inner"><canvas id="trend-cv" height="130"></canvas></div> </details> <script> (function(){ var TREND = $trendJson; var cv = document.getElementById('trend-cv'); if (!cv || !TREND.length) return; cv.width = cv.parentElement.offsetWidth || 700; var W=cv.width, H=cv.height, PAD={t:20,r:20,b:36,l:44}; var iW=W-PAD.l-PAD.r, iH=H-PAD.t-PAD.b; var ctx=cv.getContext('2d'); var scores=TREND.map(function(d){return d.score;}); var minS=Math.max(0,Math.min.apply(null,scores)-5); var maxS=Math.min(100,Math.max.apply(null,scores)+5); function sx(i){return PAD.l+(i/(TREND.length-1))*iW;} function sy(v){return PAD.t+iH-((v-minS)/(maxS-minS))*iH;} ctx.strokeStyle='#e2e8f0'; ctx.lineWidth=1; [0,25,50,75,100].forEach(function(v){ if(v<minS||v>maxS)return; var y=sy(v); ctx.beginPath(); ctx.moveTo(PAD.l,y); ctx.lineTo(PAD.l+iW,y); ctx.stroke(); ctx.fillStyle='#94a3b8'; ctx.font='11px sans-serif'; ctx.textAlign='right'; ctx.fillText(v+'%',PAD.l-6,y+4); }); ctx.beginPath(); ctx.moveTo(sx(0),sy(scores[0])); scores.forEach(function(s,i){if(i)ctx.lineTo(sx(i),sy(s));}); ctx.lineTo(sx(scores.length-1),PAD.t+iH); ctx.lineTo(sx(0),PAD.t+iH); ctx.closePath(); ctx.fillStyle='rgba(37,99,235,0.08)'; ctx.fill(); ctx.beginPath(); scores.forEach(function(s,i){i?ctx.lineTo(sx(i),sy(s)):ctx.moveTo(sx(i),sy(s));}); ctx.strokeStyle='#2563eb'; ctx.lineWidth=2; ctx.stroke(); TREND.forEach(function(d,i){ var x=sx(i),y=sy(d.score); var col=d.score>=80?'#16a34a':d.score>=60?'#d97706':'#dc2626'; ctx.beginPath(); ctx.arc(x,y,4,0,2*Math.PI); ctx.fillStyle=col; ctx.fill(); ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle=col; ctx.font='bold 11px sans-serif'; ctx.textAlign='center'; ctx.fillText(d.score+'%',x,y-9); ctx.fillStyle='#64748b'; ctx.font='10px sans-serif'; ctx.fillText(d.ts,x,H-8); }); })(); </script> "@ } } # ── Run-diff block (changes vs a previous run, when -Diff is supplied) ─── $diffBlock = "" if ($null -ne $Diff) { $dCats = @( @{ Key = 'Regressions'; Label = 'Regression'; Noun = 'regression(s)'; Col = '#dc2626'; Bg = '#fef2f2' }, @{ Key = 'Improvements'; Label = 'Improvement'; Noun = 'improvement(s)'; Col = '#16a34a'; Bg = '#f0fdf4' }, @{ Key = 'New'; Label = 'New'; Noun = 'new'; Col = '#2563eb'; Bg = '#eff6ff' }, @{ Key = 'Removed'; Label = 'Removed'; Noun = 'removed'; Col = '#64748b'; Bg = '#f1f5f9' } ) $diffTotal = 0 $diffCounts = foreach ($cat in $dCats) { $n = @($Diff.($cat.Key)).Count $diffTotal += $n "$n $($cat.Noun)" } $diffSummary = $diffCounts -join ' · ' $maxPerCat = 50 $dRows = [System.Text.StringBuilder]::new() foreach ($cat in $dCats) { $items = @($Diff.($cat.Key)) foreach ($e in @($items | Select-Object -First $maxPerCat)) { $target = if ($e.subscription) { "<div class=`"sub-name`">📋 $([System.Web.HttpUtility]::HtmlEncode($e.subscription))</div>" } else { "<div class=`"sub-name`" style=`"color:#94a3b8`">Tenant-wide</div>" } if ($e.resource) { $target += "<div class=`"res-name`">🔹 <code>$([System.Web.HttpUtility]::HtmlEncode($e.resource))</code></div>" } $statusCell = switch ($cat.Key) { 'New' { [System.Web.HttpUtility]::HtmlEncode($e.status) } 'Removed' { "was $([System.Web.HttpUtility]::HtmlEncode($e.previousStatus))" } default { "$([System.Web.HttpUtility]::HtmlEncode($e.previousStatus)) → $([System.Web.HttpUtility]::HtmlEncode($e.status))" } } [void]$dRows.Append( "<tr style=`"background:$($cat.Bg)`">" + "<td><span class=`"diff-badge`" style=`"color:$($cat.Col)`">$($cat.Label)</span></td>" + "<td><code>$([System.Web.HttpUtility]::HtmlEncode($e.control))</code></td>" + "<td>$([System.Web.HttpUtility]::HtmlEncode($e.title))</td>" + "<td class=`"sub-col`">$target</td>" + "<td style=`"white-space:nowrap`">$statusCell</td>" + "<td>$([System.Web.HttpUtility]::HtmlEncode($e.details))</td>" + "</tr>`n") } if ($items.Count -gt $maxPerCat) { [void]$dRows.Append( "<tr class=`"diff-more`"><td colspan=`"6`">…and $($items.Count - $maxPerCat) more " + "$($cat.Label.ToLower()) entries — see the full list in the .diff.json export.</td></tr>`n") } } $diffBody = if ($diffTotal -eq 0) { "<p style=`"font-size:.85rem;color:#64748b`">No changes compared to the baseline run.</p>" } else { "<table><colgroup><col style=`"width:9%`"><col style=`"width:6%`"><col style=`"width:24%`">" + "<col style=`"width:17%`"><col style=`"width:12%`"><col style=`"width:32%`"></colgroup>" + "<thead><tr><th>Change</th><th>Control</th><th>Title</th><th>Subscription / Resource</th>" + "<th>Status</th><th>Details</th></tr></thead><tbody>$($dRows.ToString())</tbody></table>" } $openAttr = if ($diffTotal -gt 0) { " open" } else { "" } $diffBlock = @" <details class="trend-box"$openAttr> <summary>🔄 Changes vs previous run — $diffSummary</summary> <div class="trend-inner">$diffBody</div> </details> "@ } # ── JS data ─────────────────────────────────────────────────────────────── $jsCountsJson = [PSCustomObject]@{ PASS=$counts.PASS; FAIL=$counts.FAIL; ERROR=$counts.ERROR } | ConvertTo-Json -Compress $jsL1Json = [PSCustomObject]@{ pass=$l1.PASS; fail=$l1.FAIL; error=$l1.ERROR } | ConvertTo-Json -Compress $jsL2Json = [PSCustomObject]@{ pass=$l2.PASS; fail=$l2.FAIL; error=$l2.ERROR } | ConvertTo-Json -Compress $ts = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm") + " UTC" # ── Assemble HTML ───────────────────────────────────────────────────────── $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>CIS Azure Audit Report — $ts</title> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.5; } header { background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); color: #fff; padding: 2rem; } header h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: .25rem; } header p { opacity: .8; font-size: .9rem; } .cards { display: flex; gap: 1rem; padding: 1.5rem 2rem; flex-wrap: wrap; } .card { flex: 1; min-width: 120px; background: #fff; border-radius: 10px; padding: 1.2rem; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,.08); } .card .n { font-size: 2rem; font-weight: 800; line-height: 1; } .card .l { font-size: .78rem; color: #64748b; margin-top: .3rem; } .c-sc .n { font-size: 2.4rem; } .c-pa .n { color: #16a34a; } .c-fa .n { color: #dc2626; } .c-er .n { color: #ea580c; } .c-in .n { color: #2563eb; } .c-ma .n { color: #7c3aed; } .c-su .n { color: #64748b; } .trend-box { margin: 0 2rem 1rem; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: hidden; } .trend-box > summary { padding: .65rem 1rem; font-size: .85rem; font-weight: 600; color: #475569; cursor: pointer; list-style: none; user-select: none; } .trend-box > summary::-webkit-details-marker { display: none; } .trend-box > summary:hover { background: #f8fafc; } .trend-inner { padding: .75rem 1rem 1rem; } .trend-inner canvas { width: 100%; display: block; } .filters { display: flex; align-items: center; gap: .75rem; padding: .8rem 2rem; background: #fff; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; } .filters label { font-weight: 600; font-size: .85rem; color: #475569; } .filters input, .filters select { border: 1px solid #cbd5e1; border-radius: 6px; padding: .4rem .7rem; font-size: .85rem; outline: none; } .filters input { min-width: 220px; } .filters input:focus { border-color: #2563eb; } .exp-btn { background: #1e3a5f; color: #fff; border-radius: 6px; padding: .4rem .8rem; font-size: .85rem; text-decoration: none; white-space: nowrap; border: none; cursor: pointer; font-family: inherit; } .exp-btn:hover { background: #2563eb; } .wrap { overflow-x: auto; padding: 0 2rem 2rem; } table { width: 100%; border-collapse: collapse; font-size: .84rem; table-layout: fixed; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); } thead { background: #1e3a5f; color: #fff; } th, td { padding: .55rem .8rem; text-align: left; border-bottom: 1px solid #e2e8f0; } td { overflow-wrap: break-word; word-wrap: break-word; } th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; } tr.sh td { background: #f1f5f9; font-size: .8rem; color: #475569; border-top: 2px solid #cbd5e1; padding: .5rem .8rem; } tr.sh { cursor: pointer; user-select: none; } tr.sh:hover td { background: #e2e8f0; } .sec-arrow { display: inline-block; margin-right: .45rem; font-size: .8rem; transition: transform .2s ease; } .sec-arrow.open { transform: rotate(0deg); } .sec-arrow.closed { transform: rotate(-90deg); } .row-hidden { display: none !important; } .ss { float: right; color: #94a3b8; font-weight: normal; } .badge { font-size: .78rem; font-weight: 700; white-space: nowrap; } .diff-badge { font-size: .74rem; font-weight: 700; white-space: nowrap; } .diff-more td { color: #94a3b8; font-style: italic; font-size: .8rem; } .lv { font-size: .7rem; background: #e2e8f0; border-radius: 4px; padding: 1px 5px; font-weight: 600; color: #475569; } .sub-col { min-width: 180px; max-width: 240px; vertical-align: top; } .sub-name { font-size: .78rem; color: #374151; font-weight: 600; padding: 1px 0; margin-bottom: 2px; } .res-name { font-size: .76rem; color: #6b7280; margin-top: 3px; } .res-name code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; } /* Control ids carry a benchmark page reference in their tooltip */ td code[title] { cursor: help; border-bottom: 1px dotted #94a3b8; } .fix { margin-top: .4rem; font-size: .78rem; color: #64748b; font-style: italic; } .scope-info { margin-top: 1rem; } .scope-info table { border-collapse: collapse; font-size: .82rem; background: rgba(255,255,255,.12); border-radius: 6px; overflow: hidden; } .scope-info th { color: rgba(255,255,255,.7); font-weight: 600; padding: .25rem .8rem; text-align: right; white-space: nowrap; border-right: 1px solid rgba(255,255,255,.2); } .scope-info td { color: #fff; padding: .25rem .8rem; } footer { text-align: center; padding: 1.5rem; color: #94a3b8; font-size: .8rem; } .sub-summary-wrap { padding: 0 2rem 1.5rem; } .sub-summary-wrap h2 { font-size: 1rem; font-weight: 700; color: #1e293b; margin-bottom: .6rem; } .sub-summary-wrap h2 small { font-size: .75rem; color: #94a3b8; font-weight: normal; } .sub-summary { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); } .sub-summary thead { background: #1e3a5f; color: #fff; } .sub-summary th, .sub-summary td { padding: .45rem .8rem; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: .83rem; } .sub-summary th { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; } .sub-row { cursor: pointer; } .sub-row:hover { background: #f1f5f9 !important; } .sub-row.active { background: #dbeafe !important; outline: 2px solid #2563eb; } .sbar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; min-width: 120px; background: #e2e8f0; } .sbar span { display: block; height: 100%; } #back-top { position: fixed; bottom: 1.5rem; right: 1.5rem; width: 2.5rem; height: 2.5rem; background: #2563eb; color: #fff; border-radius: 50%; font-size: 1.1rem; box-shadow: 0 2px 8px rgba(0,0,0,.2); text-decoration: none; display: flex; align-items: center; justify-content: center; z-index: 999; } #back-top:hover { background: #1d4ed8; } .dashboard { display: flex; gap: 2rem; padding: 1rem 2rem 1.5rem; flex-wrap: wrap; align-items: flex-start; background: #fff; border-bottom: 1px solid #e2e8f0; } .dash-donuts { display: flex; gap: 2rem; flex-wrap: wrap; align-items: flex-start; } .donut-group { display: flex; flex-direction: column; align-items: center; gap: .25rem; } .donut-label { font-size: .7rem; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: .05em; text-align: center; margin-bottom: .15rem; } .lv-badge { font-size: .62rem; font-weight: 700; color: #fff; border-radius: 3px; padding: 0 4px; margin-left: 4px; vertical-align: middle; } .donut-pct { font-size: 1.45rem; font-weight: 800; line-height: 1; margin-top: .2rem; } .donut-cnt { font-size: .72rem; color: #94a3b8; margin-top: .1rem; } .donut-legend { display: flex; gap: .9rem; margin-top: .7rem; flex-wrap: wrap; } .donut-legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: #475569; } .donut-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; } .sec-breakdown { flex: 1; min-width: 260px; } .sb-title { font-size: .72rem; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: .05em; margin-bottom: .75rem; } .sb-subtitle { font-size: .67rem; font-weight: 400; color: #94a3b8; text-transform: none; letter-spacing: 0; margin-left: .4rem; } .sb-row { display: flex; align-items: center; gap: .6rem; margin-bottom: .42rem; } .sb-name { font-size: .77rem; color: #1e293b; font-weight: 500; width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; } .sb-bar { flex: 1; height: 9px; border-radius: 5px; background: #e2e8f0; overflow: hidden; display: flex; min-width: 80px; } .sb-bar span { display: block; height: 100%; } .sb-pct { font-size: .77rem; font-weight: 700; width: 40px; text-align: right; flex-shrink: 0; } @media print { #back-top, .filters, .trend-box { display: none !important; } .sec-arrow { display: none; } body { background: #fff; font-size: .78rem; } .wrap { overflow: visible; padding: 0 1rem 1rem; } header, tr[style], tr.sh td, thead, .cards .card { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .cards .card { box-shadow: none; border: 1px solid #e2e8f0; } table, .sub-summary { box-shadow: none; } thead { display: table-header-group; } tr { page-break-inside: avoid; } tr.sh { page-break-inside: avoid; page-break-after: avoid; cursor: default; } .dashboard { page-break-inside: avoid; } .sub-summary-wrap { page-break-inside: avoid; } .sub-summary-wrap h2 small { display: none; } .sub-col { min-width: 100px; max-width: 160px; } table { font-size: .78rem; } .sub-row:hover { background: inherit !important; } .sub-row.active { outline: none; background: inherit !important; } } </style> </head> <body> <a id="top"></a> <header> <h1>🔒 CIS Azure Audit Report — $ts</h1> <p>Audit Tool v$($script:CIS_VERSION) · CIS Microsoft Azure Foundations Benchmark v$($script:BENCHMARK_VER)</p> $scopeBlock </header> <div class="cards"> <div class="card c-sc"><div class="n" style="color:$scoreCol">$score%</div><div class="l">Compliance Score</div></div> <div class="card c-pa"><div class="n">$($counts.PASS)</div><div class="l">✅ Passed</div></div> <div class="card c-fa"><div class="n">$($counts.FAIL)</div><div class="l">❌ Failed</div></div> <div class="card c-er"><div class="n">$($counts.ERROR)</div><div class="l">⚠️ Errors</div></div> <div class="card c-in"><div class="n">$($counts.INFO)</div><div class="l">ℹ️ Info/N/A</div></div> <div class="card c-ma"><div class="n">$($counts.MANUAL)</div><div class="l">📋 Manual</div></div> <div class="card c-su"><div class="n">$($counts.SUPPRESSED)</div><div class="l">🔇 Suppressed</div></div> </div> <div class="dashboard"> <div class="dash-donuts"> <div class="donut-group"> <div class="donut-label">Overall</div> <canvas id="d-overall" width="110" height="110"></canvas> <div id="pct-overall" class="donut-pct" style="color:$scoreCol">$score%</div> <div id="cnt-overall" class="donut-cnt">$($counts.PASS) / $overallTotal pass</div> </div> <div class="donut-group"> <div class="donut-label">Level 1<span class="lv-badge" style="background:#dc2626">L1</span></div> <canvas id="d-l1" width="110" height="110"></canvas> <div id="pct-l1" class="donut-pct" style="color:$l1Col">$l1Score%</div> <div id="cnt-l1" class="donut-cnt">$($l1.PASS) / $l1Total pass</div> </div> <div class="donut-group"> <div class="donut-label">Level 2<span class="lv-badge" style="background:#7c3aed">L2</span></div> <canvas id="d-l2" width="110" height="110"></canvas> <div id="pct-l2" class="donut-pct" style="color:$l2Col">$l2Score%</div> <div id="cnt-l2" class="donut-cnt">$($l2.PASS) / $l2Total pass</div> </div> <div class="donut-legend"> <span><i style="background:#16a34a"></i>Pass</span> <span><i style="background:#dc2626"></i>Fail</span> <span><i style="background:#cbd5e1"></i>Error / N/A</span> </div> </div> <div class="sec-breakdown" id="sec-breakdown"></div> </div> $trendBlock $diffBlock $subTable <div class="filters"> <label>Filter:</label> <input id="s" placeholder="Search control ID or title..."> <select id="st"> <option value="">All statuses</option> <option>PASS</option><option>FAIL</option><option>ERROR</option> <option>INFO</option><option>MANUAL</option><option>SUPPRESSED</option> </select> <select id="lv"> <option value="">All levels</option> <option value="L1">Level 1</option><option value="L2">Level 2</option> </select> <button class="exp-btn" onclick="exportCsv()">⇩ Export CSV</button> <button class="exp-btn" onclick="exportJson()">⇩ Export JSON</button> </div> <div class="wrap"><table> <colgroup><col style="width:6%"><col style="width:7%"><col style="width:25%"><col style="width:17%"><col style="width:8%"><col style="width:37%"></colgroup> <thead><tr> <th>Control</th><th>Level</th><th>Title</th><th>Subscription / Resource</th><th>Status</th><th>Details</th> </tr></thead> <tbody id="tb">$($rows.ToString())</tbody> </table></div> <a href="#top" id="back-top" title="Back to top">⇧</a> <footer> CIS Microsoft Azure Foundations Benchmark v$($script:BENCHMARK_VER) · Tool v$($script:CIS_VERSION) · Compliance score = PASS / (PASS + FAIL + ERROR); INFO, MANUAL and SUPPRESSED checks are excluded. Manual controls require separate review per the CIS benchmark document. </footer> <script> (function(){ var s = document.getElementById('s'); var st = document.getElementById('st'); var lv = document.getElementById('lv'); var JS_COUNTS = $jsCountsJson; var JS_L1 = $jsL1Json; var JS_L2 = $jsL2Json; var JS_SECTIONS = $secDataJson; var subF = ''; var _collapsed = {}; // Subscription summary collapse/expand toggle var subToggle = document.getElementById('sub-summary-toggle'); var subBody = document.getElementById('sub-summary-body'); var subArrow = document.getElementById('sub-summary-arrow'); if (subToggle && subBody) { subToggle.addEventListener('click', function(){ var hidden = subBody.style.display === 'none'; subBody.style.display = hidden ? '' : 'none'; if (subArrow) { subArrow.classList.toggle('closed', !hidden); subArrow.classList.toggle('open', hidden); } }); } document.querySelectorAll('#tb tr.sh').forEach(function(hdr){ hdr.addEventListener('click', function(){ var id = hdr.dataset.secId; _collapsed[id] = !_collapsed[id]; var arrow = hdr.querySelector('.sec-arrow'); arrow.classList.toggle('closed', _collapsed[id]); arrow.classList.toggle('open', !_collapsed[id]); filter(); }); }); function filter(){ var sv = s.value.toLowerCase(); var stv = st.value; var lvv = lv.value; var filtering = !!(sv || stv || lvv || subF); document.querySelectorAll('#tb tr:not(.sh)').forEach(function(r){ var badge = r.querySelector('.badge'); var lb = r.querySelector('.lv'); var ok = (!sv || r.textContent.toLowerCase().includes(sv)) && (!stv || (badge && badge.textContent.includes(stv))) && (!lvv || (lb && lb.textContent === lvv)) && (!subF || r.dataset.sub === subF); r.dataset.filterMatch = ok ? '1' : '0'; r.classList.toggle('row-hidden', !(ok && (!_collapsed[r.dataset.sec] || filtering))); }); document.querySelectorAll('#tb tr.sh').forEach(function(h){ var sib = h.nextElementSibling; var anyMatch = false; while (sib && !sib.classList.contains('sh')){ if (sib.dataset.filterMatch === '1') anyMatch = true; sib = sib.nextElementSibling; } h.classList.toggle('row-hidden', !anyMatch); }); } s.addEventListener('input', filter); st.addEventListener('change', filter); lv.addEventListener('change', filter); document.querySelectorAll('.sub-row').forEach(function(row){ row.addEventListener('click', function(){ if (subF === row.dataset.sub){ subF = ''; row.classList.remove('active'); } else { subF = row.dataset.sub; document.querySelectorAll('.sub-row').forEach(function(r){ r.classList.remove('active'); }); row.classList.add('active'); } filter(); updateCharts(); }); }); function updateCharts(){ var counts, l1, l2, secs; if (!subF){ counts = JS_COUNTS; l1 = JS_L1; l2 = JS_L2; secs = JS_SECTIONS; } else { counts = {PASS:0,FAIL:0,ERROR:0}; l1 = {pass:0,fail:0,error:0}; l2 = {pass:0,fail:0,error:0}; secs = {}; document.querySelectorAll('#tb tr:not(.sh)').forEach(function(r){ if (r.dataset.sub !== subF) return; var st2=r.dataset.status, lvl=r.dataset.level, sn=r.dataset.secName; if (!secs[sn]) secs[sn]={pass:0,fail:0,error:0,score:0}; if (st2==='PASS') { counts.PASS++; secs[sn].pass++; (lvl==='L1'?l1:l2).pass++; } else if (st2==='FAIL') { counts.FAIL++; secs[sn].fail++; (lvl==='L1'?l1:l2).fail++; } else if (st2==='ERROR') { counts.ERROR++; secs[sn].error++; (lvl==='L1'?l1:l2).error++; } }); Object.keys(secs).forEach(function(sn){ var d=secs[sn]; d.score=Math.round(d.pass/Math.max(d.pass+d.fail+d.error,1)*1000)/10; }); } ['d-overall','d-l1','d-l2'].forEach(function(id){ var cv=document.getElementById(id); if(cv) cv.getContext('2d').clearRect(0,0,cv.width,cv.height); }); drawDonut('d-overall',counts.PASS,counts.FAIL,counts.ERROR); drawDonut('d-l1',l1.pass,l1.fail,l1.error); drawDonut('d-l2',l2.pass,l2.fail,l2.error); renderSectionBreakdown(secs); function scoreColor(sc){ return sc>=80?'#16a34a':sc>=60?'#d97706':'#dc2626'; } function pct(p,f,e){ return Math.round(p/Math.max(p+f+e,1)*1000)/10; } var sc=pct(counts.PASS,counts.FAIL,counts.ERROR); var l1s=pct(l1.pass,l1.fail,l1.error); var l2s=pct(l2.pass,l2.fail,l2.error); [['pct-overall',sc],['pct-l1',l1s],['pct-l2',l2s]].forEach(function(pair){ var el=document.getElementById(pair[0]); if(el){ el.textContent=pair[1]+'%'; el.style.color=scoreColor(pair[1]); } }); var ot=counts.PASS+counts.FAIL+counts.ERROR; var l1t=l1.pass+l1.fail+l1.error; var l2t=l2.pass+l2.fail+l2.error; [['cnt-overall',counts.PASS,ot],['cnt-l1',l1.pass,l1t],['cnt-l2',l2.pass,l2t]].forEach(function(t){ var el=document.getElementById(t[0]); if(el) el.textContent=t[1]+' / '+t[2]+' pass'; }); } updateCharts(); function drawDonut(id,pass,fail,error){ var cv=document.getElementById(id); if(!cv) return; var ctx=cv.getContext('2d'); var cx=55,cy=55,r=44,ri=28; var total=pass+fail+error; if(total===0) return; var segs=[[pass,'#16a34a'],[fail,'#dc2626'],[error,'#cbd5e1']]; var start=-Math.PI/2; segs.forEach(function(seg){ if(!seg[0]) return; var arc=(seg[0]/total)*2*Math.PI; ctx.beginPath(); ctx.moveTo(cx+r*Math.cos(start),cy+r*Math.sin(start)); ctx.arc(cx,cy,r,start,start+arc); ctx.arc(cx,cy,ri,start+arc,start,true); ctx.closePath(); ctx.fillStyle=seg[1]; ctx.fill(); start+=arc; }); ctx.beginPath(); ctx.arc(cx,cy,ri-1,0,2*Math.PI); ctx.fillStyle='#fff'; ctx.fill(); } function renderSectionBreakdown(data){ data = data || JS_SECTIONS; var el=document.getElementById('sec-breakdown'); if(!el) return; var secs=Object.keys(data) .filter(function(a){ return data[a].pass+data[a].fail+data[a].error>0; }) .sort(function(a,b){ return data[a].score-data[b].score; }); var h='<div class="sb-title">Section Breakdown<span class="sb-subtitle">worst → best</span></div>'; secs.forEach(function(sec){ var d=data[sec]; var scored=d.pass+d.fail+d.error; var col=d.score>=80?'#16a34a':d.score>=60?'#d97706':'#dc2626'; var pw=scored?Math.round(d.pass/scored*100):0; var fw=scored?Math.round(d.fail/scored*100):0; var ew=Math.max(0,100-pw-fw); h+='<div class="sb-row">' +'<div class="sb-name" title="'+sec+'">'+sec+'</div>' +'<div class="sb-bar">' +'<span style="width:'+pw+'%;background:#16a34a"></span>' +'<span style="width:'+fw+'%;background:#dc2626"></span>' +'<span style="width:'+ew+'%;background:#cbd5e1"></span>' +'</div>' +'<span class="sb-pct" style="color:'+col+'">'+d.score+'%</span>' +'</div>'; }); el.innerHTML=h; } /* CSV / JSON export */ function allRows(){ var rows=[]; document.querySelectorAll('#tb tr:not(.sh)').forEach(function(r){ var cells=r.querySelectorAll('td'); rows.push({ control: cells[0]?cells[0].textContent.trim():'', level: cells[1]?cells[1].textContent.trim():'', title: cells[2]?cells[2].textContent.trim():'', sub_resource: cells[3]?cells[3].textContent.trim().replace(/\s+/g,' '):'', status: r.dataset.status||'', details: cells[5]?cells[5].textContent.trim().replace(/\s+/g,' '):'' }); }); return rows; } window.exportCsv = function(){ var rows=allRows(); var hdr='Control,Level,Title,Subscription/Resource,Status,Details\n'; var body=rows.map(function(r){ return [r.control,r.level,r.title,r.sub_resource,r.status,r.details] .map(function(v){ return '"'+v.replace(/"/g,'""')+'"'; }).join(','); }).join('\n'); var a=document.createElement('a'); a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(hdr+body); a.download='cis_audit_export.csv'; a.click(); }; window.exportJson = function(){ var a=document.createElement('a'); a.href='data:application/json;charset=utf-8,'+encodeURIComponent(JSON.stringify(allRows(),null,2)); a.download='cis_audit_export.json'; a.click(); }; })(); </script> </body> </html> "@ [System.IO.File]::WriteAllText($OutputPath, $html, [System.Text.Encoding]::UTF8) # ── JSON export ─────────────────────────────────────────────────────────── $jsonPath = [System.IO.Path]::ChangeExtension($OutputPath, '.json') $jsonData = @($Results | ForEach-Object { [ordered]@{ control = $_.ControlId level = $_.Level title = $_.Title subscription = if ($_.SubscriptionName) { $_.SubscriptionName } else { '' } resource = if ($_.Resource) { $_.Resource } else { '' } status = $_.Status details = $_.Details } }) # -InputObject keeps a single-element result serialized as a JSON array $jsonText = ConvertTo-Json -InputObject $jsonData -Depth 5 # BOM-less UTF-8 — RFC 8259 forbids a BOM and strict JSON parsers reject it [System.IO.File]::WriteAllText($jsonPath, $jsonText, [System.Text.UTF8Encoding]::new($false)) # ── CSV export ──────────────────────────────────────────────────────────── $csvPath = [System.IO.Path]::ChangeExtension($OutputPath, '.csv') $csvLines = [System.Collections.Generic.List[string]]::new() $csvLines.Add('"control","level","title","subscription","resource","status","details"') foreach ($r in $Results) { $fields = @( $r.ControlId, $r.Level, ($r.Title -replace '"', '""'), $(if ($r.SubscriptionName) { $r.SubscriptionName -replace '"', '""' } else { '' }), $(if ($r.Resource) { $r.Resource -replace '"', '""' } else { '' }), $r.Status, ($r.Details -replace '"', '""') ) $csvLines.Add('"' + ($fields -join '","') + '"') } [System.IO.File]::WriteAllText($csvPath, ($csvLines -join "`n"), [System.Text.Encoding]::UTF8) # ── SARIF export ────────────────────────────────────────────────────────── $null = New-CISSarifReport -Results $Results -OutputPath ([System.IO.Path]::ChangeExtension($OutputPath, '.sarif')) } |