public/Export-TechnicalReport.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-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: Guerrilla-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, [ValidateSet('Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')] [string]$Style = 'Auto', [string]$Language = '' ) if (-not $OutputPath) { $OutputPath = Join-Path (Get-Location) 'Guerrilla-Technical-Report.html' } $dataDir = Get-GuerrillaDataRoot 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 } } if (-not $Language) { $Language = Resolve-GuerrillaReportLanguage -Configured '' } $esc = { param([string]$s) [System.Web.HttpUtility]::HtmlEncode($s) } $t = Get-GuerrillaReportStringResolver -Language $Language $tr = Get-GuerrillaReportStringResolver -Language $Language -Raw $Findings = Get-GuerrillaLocalizedFindings -Findings $Findings -Language $Language $timestampStr = [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' $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) $extraCss = @' .finding { background: var(--g-surface); border-radius: var(--radius); margin: 1rem 0; overflow: hidden; } .finding-header { padding: 0.9rem 1.4rem; border-bottom: 1px solid var(--g-border); display: flex; justify-content: space-between; align-items: center; gap: 0.8rem; flex-wrap: wrap; } .finding-header .badges { display: flex; gap: 0.4rem; flex-wrap: wrap; } .finding-body { padding: 0.9rem 1.4rem 1.1rem; } .finding-body dl { margin: 0; } .finding-body dt { color: var(--g-heading); font-weight: 600; margin-top: 0.7em; font-size: 0.85rem; } .finding-body dd { margin: 0.15em 0 0.5em 0; font-size: 0.95rem; word-break: break-word; } .finding-accepted { color: var(--g-muted); font-style: italic; } @media print { .finding { break-inside: avoid; border: 1px solid var(--g-border); } } '@ $subtitle = "$(& $esc $OrganizationName) · $(& $t 'common.generated'): $timestampStr" [void]$html.Append((Get-GuerrillaReportShellStart ` -Title (& $tr 'technical.title') ` -Subtitle $subtitle ` -HtmlTitle "$(& $tr 'technical.htmlTitle') - $OrganizationName" ` -TopbarMeta (& $tr 'technical.topbar') ` -Style $Style -Language $Language -ExtraCss $extraCss)) [void]$html.Append(@" <div class="stat-grid"> <div class="stat"><span class="value">$totalChecks</span><span class="label">$(& $t 'common.totalChecks')</span></div> <div class="stat"><span class="value" style="color:var(--g-ok)">$passCount</span><span class="label">$(& $t 'common.pass')</span></div> <div class="stat"><span class="value" style="color:var(--g-bad)">$failCount</span><span class="label">$(& $t 'common.fail')</span></div> <div class="stat"><span class="value" style="color:var(--g-warn)">$warnCount</span><span class="label">$(& $t 'common.warn')</span></div> <div class="stat"><span class="value" style="color:var(--g-sev-critical)">$critCount</span><span class="label">$(& $t 'common.critical')</span></div> </div> <h2>$(& $t 'common.categoryBreakdown')</h2> <div class="table-wrap"> <table> <thead><tr><th>$(& $t 'common.thCategory')</th><th>$(& $t 'common.pass')</th><th>$(& $t 'common.fail')</th><th>$(& $t 'common.warn')</th><th>$(& $t 'technical.thTotal')</th></tr></thead> <tbody> "@) 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 $catLabel = & $esc (Get-GuerrillaLocalizedCategoryName -Name "$($cat.Name)" -Language $Language) [void]$html.Append("<tr><td>$catLabel</td><td><span class='verdict-pass'>$cp</span></td><td><span class='verdict-fail'>$cf</span></td><td><span class='verdict-warn'>$cw</span></td><td>$($cat.Count)</td></tr>`n") } [void]$html.Append('</tbody></table></div>') # Security Maturity + Attack Paths (shared sections). Maturity spans all checks; the attack-path # section only renders when AD attack-path findings are present (-OmitIfAbsent). [void]$html.Append((Get-GuerrillaMaturitySectionHtml -Findings $Findings -Esc $esc -Language $Language)) [void]$html.Append((Get-GuerrillaIndicatorsOfExposureHtml -Findings $Findings -Esc $esc -Language $Language)) [void]$html.Append((Get-GuerrillaCartographyHtml -Findings $Findings -Esc $esc -Language $Language)) [void]$html.Append((Get-GuerrillaAttackPathSectionHtml -Findings $Findings -Esc $esc -OmitIfAbsent -Language $Language)) [void]$html.Append(@" <h2>$(& $tr 'technical.detailedFindings' $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) { & $t 'common.accepted' } else { & $esc "$status" } $sevClass = ("$sev").ToLower() $statusClass = if ($isAccepted) { 'accepted' } else { ("$status").ToLower() } [void]$html.Append(@" <div class="finding"> <div class="finding-header"> <div><strong>$(& $esc $checkId)</strong> · $name</div> <div class="badges"><span class="badge badge-sev-$sevClass">$(& $esc "$sev")</span> <span class="badge badge-status-$statusClass">$statusDisplay</span></div> </div> <div class="finding-body"> <dl> $(if ($finding.Description) { "<dt>$(& $t 'technical.descriptionLabel')</dt><dd>$(& $esc $finding.Description)</dd>" }) $(if ($finding.RecommendedValue) { "<dt>$(& $t 'technical.recommendedLabel')</dt><dd>$(& $esc $finding.RecommendedValue)</dd>" }) $(if ($finding.RemediationSteps) { "<dt>$(& $t 'technical.remediationStepsLabel')</dt><dd>$(& $esc $finding.RemediationSteps)</dd>" }) $(if ($finding.RemediationUrl) { "<dt>$(& $t 'technical.referenceLabel')</dt><dd><a href='$(& $esc $finding.RemediationUrl)'>$(& $esc $finding.RemediationUrl)</a></dd>" }) $(if ($isAccepted) { "<dt>$(& $t 'technical.riskAcceptanceLabel')</dt><dd class='finding-accepted'>$(& $tr 'technical.acceptedBy' (& $esc $riskAcceptances[$checkId].AcceptedBy) (& $esc $riskAcceptances[$checkId].Justification))</dd>" }) $(if ($finding.Compliance) { $compHtml = "<dt>$(& $t 'technical.complianceLabel')</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((Get-GuerrillaReportShellEnd ` -FooterNote (& $tr 'technical.footer') ` -TimestampText $timestampStr)) $html.ToString() | Set-Content -Path $OutputPath -Encoding UTF8 return [PSCustomObject]@{ PSTypeName = 'Guerrilla.TechnicalReport' Success = $true Path = (Resolve-Path $OutputPath).Path Message = "Technical report exported to $OutputPath" FindingsCount = $displayFindings.Count } } |