Private/Export/Export-InfiltrationReportHtml.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Export-InfiltrationReportHtml {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Result,

        [Parameter(Mandatory)]
        [string]$OutputPath,

        [PSCustomObject[]]$PreviousFindings
    )

    $esc = { param([string]$s) [System.Web.HttpUtility]::HtmlEncode($s) }

    $findings = $Result.Findings
    $score = $Result.Score
    $overallScore = $score.OverallScore
    $categoryScores = $score.CategoryScores
    $timestampStr = $Result.ScanStart.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC'

    $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

    $moduleVersion = '2.0.0'
    try {
        $manifestPath = Join-Path (Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent) 'PSGuerrilla.psd1'
        if (Test-Path $manifestPath) {
            $manifest = Import-PowerShellDataFile -Path $manifestPath -ErrorAction SilentlyContinue
            if ($manifest.ModuleVersion) { $moduleVersion = $manifest.ModuleVersion }
        }
    } catch { }

    $scoreColor = switch ($true) {
        ($overallScore -ge 90) { 'var(--sage)';        break }
        ($overallScore -ge 75) { 'var(--olive)';       break }
        ($overallScore -ge 60) { 'var(--gold)';        break }
        ($overallScore -ge 40) { 'var(--amber)';       break }
        ($overallScore -ge 20) { 'var(--deep-orange)'; break }
        default                { 'var(--dark-red)' }
    }

    $scoreLabel = Get-FortificationScoreLabel -Score $overallScore
    $scoreDash = [Math]::Round(251.2 * (1 - $overallScore / 100), 1)

    $html = [System.Text.StringBuilder]::new(65536)

    [void]$html.AppendLine('<!DOCTYPE html>')
    [void]$html.AppendLine('<html lang="en"><head><meta charset="utf-8">')
    [void]$html.AppendLine('<meta name="viewport" content="width=device-width, initial-scale=1">')
    [void]$html.AppendLine("<title>Infiltration Report - $(& $esc $Result.TenantId)</title>")
    [void]$html.AppendLine('<style>')
    [void]$html.AppendLine(':root{--bg:#1a1a17;--surface:#242420;--border:#3a3a32;--text:#c8c0a8;--olive:#8b8b3e;--sage:#6b8f6b;--amber:#d4a520;--gold:#c4a93c;--parchment:#d4c8a0;--deep-orange:#cc5500;--dark-red:#8b1a1a;--dim:#6b6b5a}')
    [void]$html.AppendLine('*{margin:0;padding:0;box-sizing:border-box}')
    [void]$html.AppendLine('body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.6;padding:2rem}')
    [void]$html.AppendLine('.container{max-width:1200px;margin:0 auto}')
    [void]$html.AppendLine('.header{text-align:center;margin-bottom:2rem;padding:2rem;background:var(--surface);border:1px solid var(--border);border-radius:8px}')
    [void]$html.AppendLine('.header h1{color:var(--olive);font-size:1.8rem;margin-bottom:.5rem}')
    [void]$html.AppendLine('.header .subtitle{color:var(--dim);font-size:1rem}')
    [void]$html.AppendLine('.header .tenant{color:var(--parchment);font-size:1.1rem;margin-top:.5rem}')
    [void]$html.AppendLine('.score-section{display:flex;align-items:center;justify-content:center;gap:2rem;margin:2rem 0}')
    [void]$html.AppendLine('.score-ring{position:relative;width:120px;height:120px}')
    [void]$html.AppendLine('.score-ring svg{transform:rotate(-90deg)}')
    [void]$html.AppendLine('.score-ring .value{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;font-weight:700}')
    [void]$html.AppendLine('.score-ring .label{position:absolute;top:70%;left:50%;transform:translate(-50%,0);font-size:.8rem;color:var(--dim)}')
    [void]$html.AppendLine('.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:1rem;margin:1.5rem 0}')
    [void]$html.AppendLine('.stat{text-align:center;padding:1rem;background:var(--surface);border:1px solid var(--border);border-radius:6px}')
    [void]$html.AppendLine('.stat .num{font-size:1.8rem;font-weight:700}.stat .lbl{color:var(--dim);font-size:.85rem}')
    [void]$html.AppendLine('.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1rem;margin:2rem 0}')
    [void]$html.AppendLine('.cat{padding:1rem;background:var(--surface);border:1px solid var(--border);border-radius:6px}')
    [void]$html.AppendLine('.cat .cat-name{color:var(--olive);font-weight:600;margin-bottom:.5rem;font-size:.9rem}')
    [void]$html.AppendLine('.cat .cat-score{font-size:1.5rem;font-weight:700}')
    [void]$html.AppendLine('.cat .cat-bar{height:6px;background:var(--border);border-radius:3px;margin:.5rem 0}')
    [void]$html.AppendLine('.cat .cat-bar-fill{height:100%;border-radius:3px;transition:width .3s}')
    [void]$html.AppendLine('.cat .cat-stats{color:var(--dim);font-size:.8rem}')
    [void]$html.AppendLine('.section{margin:2rem 0}.section h2{color:var(--olive);border-bottom:1px solid var(--border);padding-bottom:.5rem;margin-bottom:1rem}')
    [void]$html.AppendLine('.filters{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}')
    [void]$html.AppendLine('.filters button{padding:.4rem .8rem;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:4px;cursor:pointer;font-size:.85rem}')
    [void]$html.AppendLine('.filters button.active{background:var(--olive);color:var(--bg);border-color:var(--olive)}')
    [void]$html.AppendLine('table{width:100%;border-collapse:collapse;margin:1rem 0}')
    [void]$html.AppendLine('th{background:var(--surface);color:var(--olive);text-align:left;padding:.7rem;border:1px solid var(--border);font-size:.85rem}')
    [void]$html.AppendLine('td{padding:.6rem .7rem;border:1px solid var(--border);font-size:.85rem;vertical-align:top}')
    [void]$html.AppendLine('tr:nth-child(even){background:rgba(139,139,62,.05)}')
    [void]$html.AppendLine('.sev-critical{color:var(--dark-red);font-weight:700}.sev-high{color:var(--deep-orange);font-weight:600}')
    [void]$html.AppendLine('.sev-medium{color:var(--amber)}.sev-low{color:var(--sage)}.sev-info{color:var(--dim)}')
    [void]$html.AppendLine('.st-pass{color:var(--sage)}.st-fail{color:var(--deep-orange)}.st-warn{color:var(--amber)}.st-skip{color:var(--dim)}.st-accepted{color:var(--dim);font-style:italic}')
    [void]$html.AppendLine('.detail-toggle{cursor:pointer;color:var(--olive);text-decoration:underline;font-size:.85rem}')
    [void]$html.AppendLine('.detail-content{display:none;padding:.5rem;background:rgba(0,0,0,.2);border-radius:4px;margin-top:.5rem;font-size:.8rem;white-space:pre-wrap}')
    [void]$html.AppendLine('.remediation-link{color:var(--sage);text-decoration:none;font-size:.8rem}')
    [void]$html.AppendLine('.remediation-link:hover{text-decoration:underline}')
    [void]$html.AppendLine('.footer{text-align:center;color:var(--dim);margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);font-size:.85rem}')
    [void]$html.AppendLine('.delta{padding:1rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;margin:1rem 0}')
    [void]$html.AppendLine('.delta .improved{color:var(--sage)}.delta .regressed{color:var(--deep-orange)}.delta .new{color:var(--amber)}')
    [void]$html.AppendLine('</style></head><body><div class="container">')

    # Header
    [void]$html.AppendLine('<div class="header">')
    [void]$html.AppendLine('<h1>INFILTRATION REPORT</h1>')
    [void]$html.AppendLine('<div class="subtitle">Entra ID / Azure / M365 Security Audit</div>')
    [void]$html.AppendLine("<div class=`"tenant`">Tenant: $(& $esc $Result.TenantId)</div>")
    [void]$html.AppendLine("<div class=`"subtitle`">$timestampStr | PSGuerrilla v$moduleVersion</div>")
    [void]$html.AppendLine('</div>')

    # Score section
    [void]$html.AppendLine('<div class="score-section">')
    [void]$html.AppendLine('<div class="score-ring">')
    [void]$html.AppendLine('<svg width="120" height="120"><circle cx="60" cy="60" r="40" fill="none" stroke="var(--border)" stroke-width="8"/>')
    [void]$html.AppendLine("<circle cx=`"60`" cy=`"60`" r=`"40`" fill=`"none`" stroke=`"$scoreColor`" stroke-width=`"8`" stroke-dasharray=`"251.2`" stroke-dashoffset=`"$scoreDash`" stroke-linecap=`"round`"/>")
    [void]$html.AppendLine('</svg>')
    [void]$html.AppendLine("<div class=`"value`" style=`"color:$scoreColor`">$overallScore</div>")
    [void]$html.AppendLine("<div class=`"label`">$(& $esc $scoreLabel)</div>")
    [void]$html.AppendLine('</div>')

    # Summary stats
    [void]$html.AppendLine('<div class="stats">')
    [void]$html.AppendLine("<div class=`"stat`"><div class=`"num`" style=`"color:var(--parchment)`">$totalChecks</div><div class=`"lbl`">Total</div></div>")
    [void]$html.AppendLine("<div class=`"stat`"><div class=`"num`" style=`"color:var(--sage)`">$passCount</div><div class=`"lbl`">Passed</div></div>")
    [void]$html.AppendLine("<div class=`"stat`"><div class=`"num`" style=`"color:var(--deep-orange)`">$failCount</div><div class=`"lbl`">Failed</div></div>")
    [void]$html.AppendLine("<div class=`"stat`"><div class=`"num`" style=`"color:var(--amber)`">$warnCount</div><div class=`"lbl`">Warnings</div></div>")
    [void]$html.AppendLine("<div class=`"stat`"><div class=`"num`" style=`"color:var(--dim)`">$skipCount</div><div class=`"lbl`">Skipped</div></div>")
    [void]$html.AppendLine('</div></div>')

    # Severity breakdown
    if ($failCount -gt 0) {
        [void]$html.AppendLine('<div class="stats">')
        [void]$html.AppendLine("<div class=`"stat`"><div class=`"num sev-critical`">$critCount</div><div class=`"lbl`">Critical</div></div>")
        [void]$html.AppendLine("<div class=`"stat`"><div class=`"num sev-high`">$highCount</div><div class=`"lbl`">High</div></div>")
        [void]$html.AppendLine("<div class=`"stat`"><div class=`"num sev-medium`">$medCount</div><div class=`"lbl`">Medium</div></div>")
        [void]$html.AppendLine("<div class=`"stat`"><div class=`"num sev-low`">$lowCount</div><div class=`"lbl`">Low</div></div>")
        [void]$html.AppendLine('</div>')
    }

    # Category score cards
    [void]$html.AppendLine('<div class="section"><h2>Category Scores</h2><div class="cats">')
    foreach ($cat in ($categoryScores.GetEnumerator() | Sort-Object { $_.Value.Score })) {
        $catScore = $cat.Value.Score
        $catColor = switch ($true) {
            ($catScore -ge 90) { 'var(--sage)';        break }
            ($catScore -ge 75) { 'var(--olive)';       break }
            ($catScore -ge 60) { 'var(--gold)';        break }
            ($catScore -ge 40) { 'var(--amber)';       break }
            default            { 'var(--deep-orange)' }
        }
        [void]$html.AppendLine('<div class="cat">')
        [void]$html.AppendLine("<div class=`"cat-name`">$(& $esc $cat.Key)</div>")
        [void]$html.AppendLine("<div class=`"cat-score`" style=`"color:$catColor`">$catScore</div>")
        [void]$html.AppendLine("<div class=`"cat-bar`"><div class=`"cat-bar-fill`" style=`"width:${catScore}%;background:$catColor`"></div></div>")
        [void]$html.AppendLine("<div class=`"cat-stats`">Pass: $($cat.Value.Pass) | Fail: $($cat.Value.Fail) | Warn: $($cat.Value.Warn) | Skip: $($cat.Value.Skip)</div>")
        [void]$html.AppendLine('</div>')
    }
    [void]$html.AppendLine('</div></div>')

    # Delta comparison
    if ($PreviousFindings -and $PreviousFindings.Count -gt 0) {
        $prevLookup = @{}
        foreach ($pf in $PreviousFindings) {
            if ($pf.checkId) { $prevLookup[$pf.checkId] = $pf }
        }

        $improved = [System.Collections.Generic.List[string]]::new()
        $regressed = [System.Collections.Generic.List[string]]::new()
        $newChecks = [System.Collections.Generic.List[string]]::new()

        foreach ($f in $findings) {
            if ($prevLookup.ContainsKey($f.CheckId)) {
                $prev = $prevLookup[$f.CheckId]
                $prevStatus = $prev.status ?? $prev.Status
                if ($f.Status -eq 'PASS' -and $prevStatus -in @('FAIL', 'WARN')) {
                    $improved.Add("$($f.CheckId): $($f.CheckName)")
                } elseif ($f.Status -eq 'FAIL' -and $prevStatus -in @('PASS', 'WARN')) {
                    $regressed.Add("$($f.CheckId): $($f.CheckName)")
                }
            } else {
                $newChecks.Add("$($f.CheckId): $($f.CheckName)")
            }
        }

        if ($improved.Count -gt 0 -or $regressed.Count -gt 0 -or $newChecks.Count -gt 0) {
            [void]$html.AppendLine('<div class="section"><h2>Delta from Previous Scan</h2><div class="delta">')
            if ($improved.Count -gt 0) {
                [void]$html.AppendLine("<p class=`"improved`">Improved ($($improved.Count)):</p><ul>")
                foreach ($i in $improved) { [void]$html.AppendLine("<li>$(& $esc $i)</li>") }
                [void]$html.AppendLine('</ul>')
            }
            if ($regressed.Count -gt 0) {
                [void]$html.AppendLine("<p class=`"regressed`">Regressed ($($regressed.Count)):</p><ul>")
                foreach ($r in $regressed) { [void]$html.AppendLine("<li>$(& $esc $r)</li>") }
                [void]$html.AppendLine('</ul>')
            }
            if ($newChecks.Count -gt 0) {
                [void]$html.AppendLine("<p class=`"new`">New checks ($($newChecks.Count)):</p><ul>")
                foreach ($n in $newChecks | Select-Object -First 20) { [void]$html.AppendLine("<li>$(& $esc $n)</li>") }
                [void]$html.AppendLine('</ul>')
            }
            [void]$html.AppendLine('</div></div>')
        }
    }

    # Findings table
    [void]$html.AppendLine('<div class="section"><h2>All Findings</h2>')
    [void]$html.AppendLine('<div class="filters">')
    [void]$html.AppendLine('<button class="active" onclick="filterFindings(''all'')">All</button>')
    [void]$html.AppendLine('<button onclick="filterFindings(''FAIL'')">Failed</button>')
    [void]$html.AppendLine('<button onclick="filterFindings(''WARN'')">Warnings</button>')
    [void]$html.AppendLine('<button onclick="filterFindings(''PASS'')">Passed</button>')
    [void]$html.AppendLine('<button onclick="filterFindings(''SKIP'')">Skipped</button>')
    [void]$html.AppendLine('</div>')

    [void]$html.AppendLine('<table id="findings-table"><thead><tr>')
    [void]$html.AppendLine('<th>ID</th><th>Check</th><th>Category</th><th>Severity</th><th>Status</th><th>Current Value</th><th>Remediation</th>')
    [void]$html.AppendLine('</tr></thead><tbody>')

    foreach ($f in ($findings | Sort-Object -Property @{Expression = { switch ($_.Severity) { 'Critical' { 0 } 'High' { 1 } 'Medium' { 2 } 'Low' { 3 } 'Info' { 4 } default { 5 } } }}, @{Expression = { switch ($_.Status) { 'FAIL' { 0 } 'WARN' { 1 } 'PASS' { 2 } 'SKIP' { 3 } 'ERROR' { 4 } default { 5 } } }})) {
        $isAccepted = try { Test-RiskAccepted -CheckId $f.CheckId } catch { $false }
        $sevClass = "sev-$($f.Severity.ToLower())"
        $stClass = if ($isAccepted) { 'st-accepted' } else { "st-$($f.Status.ToLower())" }
        $statusLabel = if ($isAccepted) { 'ACCEPTED' } else { $f.Status }

        [void]$html.AppendLine("<tr data-status=`"$statusLabel`" data-severity=`"$($f.Severity)`">")
        [void]$html.AppendLine("<td><code>$(& $esc $f.CheckId)</code></td>")
        [void]$html.AppendLine("<td><strong>$(& $esc $f.CheckName)</strong><br><small style=`"color:var(--dim)`">$(& $esc $f.Description)</small></td>")
        [void]$html.AppendLine("<td>$(& $esc $f.Category)<br><small>$(& $esc $f.Subcategory)</small></td>")
        [void]$html.AppendLine("<td class=`"$sevClass`">$($f.Severity)</td>")
        [void]$html.AppendLine("<td class=`"$stClass`"><strong>$statusLabel</strong></td>")
        [void]$html.AppendLine("<td>$(& $esc $f.CurrentValue)</td>")

        $remCell = ''
        if ($f.RemediationSteps) {
            $remCell += & $esc $f.RemediationSteps
        }
        if ($f.RemediationUrl) {
            $remCell += "<br><a class=`"remediation-link`" href=`"$(& $esc $f.RemediationUrl)`" target=`"_blank`">Open in Admin Portal</a>"
        }
        [void]$html.AppendLine("<td>$remCell</td>")
        [void]$html.AppendLine('</tr>')
    }

    [void]$html.AppendLine('</tbody></table></div>')

    # JavaScript for filtering
    [void]$html.AppendLine('<script>')
    [void]$html.AppendLine('function filterFindings(s){')
    [void]$html.AppendLine('document.querySelectorAll(".filters button").forEach(b=>{b.classList.remove("active");if(b.textContent.toLowerCase().includes(s.toLowerCase())||s==="all"&&b.textContent==="All")b.classList.add("active")});')
    [void]$html.AppendLine('document.querySelectorAll("#findings-table tbody tr").forEach(r=>{r.style.display=s==="all"||r.dataset.status===s?"":"none"})}')
    [void]$html.AppendLine('</script>')

    # Footer
    [void]$html.AppendLine("<div class=`"footer`">Generated by PSGuerrilla v$moduleVersion | Infiltration Audit | $timestampStr<br>By Jim Tyler, Microsoft MVP | <a href=`"https://github.com/jimrtyler`">GitHub</a> | <a href=`"https://linkedin.com/in/jamestyler`">LinkedIn</a> | <a href=`"https://youtube.com/@jimrtyler`">YouTube</a></div>")
    [void]$html.AppendLine('</div></body></html>')

    $html.ToString() | Set-Content -Path $OutputPath -Encoding UTF8
}