Public/Export-TechnicalReport.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-TechnicalReport {
    <#
    .SYNOPSIS
        Generates a full technical security assessment report with remediation commands.
    .DESCRIPTION
        Produces a detailed HTML report for IT staff and security administrators. Includes
        every finding with check ID, current vs recommended values, severity, remediation
        steps, PowerShell commands, and compliance references.
    .PARAMETER Findings
        Array of audit finding objects. If not provided, reads from latest state.
    .PARAMETER OutputPath
        File path for the HTML output. Default: PSGuerrilla-Technical-Report.html
    .PARAMETER OrganizationName
        Name of the organization for the report header.
    .PARAMETER IncludePass
        Include passing checks in the report. Default: false (only FAIL/WARN).
    .EXAMPLE
        Export-TechnicalReport -OrganizationName 'Springfield USD'
    .EXAMPLE
        Export-TechnicalReport -IncludePass -OutputPath ./full-report.html
    #>

    [CmdletBinding()]
    param(
        [PSCustomObject[]]$Findings,
        [string]$OutputPath,
        [string]$OrganizationName = 'Organization',
        [switch]$IncludePass
    )

    if (-not $OutputPath) { $OutputPath = Join-Path (Get-Location) 'PSGuerrilla-Technical-Report.html' }

    $dataDir = Get-PSGuerrillaDataRoot
    if (-not $Findings -or $Findings.Count -eq 0) {
        if (Test-Path $dataDir) {
            foreach ($f in (Get-ChildItem -Path $dataDir -Filter '*.findings.json' -ErrorAction SilentlyContinue)) {
                try { $Findings += @(Get-Content $f.FullName -Raw | ConvertFrom-Json) } catch { }
            }
        }
    }

    if (-not $Findings -or $Findings.Count -eq 0) {
        Write-Warning 'No audit findings available. Run a scan first.'
        return [PSCustomObject]@{ Success = $false; Message = 'No findings'; Path = $null }
    }

    $esc = { param([string]$s) [System.Web.HttpUtility]::HtmlEncode($s) }
    $timestamp = [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')
    $html = [System.Text.StringBuilder]::new(131072)

    # Risk acceptance lookup
    $riskAcceptances = @{}
    try {
        foreach ($ra in (Get-RiskAcceptance -Status Active)) {
            $riskAcceptances[$ra.CheckId] = $ra
        }
    } catch { }

    # Stats
    $totalChecks = $Findings.Count
    $failCount = @($Findings | Where-Object Status -eq 'FAIL').Count
    $warnCount = @($Findings | Where-Object Status -eq 'WARN').Count
    $passCount = @($Findings | Where-Object Status -eq 'PASS').Count
    $critCount = @($Findings | Where-Object { $_.Status -eq 'FAIL' -and $_.Severity -eq 'Critical' }).Count

    # Category breakdown
    $categories = @($Findings | Group-Object Category | Sort-Object { @($_.Group | Where-Object Status -eq 'FAIL').Count } -Descending)

    # Filter findings for display
    $displayFindings = if ($IncludePass) { $Findings } else { @($Findings | Where-Object Status -in @('FAIL', 'WARN')) }
    $displayFindings = @($displayFindings | Sort-Object @{Expression={
        switch ($_.Severity) { 'Critical' { 0 } 'High' { 1 } 'Medium' { 2 } 'Low' { 3 } default { 4 } }
    }}, CheckId)

    [void]$html.Append(@"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Technical Security Report - $(& $esc $OrganizationName)</title>
<style>
:root { --bg:#1a1f16; --surface:#242b1e; --surface-alt:#2d3526; --border:#3d4a35; --text:#d4c9a8; --text-muted:#8a8468; --olive:#a8b58b; --amber:#d4883a; --sage:#6b9b6b; --parchment:#d4c4a0; --gold:#c9a84c; --dim:#6b6b5a; --deep-orange:#c75c2e; --dark-red:#8b2500; --critical:#c75c2e; --high:#d4883a; --medium:#c9a84c; --low:#6b9b6b; }
body { font-family:'Segoe UI',Tahoma,sans-serif; background:var(--bg); color:var(--text); margin:0; padding:20px; }
.container { max-width:1000px; margin:0 auto; }
h1 { color:var(--olive); border-bottom:2px solid var(--border); padding-bottom:10px; }
h2 { color:var(--olive); margin-top:30px; }
h3 { color:var(--gold); }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:15px 0; }
.stat { background:var(--surface); border:1px solid var(--border); border-radius:6px; padding:12px; text-align:center; }
.stat .val { font-size:1.5em; font-weight:bold; }
.stat .lbl { color:var(--text-muted); font-size:0.8em; }
table { width:100%; border-collapse:collapse; margin:10px 0; }
th { background:var(--surface-alt); color:var(--olive); padding:8px 10px; text-align:left; font-size:0.85em; }
td { padding:6px 10px; border-bottom:1px solid var(--border); font-size:0.85em; }
.finding { background:var(--surface); border:1px solid var(--border); border-radius:6px; margin:12px 0; overflow:hidden; }
.finding-header { padding:12px 15px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; }
.finding-body { padding:12px 15px; }
.finding-body dt { color:var(--olive); font-weight:bold; margin-top:8px; font-size:0.85em; }
.finding-body dd { margin:2px 0 8px 0; }
.sev-badge { padding:2px 8px; border-radius:3px; font-size:0.8em; font-weight:bold; }
.sev-Critical { background:var(--dark-red); color:#fff; }
.sev-High { background:var(--deep-orange); color:#fff; }
.sev-Medium { background:var(--gold); color:var(--bg); }
.sev-Low { background:var(--sage); color:var(--bg); }
.status-FAIL { color:var(--deep-orange); font-weight:bold; }
.status-WARN { color:var(--gold); }
.status-PASS { color:var(--sage); }
.status-ACCEPTED { color:var(--dim); font-style:italic; }
code { background:var(--surface-alt); padding:2px 6px; border-radius:3px; font-size:0.9em; }
pre { background:var(--surface-alt); padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em; }
.footer { color:var(--dim); font-size:0.8em; margin-top:40px; border-top:1px solid var(--border); padding-top:10px; }
@media print { body { background:#fff; color:#333; } :root { --bg:#fff; --surface:#f9f9f9; --surface-alt:#eee; --border:#ccc; --text:#333; --text-muted:#666; --olive:#5a6b3a; --sage:#3a7a3a; --gold:#8a7a2a; --amber:#aa6a1a; --deep-orange:#aa3a0a; --dark-red:#7a1a00; --dim:#999; } .finding { page-break-inside:avoid; } }
</style>
</head>
<body>
<div class="container">
<h1>Technical Security Assessment Report</h1>
<p>$(& $esc $OrganizationName) | $timestamp UTC</p>
 
<div class="stats">
<div class="stat"><div class="val">$totalChecks</div><div class="lbl">Total Checks</div></div>
<div class="stat"><div class="val" style="color:var(--sage);">$passCount</div><div class="lbl">Pass</div></div>
<div class="stat"><div class="val" style="color:var(--deep-orange);">$failCount</div><div class="lbl">Fail</div></div>
<div class="stat"><div class="val" style="color:var(--gold);">$warnCount</div><div class="lbl">Warn</div></div>
<div class="stat"><div class="val" style="color:var(--dark-red);">$critCount</div><div class="lbl">Critical</div></div>
</div>
 
<h2>Category Breakdown</h2>
<table>
<tr><th>Category</th><th>Pass</th><th>Fail</th><th>Warn</th><th>Total</th></tr>
"@
)

    foreach ($cat in $categories) {
        $cp = @($cat.Group | Where-Object Status -eq 'PASS').Count
        $cf = @($cat.Group | Where-Object Status -eq 'FAIL').Count
        $cw = @($cat.Group | Where-Object Status -eq 'WARN').Count
        [void]$html.Append("<tr><td>$(& $esc $cat.Name)</td><td style='color:var(--sage)'>$cp</td><td style='color:var(--deep-orange)'>$cf</td><td style='color:var(--gold)'>$cw</td><td>$($cat.Count)</td></tr>`n")
    }

    [void]$html.Append(@"
</table>
 
<h2>Detailed Findings ($($displayFindings.Count))</h2>
"@
)

    foreach ($finding in $displayFindings) {
        $checkId = $finding.CheckId ?? $finding.Id ?? 'N/A'
        $name = & $esc ($finding.Name ?? $finding.CheckName ?? $checkId)
        $sev = $finding.Severity ?? 'Medium'
        $status = $finding.Status ?? 'FAIL'

        # Check risk acceptance
        $isAccepted = $riskAcceptances.ContainsKey($checkId)
        $statusDisplay = if ($isAccepted) { 'ACCEPTED' } else { $status }
        $statusClass = "status-$statusDisplay"

        [void]$html.Append(@"
<div class="finding">
<div class="finding-header">
<div><strong>$checkId</strong> — $name</div>
<div><span class="sev-badge sev-$sev">$sev</span> <span class="$statusClass">$statusDisplay</span></div>
</div>
<div class="finding-body">
<dl>
$(if ($finding.Description) { "<dt>Description</dt><dd>$(& $esc $finding.Description)</dd>" })
$(if ($finding.RecommendedValue) { "<dt>Recommended</dt><dd>$(& $esc $finding.RecommendedValue)</dd>" })
$(if ($finding.RemediationSteps) { "<dt>Remediation Steps</dt><dd>$(& $esc $finding.RemediationSteps)</dd>" })
$(if ($finding.RemediationUrl) { "<dt>Reference</dt><dd><a href='$(& $esc $finding.RemediationUrl)' style='color:var(--olive)'>$(& $esc $finding.RemediationUrl)</a></dd>" })
$(if ($isAccepted) { "<dt>Risk Acceptance</dt><dd style='color:var(--dim);font-style:italic;'>Accepted by $($riskAcceptances[$checkId].AcceptedBy) — $($riskAcceptances[$checkId].Justification)</dd>" })
$(if ($finding.Compliance) {
    $compHtml = '<dt>Compliance</dt><dd>'
    if ($finding.Compliance.nistSp80053) { $compHtml += "NIST: $($finding.Compliance.nistSp80053 -join ', ') | " }
    if ($finding.Compliance.mitreAttack) { $compHtml += "MITRE: $($finding.Compliance.mitreAttack -join ', ') | " }
    if ($finding.Compliance.cisBenchmark) { $compHtml += "CIS: $($finding.Compliance.cisBenchmark -join ', ')" }
    $compHtml += '</dd>'
    $compHtml
})
</dl>
</div>
</div>
"@
)
    }

    [void]$html.Append(@"
<div class="footer">
<p>Generated by PSGuerrilla v2.1.0 | $timestamp UTC</p>
</div>
</div>
</body>
</html>
"@
)

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

    return [PSCustomObject]@{
        PSTypeName = 'PSGuerrilla.TechnicalReport'
        Success    = $true
        Path       = (Resolve-Path $OutputPath).Path
        Message    = "Technical report exported to $OutputPath"
        FindingsCount = $displayFindings.Count
    }
}