internal/Export/Export-GWSReportHtml.ps1
|
# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Export-GWSReportHtml { [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject[]]$Findings, [Parameter(Mandatory)] [int]$OverallScore, [Parameter(Mandatory)] [string]$ScoreLabel, [Parameter(Mandatory)] [hashtable]$CategoryScores, [string]$TenantDomain = '', [AllowNull()]$RunDiff, [Parameter(Mandatory)] [string]$FilePath, [ValidateSet('Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')] [string]$Style = 'Auto', [hashtable]$Branding, [string]$Language = 'en' ) $esc = { param([string]$s) [System.Web.HttpUtility]::HtmlEncode($s) } $Findings = Get-GuerrillaLocalizedFindings -Findings $Findings -Language $Language $t = Get-GuerrillaReportStringResolver -Language $Language $tr = Get-GuerrillaReportStringResolver -Language $Language -Raw # Render the affected accounts/objects captured in a finding's Details as one or more # labeled BULLETED lists — delegates to the shared Get-GuerrillaReportAffectedHtml so the # GWS, AD, Entra and Campaign reports all surface affected entities identically. $renderAffected = { param($Details) Get-GuerrillaReportAffectedHtml -Details $Details -Language $Language } $timestampStr = [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' # --- Counts --- $totalChecks = $Findings.Count $passCount = @($Findings | Where-Object Status -eq 'PASS').Count $failCount = @($Findings | Where-Object Status -eq 'FAIL').Count $warnCount = @($Findings | Where-Object Status -eq 'WARN').Count $skipCount = @($Findings | Where-Object Status -in @('SKIP', 'ERROR')).Count $failFindings = @($Findings | Where-Object Status -eq 'FAIL') $critCount = @($failFindings | Where-Object Severity -eq 'Critical').Count $highCount = @($failFindings | Where-Object Severity -eq 'High').Count $medCount = @($failFindings | Where-Object Severity -eq 'Medium').Count $lowCount = @($failFindings | Where-Object Severity -eq 'Low').Count $scoreColor = Get-GuerrillaScoreColorVar -Score $OverallScore $displayLabel = $ScoreLabel $extraCss = @' .extra-wrap { display: flex; flex-direction: column; gap: 0.5rem; } .extra-links { display: flex; flex-wrap: wrap; gap: 1.2rem; font-size: 0.88rem; margin-top: 0.2rem; } .remediation-cell { max-width: 300px; font-size: 0.9em; } '@ $html = [System.Text.StringBuilder]::new(65536) # ═══ SHELL + HEADER ═══ $domainLine = if ($TenantDomain) { "$(& $t 'common.domain'): $(& $esc $TenantDomain) · " } else { '' } $subtitle = "${domainLine}$(& $t 'common.generated'): $timestampStr · $(& $tr 'gws.configChecksEvaluated' $totalChecks)" [void]$html.Append((Get-GuerrillaReportShellStart ` -Title (& $tr 'gws.title') ` -Subtitle $subtitle ` -HtmlTitle "$(& $tr 'gws.htmlTitle')$(if ($TenantDomain) { " - $TenantDomain" }) - $timestampStr" ` -TopbarMeta (& $tr 'gws.topbar') ` -Style $Style -Language $Language -Branding $Branding -ExtraCss $extraCss)) # ═══ SCORE PANEL ═══ $circumference = 2 * [Math]::PI * 50 $dashoffset = $circumference * (1 - ($OverallScore / 100)) [void]$html.Append(@" <div class="score-panel"> <div class="score-ring"> <svg viewBox="0 0 120 120" width="120" height="120"> <circle cx="60" cy="60" r="50" fill="none" stroke="var(--g-surface-alt)" stroke-width="10"/> <circle cx="60" cy="60" r="50" fill="none" stroke="$scoreColor" stroke-width="10" stroke-dasharray="$circumference" stroke-dashoffset="$dashoffset" stroke-linecap="round"/> </svg> <div class="value">$OverallScore</div> </div> <div class="score-detail"> <div class="label" style="color:$scoreColor">$(& $esc $displayLabel)</div> <div class="desc">$(& $t 'gws.postureDesc')</div> <div class="desc">$(& $tr 'common.checksSummary' $totalChecks $passCount $failCount $warnCount $skipCount)</div> </div> </div> "@) # ═══ WHAT CHANGED SINCE LAST RUN — shared section, before findings ═══ [void]$html.Append((Get-GuerrillaComparisonSectionHtml -RunDiff $RunDiff -Esc $esc -Language $Language)) # ═══ EXECUTIVE SUMMARY ═══ $summaryVerdict = if ($critCount -gt 0) { & $t 'gws.verdictCrit' $critCount } elseif ($highCount -gt 0) { & $t 'gws.verdictHigh' $highCount } elseif ($medCount -gt 0) { & $t 'gws.verdictMed' $medCount } elseif ($failCount -gt 0) { & $t 'gws.verdictLow' $lowCount } else { & $t 'gws.verdictPass' } $noticeClass = if ($critCount -gt 0) { 'notice-bad' } elseif ($highCount -gt 0 -or $medCount -gt 0) { 'notice-warn' } else { 'notice-ok' } [void]$html.Append(@" <div class="notice $noticeClass"> <h3>$(& $t 'common.executiveSummary')</h3> <p><strong>$(& $t 'gws.assessmentLabel')</strong> $summaryVerdict</p> <p><strong>$(& $t 'gws.scopeLabel')</strong> $(& $tr 'gws.scope' $totalChecks $CategoryScores.Count)</p> <p><strong>$(& $t 'gws.resultsLabel')</strong> $(& $tr 'gws.results' $passCount $failCount $warnCount $skipCount)</p> "@) if ($critCount -gt 0) { [void]$html.Append("<p style=`"color:var(--g-bad)`"><strong>$(& $t 'gws.criticalRequire' $critCount)</strong></p>") } [void]$html.Append('</div>') # ═══ STAT CARDS ═══ [void]$html.Append('<div class="stat-grid">') $statCards = @( @{ Value = $totalChecks; Label = (& $t 'common.totalChecks'); Color = 'var(--g-heading)' } @{ Value = $passCount; Label = (& $t 'common.passed'); Color = 'var(--g-ok)' } @{ Value = $failCount; Label = (& $t 'common.failed'); Color = 'var(--g-bad)' } @{ Value = $warnCount; Label = (& $t 'common.warnings'); Color = 'var(--g-warn)' } @{ Value = $skipCount; Label = (& $t 'common.skipped'); Color = 'var(--g-muted)' } @{ Value = $critCount; Label = (& $t 'common.critical'); Color = 'var(--g-sev-critical)' } @{ Value = $highCount; Label = (& $t 'common.high'); Color = 'var(--g-sev-high)' } @{ Value = $medCount; Label = (& $t 'common.medium'); Color = 'var(--g-sev-medium)' } @{ Value = $lowCount; Label = (& $t 'common.low'); Color = 'var(--g-sev-low)' } ) foreach ($card in $statCards) { [void]$html.Append(@" <div class="stat"> <span class="value" style="color:$($card.Color)">$($card.Value)</span> <span class="label">$($card.Label)</span> </div> "@) } [void]$html.Append('</div>') # ═══ SECURITY MATURITY + INDICATORS OF EXPOSURE — shared sections ═══ [void]$html.Append((Get-GuerrillaMaturitySectionHtml -Findings $Findings -Esc $esc -Language $Language)) [void]$html.Append((Get-GuerrillaIndicatorsOfExposureHtml -Findings $Findings -Esc $esc -Language $Language)) # ═══ CATEGORY SCORES ═══ [void]$html.Append("<h2>$(& $t 'common.categoryScores')</h2><div class=`"category-grid`">") foreach ($cat in ($CategoryScores.GetEnumerator() | Sort-Object { $_.Value.Score })) { $catScore = $cat.Value.Score $catColor = Get-GuerrillaScoreColorVar -Score $catScore $catLabel = & $esc (Get-GuerrillaLocalizedCategoryName -Name "$($cat.Key)" -Language $Language) [void]$html.Append(@" <div class="cat-card"> <div class="cat-header"> <div class="cat-name">$catLabel</div> <div class="cat-score" style="color:$catColor">$catScore</div> </div> <div class="cat-bar-bg"><div class="cat-bar-fill" style="width:${catScore}%;background:$catColor"></div></div> <div class="cat-counts"> <span class="verdict-pass">$(& $t 'common.passLabel') $($cat.Value.Pass)</span> <span class="verdict-fail">$(& $t 'common.failLabel') $($cat.Value.Fail)</span> <span class="verdict-warn">$(& $t 'common.warnLabel') $($cat.Value.Warn)</span> </div> </div> "@) } [void]$html.Append('</div>') # ═══ INTERACTIVE FILTER BAR (live status/severity/search over the findings tables below) ═══ [void]$html.Append((Get-GuerrillaFindingsFilterHtml -Language $Language)) # ═══ CRITICAL & HIGH FINDINGS TABLE ═══ $priorityFindings = @($failFindings | Where-Object { $_.Severity -in @('Critical', 'High') } | Sort-Object { if ($_.Severity -eq 'Critical') { 0 } else { 1 } }, CheckId) if ($priorityFindings.Count -gt 0) { [void]$html.Append(@" <h2>$(& $tr 'gws.priorityCritHigh')</h2> <div class="table-wrap"> <table class="priority-table"> <thead><tr><th>$(& $t 'common.thCheckId')</th><th>$(& $t 'common.thCheckName')</th><th>$(& $t 'common.thCategory')</th><th>$(& $t 'common.thSeverity')</th><th>$(& $t 'common.thCurrentValue')</th><th>$(& $t 'common.thRemediation')</th></tr></thead> <tbody> "@) foreach ($f in $priorityFindings) { $sevClass = $f.Severity.ToLower() $rowText = & $esc (("$($f.CheckId) $($f.CheckName) $($f.Category) $($f.CurrentValue)").ToLower()) $catLabel = & $esc (Get-GuerrillaLocalizedCategoryName -Name "$($f.Category)" -Language $Language) $remParts = [System.Collections.Generic.List[string]]::new() if ($f.RemediationUrl) { $remParts.Add("<a href=`"$(& $esc $f.RemediationUrl)`" target=`"_blank`" rel=`"noopener`">$(& $t 'gws.fixInConsole')</a>") } if ($f.ReferenceUrl) { $remParts.Add("<a href=`"$(& $esc $f.ReferenceUrl)`" target=`"_blank`" rel=`"noopener`">$(& $tr 'gws.whyUnsafe')</a>") } $remLink = if ($remParts.Count -gt 0) { $remParts -join '<br>' } else { '' } [void]$html.Append(@" <tr class="gg-row" data-status="$(& $esc $f.Status)" data-sev="$(& $esc $f.Severity)" data-text="$rowText"> <td><code>$(& $esc $f.CheckId)</code></td> <td>$(& $esc $f.CheckName)</td> <td>$catLabel</td> <td><span class="badge badge-sev-$sevClass">$(& $esc $f.Severity)</span></td> <td>$(& $esc $f.CurrentValue)</td> <td>$remLink</td> </tr> "@) } [void]$html.Append('</tbody></table></div>') } # ═══ PER-CATEGORY DETAIL SECTIONS ═══ [void]$html.Append("<h2>$(& $t 'common.detailedByCategory')</h2>") $categories = $Findings | Group-Object -Property Category | Sort-Object Name foreach ($catGroup in $categories) { $catName = $catGroup.Name $catFindings = @($catGroup.Group | Sort-Object { switch ($_.Severity) { 'Critical' { 0 } 'High' { 1 } 'Medium' { 2 } 'Low' { 3 } default { 4 } } }, CheckId) $catPass = @($catFindings | Where-Object Status -eq 'PASS').Count $catFail = @($catFindings | Where-Object Status -eq 'FAIL').Count $catWarn = @($catFindings | Where-Object Status -eq 'WARN').Count $catHasFailures = $catFail -gt 0 $openAttr = if ($catHasFailures) { ' open' } else { '' } $catInfo = $CategoryScores[$catName] $catScoreStr = if ($catInfo) { "$(& $t 'common.thScore'): $($catInfo.Score)/100 · " } else { '' } $catLabel = & $esc (Get-GuerrillaLocalizedCategoryName -Name "$catName" -Language $Language) [void]$html.Append(@" <details class="cat-detail"$openAttr> <summary>$catLabel<span class="sum-counts">$catScoreStr$(& $tr 'common.summaryCounts' $catFindings.Count $catPass $catFail $catWarn)</span></summary> <div class="detail-body"> <table> <thead><tr><th>$(& $t 'common.thCheckId')</th><th>$(& $t 'common.thName')</th><th>$(& $t 'common.thSeverity')</th><th>$(& $t 'common.thStatus')</th><th>$(& $t 'common.thCurrentValue')</th><th>$(& $t 'common.thRecommendedValue')</th><th>$(& $t 'common.thRemediationSteps')</th></tr></thead> <tbody> "@) foreach ($f in $catFindings) { $isAccepted = try { Test-RiskAccepted -CheckId $f.CheckId } catch { $false } $statusClass = if ($isAccepted) { 'accepted' } else { $f.Status.ToLower() } $statusLabel = if ($isAccepted) { & $t 'common.accepted' } else { & $esc $f.Status } $sevClass = $f.Severity.ToLower() $rowText = & $esc (("$($f.CheckId) $($f.CheckName) $($f.Category) $($f.CurrentValue)").ToLower()) $remedSteps = if ($f.RemediationSteps) { "<div class=`"remediation-cell`">$(& $esc $f.RemediationSteps)</div>" } else { '' } [void]$html.Append(@" <tr class="gg-row" data-status="$(& $esc $f.Status)" data-sev="$(& $esc $f.Severity)" data-text="$rowText"> <td><code>$(& $esc $f.CheckId)</code></td> <td>$(& $esc $f.CheckName)</td> <td><span class="badge badge-sev-$sevClass">$(& $esc $f.Severity)</span></td> <td><span class="badge badge-status-$statusClass">$statusLabel</span></td> <td>$(& $esc $f.CurrentValue)</td> <td>$(& $esc $f.RecommendedValue)</td> <td>$remedSteps</td> </tr> "@) # --- Extra row: affected accounts + why-unsafe article + admin console deep-link --- $affectedHtml = & $renderAffected $f.Details $linkParts = [System.Collections.Generic.List[string]]::new() if ($f.Status -in @('FAIL', 'WARN', 'ERROR')) { if ($f.ReferenceUrl) { $whyTitle = if ($f.ReferenceTitle) { & $esc $f.ReferenceTitle } else { & $tr 'gws.whyUnsafeDefault' } $linkParts.Add("<span class=`"why`"><a href=`"$(& $esc $f.ReferenceUrl)`" target=`"_blank`" rel=`"noopener`">$(& $tr 'gws.whyUnsafeArticle' $whyTitle)</a></span>") } if ($f.RemediationUrl) { $linkParts.Add("<a class=`"admin-link`" href=`"$(& $esc $f.RemediationUrl)`" target=`"_blank`" rel=`"noopener`">$(& $t 'gws.fixInConsole')</a>") } } $linksHtml = if ($linkParts.Count -gt 0) { "<div class=`"extra-links`">$($linkParts -join '')</div>" } else { '' } if ($affectedHtml -or $linksHtml) { [void]$html.Append("<tr class=`"gg-row finding-extra`" data-status=`"$(& $esc $f.Status)`" data-sev=`"$(& $esc $f.Severity)`" data-text=`"$rowText`"><td colspan=`"7`"><div class=`"extra-wrap`">$affectedHtml$linksHtml</div></td></tr>") } } [void]$html.Append('</tbody></table></div></details>') } # ═══ COMPLIANCE CROSS-REFERENCE ═══ $complianceFindings = @($failFindings | Where-Object { ($_.Compliance.NistSp80053 -and $_.Compliance.NistSp80053.Count -gt 0) -or ($_.Compliance.MitreAttack -and $_.Compliance.MitreAttack.Count -gt 0) -or ($_.Compliance.CisBenchmark -and $_.Compliance.CisBenchmark.Count -gt 0) } | Sort-Object { switch ($_.Severity) { 'Critical' { 0 } 'High' { 1 } 'Medium' { 2 } 'Low' { 3 } default { 4 } } }, CheckId) if ($complianceFindings.Count -gt 0) { [void]$html.Append(@" <h2>$(& $t 'common.complianceCrossReference')</h2> <div class="table-wrap"> <table class="compliance-table"> <thead><tr><th>$(& $t 'common.thCheckId')</th><th>$(& $t 'common.thCheckName')</th><th>$(& $t 'common.thSeverity')</th><th>$(& $t 'common.thNist')</th><th>$(& $t 'common.thMitre')</th><th>$(& $t 'common.thCisBenchmark')</th></tr></thead> <tbody> "@) foreach ($f in $complianceFindings) { $sevClass = $f.Severity.ToLower() $nistCodes = if ($f.Compliance.NistSp80053 -and $f.Compliance.NistSp80053.Count -gt 0) { ($f.Compliance.NistSp80053 | ForEach-Object { "<code>$(& $esc $_)</code>" }) -join ' ' } else { '' } $mitreCodes = if ($f.Compliance.MitreAttack -and $f.Compliance.MitreAttack.Count -gt 0) { ($f.Compliance.MitreAttack | ForEach-Object { "<code>$(& $esc $_)</code>" }) -join ' ' } else { '' } $cisCodes = if ($f.Compliance.CisBenchmark -and $f.Compliance.CisBenchmark.Count -gt 0) { ($f.Compliance.CisBenchmark | ForEach-Object { "<code>$(& $esc $_)</code>" }) -join ' ' } else { '' } [void]$html.Append(@" <tr> <td><code>$(& $esc $f.CheckId)</code></td> <td>$(& $esc $f.CheckName)</td> <td><span class="badge badge-sev-$sevClass">$(& $esc $f.Severity)</span></td> <td>$nistCodes</td> <td>$mitreCodes</td> <td>$cisCodes</td> </tr> "@) } [void]$html.Append('</tbody></table></div>') } # ═══ FOOTER + SHELL END ═══ [void]$html.Append((Get-GuerrillaReportShellEnd ` -FooterNote (& $tr 'gws.footer') ` -TimestampText $timestampStr)) [System.IO.File]::WriteAllText($FilePath, $html.ToString(), [System.Text.Encoding]::UTF8) } |