Private/Report.ps1
|
# The Report: the document the Technician reads after a Run. # # Findings, Sections and Provenance in, markup out. Nothing here talks to the Target # Machine or decides anything, which is why it can be tested: ordering, encoding, Hint # suppression and the Provenance statement are the things that break quietly. $script:ReportCss = @' <style> body{font-family:Segoe UI,Arial,sans-serif;font-size:13px;margin:0 24px 24px;color:#222} .brand{background:#14161a;color:#e8e8e8;margin:0 -24px 18px;padding:14px 24px;display:flex;align-items:baseline;gap:14px} .brand .logo{font-family:Consolas,'Cascadia Mono',monospace;font-size:22px;font-weight:bold;color:#3fd0c9;letter-spacing:.5px} .brand .logo:before{content:'> ';color:#777} .brand .tag{color:#9aa0a6;font-style:italic} .brand .ver{margin-left:auto;color:#777;font-family:Consolas,monospace} footer{margin-top:30px;color:#999;font-size:11px} h1{font-size:20px} h2{font-size:16px;margin-top:28px} h3{font-size:14px;margin-top:22px} small{color:#888;font-weight:normal} table{border-collapse:collapse;width:100%;margin-top:6px} th,td{border:1px solid #ccc;padding:4px 7px;text-align:left;vertical-align:top} th{background:#eee} .FAIL{background:#fde0e0} .WARN{background:#fff4d0} .OK{background:#e9f7e9} .INFO{background:#f6f6f6} pre{background:#f6f6f6;padding:8px;overflow:auto} .sum b{font-size:15px;margin-right:18px} .provenance{color:#555} </style> '@ function ConvertTo-HtmlText { [CmdletBinding()] param([AllowNull()][AllowEmptyString()]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } function Sort-Finding { <# .SYNOPSIS Findings worst first, then grouped by Category. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()]$Finding) # $script:SeverityValues is ordered worst first; its index is the sort key. $order = @{} for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i } @($Finding | Where-Object { $_ }) | Sort-Object ` @{ Expression = { $order[[string]$_.Severity] } }, @{ Expression = { [string]$_.Category } }, @{ Expression = { [string]$_.Check } } } function ConvertTo-Report { <# .SYNOPSIS Renders a Run as the HTML document a Technician reads. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][AllowEmptyCollection()]$Section, [Parameter(Mandatory)]$Provenance, [Parameter(Mandatory)][string]$ComputerName, [datetime]$AsOf = (Get-Date) ) $sorted = @(Sort-Finding -Finding $Finding) $failed = @($sorted | Where-Object { $_.Severity -eq 'FAIL' }).Count $warned = @($sorted | Where-Object { $_.Severity -eq 'WARN' }).Count $rows = foreach ($f in $sorted) { # An OK Finding has nothing for a Technician to act on, so it shows no Hint. The # constructor already drops it, but Findings merged from the Elevated Part arrive # over CliXML without passing through it. $hint = '' if ($f.Severity -ne 'OK') { $hint = ConvertTo-HtmlText $f.Hint } "<tr class='{0}'><td><b>{0}</b></td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td><td>{5}</td></tr>" -f ` $f.Severity, (ConvertTo-HtmlText $f.Category), (ConvertTo-HtmlText $f.Check), (ConvertTo-HtmlText $f.Value), $hint, (ConvertTo-HtmlText $f.Privilege) } $sectionHtml = foreach ($s in @($Section | Where-Object { $_ })) { ConvertTo-SectionHtml -Section $s } # Not $provenance: PowerShell variable names are case-insensitive, so that would # overwrite the $Provenance parameter and leave every later read of it empty. $machine = ConvertTo-HtmlText $ComputerName $provenanceText = ConvertTo-HtmlText (Get-ProvenanceStatement -Provenance $Provenance -AsOf $AsOf) $moduleVersion = ConvertTo-HtmlText $Provenance.ModuleVersion # Read before the template rather than inside it: an expression inside a here-string # that throws leaves a half-rendered Report, and Get-Text throws on a missing key. $title = ConvertTo-HtmlText (Get-Text 'Report.Title' $ComputerName) $tagline = ConvertTo-HtmlText (Get-Text 'Report.Tagline') $generated = ConvertTo-HtmlText (Get-Text 'Report.Generated' ` $AsOf.ToString('yyyy-MM-dd HH:mm') $PSVersionTable.PSVersion) $headingFindings = ConvertTo-HtmlText (Get-Text 'Report.Heading.Findings') $headingEvidence = ConvertTo-HtmlText (Get-Text 'Report.Heading.Evidence') $colSeverity = ConvertTo-HtmlText (Get-Text 'Report.Column.Severity') $colCategory = ConvertTo-HtmlText (Get-Text 'Report.Column.Category') $colCheck = ConvertTo-HtmlText (Get-Text 'Report.Column.Check') $colValue = ConvertTo-HtmlText (Get-Text 'Report.Column.Value') $colHint = ConvertTo-HtmlText (Get-Text 'Report.Column.Hint') $colPrivilege = ConvertTo-HtmlText (Get-Text 'Report.Column.Privilege') @" <!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>$title</title>$script:ReportCss</head><body> <div class="brand"><span class="logo">gutcheck</span><span class="tag">$tagline</span><span class="ver">v$moduleVersion</span></div> <h1>$machine</h1> <p>$generated</p> <p class="provenance">$provenanceText</p> <p class="sum"><b style="color:#b00">FAIL: $failed</b><b style="color:#a70">WARN: $warned</b></p> <h2>$headingFindings</h2> <table><tr><th>$colSeverity</th><th>$colCategory</th><th>$colCheck</th><th>$colValue</th><th>$colHint</th><th>$colPrivilege</th></tr> $($rows -join "`n") </table> <h2>$headingEvidence</h2> $($sectionHtml -join "`n") <footer>$provenanceText</footer> </body></html> "@ } function ConvertTo-SectionHtml { [CmdletBinding()] param([Parameter(Mandatory)]$Section) if ($Section.Text) { $body = '<pre>' + (ConvertTo-HtmlText ([string]$Section.Text).Trim()) + '</pre>' } else { $body = (@($Section.Row) | ConvertTo-Html -Fragment) -join "`n" } "<h3>{0} <small>[{1}]</small></h3>`n{2}" -f ` (ConvertTo-HtmlText $Section.Title), (ConvertTo-HtmlText $Section.Privilege), $body } function Write-Report { <# .SYNOPSIS Writes a Run's Report and its CSV companions, and returns the Report's path. .DESCRIPTION The CSVs are semicolon-delimited because Technicians open them in German Excel. The events CSV is written even when a Run found no events: a missing file and a clean machine must not look the same on disk. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][AllowEmptyCollection()]$Section, [AllowNull()][AllowEmptyCollection()]$EventLogEntry, [Parameter(Mandatory)][string]$OutputPath, [Parameter(Mandatory)]$Provenance, [Parameter(Mandatory)][string]$ComputerName, [datetime]$AsOf = (Get-Date) ) if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $reportPath = Join-Path $OutputPath 'report.html' ConvertTo-Report -Finding $Finding -Section $Section -Provenance $Provenance ` -ComputerName $ComputerName -AsOf $AsOf | Out-File -FilePath $reportPath -Encoding utf8 @(Sort-Finding -Finding $Finding) | Select-Object Severity, Category, Check, Value, Hint, Privilege | Export-Csv -Path (Join-Path $OutputPath 'findings.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';' $events = @($EventLogEntry | Where-Object { $_ }) if (-not $events.Count) { # Export-Csv writes nothing at all for an empty collection, which would leave the # Technician unable to tell "no events" from "the Run never got that far". 'Time;Category;Provider;Id;Message' | Out-File -FilePath (Join-Path $OutputPath 'events.csv') -Encoding utf8 } else { $events | Sort-Object Time -Descending | Select-Object Time, Category, Provider, Id, Message | Export-Csv -Path (Join-Path $OutputPath 'events.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';' } $reportPath } |