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
}