Private/FleetReport.ps1
|
function Assert-HLFleetResult { [CmdletBinding()] param( [AllowNull()] [object]$FleetResult ) if ($null -eq $FleetResult) { throw 'The fleet result is null. Provide the object returned by Invoke-HardeningLensFleet or a fleet-result JSON file.' } foreach ($required in @('schemaVersion', 'run', 'summary', 'hosts')) { if (-not (Test-HLProperty -InputObject $FleetResult -Name $required)) { throw "The fleet result does not contain the required property '$required'." } } if ([string]$FleetResult.schemaVersion -ne '1.1') { throw "Unsupported fleet result schema version '$($FleetResult.schemaVersion)'. Supported: 1.1." } foreach ($required in @('id', 'startedAt', 'completedAt', 'baselineSelection')) { if (-not (Test-HLProperty -InputObject $FleetResult.run -Name $required)) { throw "The fleet result run block does not contain the required property '$required'." } } $hostEntries = @($FleetResult.hosts) if ($hostEntries.Count -eq 0) { throw 'The fleet result does not contain any host outcome.' } foreach ($hostEntry in $hostEntries) { foreach ($required in @('requestedComputerName', 'status')) { if (-not (Test-HLProperty -InputObject $hostEntry -Name $required)) { throw "A fleet host outcome does not contain the required property '$required'." } } if ([string]$hostEntry.status -notin @('Succeeded', 'Failed')) { throw "Unsupported fleet host status '$($hostEntry.status)'." } } } function Get-HLFleetReportScript { [CmdletBinding()] param() return @' (function(){const search=document.getElementById('hostSearch'),status=document.getElementById('hostStatusFilter'),rows=[...document.querySelectorAll('#hostRows tr')],count=document.getElementById('hostVisibleCount');function filter(){const q=search.value.trim().toLowerCase();let visible=0;rows.forEach(r=>{const ok=(!q||r.dataset.search.includes(q))&&(!status.value||r.dataset.status===status.value);r.classList.toggle('hidden',!ok);if(ok)visible++;});count.textContent=visible+' of '+rows.length+' hosts';}search.addEventListener('input',filter);status.addEventListener('change',filter);filter();})(); '@ } function Get-HLFleetTopFinding { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$HostEntries, [int]$Top = 15 ) $succeeded = @($HostEntries | Where-Object { [string]$_.status -eq 'Succeeded' -and $null -ne $_.assessment }) $byControl = @{} foreach ($hostEntry in $succeeded) { foreach ($result in @($hostEntry.assessment.results)) { if ([string]$result.status -notin @('Fail', 'Warning', 'Error', 'Excepted')) { continue } $controlId = [string]$result.controlId if (-not $byControl.ContainsKey($controlId)) { $byControl[$controlId] = [pscustomobject][ordered]@{ ControlId = $controlId Title = [string]$result.title Category = [string]$result.category Severity = [string]$result.severity Fail = 0 Warning = 0 Error = 0 Excepted = 0 Affected = 0 } } $entry = $byControl[$controlId] $status = [string]$result.status $entry.$status = [int]$entry.$status + 1 $entry.Affected = [int]$entry.Affected + 1 } } return @($byControl.Values | Sort-Object @{ Expression = { Get-HLSeverityRank -Severity ([string]$_.Severity) }; Descending = $true }, @{ Expression = { [int]$_.Affected }; Descending = $true }, ControlId | Select-Object -First $Top) } function ConvertTo-HLFleetHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$FleetResult ) $builder = New-Object Text.StringBuilder $reportScript = Get-HLFleetReportScript $reportScriptHash = Get-HLInlineScriptHash -Script $reportScript $run = $FleetResult.run $summary = $FleetResult.summary $hostEntries = @($FleetResult.hosts) $succeededHosts = @($hostEntries | Where-Object { [string]$_.status -eq 'Succeeded' }) $averageScore = if ((Test-HLProperty -InputObject $summary -Name 'averageHardeningScore') -and $null -ne $summary.averageHardeningScore) { '{0:N1}%' -f [double]$summary.averageHardeningScore } else { 'n/a' } $averageCoverage = if ((Test-HLProperty -InputObject $summary -Name 'averageEvidenceCoverage') -and $null -ne $summary.averageEvidenceCoverage) { '{0:N1}%' -f [double]$summary.averageEvidenceCoverage } else { 'n/a' } $totalFail = 0 $totalError = 0 foreach ($hostEntry in $succeededHosts) { if ($null -ne $hostEntry.assessment) { $totalFail += [int]$hostEntry.assessment.summary.Fail $totalError += [int]$hostEntry.assessment.summary.Error } } $started = try { ([datetime]$run.startedAt).ToString('yyyy-MM-dd HH:mm:ss K') } catch { [string]$run.startedAt } $completed = try { ([datetime]$run.completedAt).ToString('yyyy-MM-dd HH:mm:ss K') } catch { [string]$run.completedAt } $comparison = if ((Test-HLProperty -InputObject $FleetResult -Name 'comparison') -and $null -ne $FleetResult.comparison) { $FleetResult.comparison } else { $null } [void]$builder.AppendLine('<!doctype html>') [void]$builder.AppendLine('<html lang="en"><head><meta charset="utf-8">') [void]$builder.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">') [void]$builder.AppendLine(('<meta http-equiv="Content-Security-Policy" content="default-src ''none''; style-src ''unsafe-inline''; script-src ''sha256-{0}''; img-src data:; base-uri ''none''; form-action ''none''">' -f (ConvertTo-HLHtmlEncoded $reportScriptHash))) [void]$builder.AppendLine('<title>Hardening Lens Fleet Report</title>') [void]$builder.AppendLine(@' <style> :root{--bg:#07110d;--panel:#0c1b14;--line:#244434;--text:#e7f3ec;--muted:#9eb7aa;--accent:#55e69d;--pass:#3ed598;--fail:#ff6b6b;--warn:#ffc857;--except:#b892ff;--unknown:#7ea7c9;--error:#ff8f5a;--na:#798b82} *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -10%,#123c28 0,transparent 32%),var(--bg);color:var(--text);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif} a{color:var(--accent)}code,pre,.mono{font-family:"Cascadia Code","SFMono-Regular",Consolas,monospace}.wrap{max-width:1500px;margin:0 auto;padding:32px}.hero{border:1px solid var(--line);background:linear-gradient(135deg,rgba(85,230,157,.09),rgba(12,27,20,.96));border-radius:18px;padding:28px;box-shadow:0 24px 80px rgba(0,0,0,.3)}.eyebrow{color:var(--accent);letter-spacing:.16em;text-transform:uppercase;font:700 12px/1.4 monospace}.hero h1{font-size:clamp(30px,5vw,52px);margin:7px 0 8px}.subtitle{color:var(--muted);margin:0}.meta-grid,.metric-grid{display:grid;gap:14px}.meta-grid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-top:22px}.metric-grid{grid-template-columns:repeat(auto-fit,minmax(135px,1fr));margin:22px 0}.card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:16px}.metric .value{font:800 27px/1.1 monospace}.metric .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-top:6px}.score{color:var(--accent)}.section{margin-top:28px}.section h2{font-size:22px;margin:0 0 12px}.toolbar{display:flex;gap:10px;flex-wrap:wrap;padding:14px;border:1px solid var(--line);background:var(--panel);border-radius:14px;margin-bottom:12px}.toolbar input,.toolbar select{background:#07110d;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px 11px;min-width:180px}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:var(--panel)}table{width:100%;border-collapse:collapse;min-width:980px}th,td{padding:12px 13px;text-align:left;vertical-align:top;border-bottom:1px solid rgba(36,68,52,.72)}th{position:sticky;top:0;background:#0e2017;color:#bcd1c5;font-size:12px;text-transform:uppercase;letter-spacing:.06em;z-index:1}tr:hover td{background:rgba(85,230,157,.03)}.badge{display:inline-block;border:1px solid currentColor;border-radius:999px;padding:2px 8px;font:700 11px/1.5 monospace;text-transform:uppercase}.badge.pass,.badge.succeeded{color:var(--pass)}.badge.fail,.badge.failed{color:var(--fail)}.badge.warning{color:var(--warn)}.badge.excepted{color:var(--except)}.badge.unknown{color:var(--unknown)}.badge.error{color:var(--error)}.badge.na{color:var(--na)}.severity{font-weight:700}.sev-critical{color:#ff5b77}.sev-high{color:#ff8f5a}.sev-medium{color:#ffc857}.sev-low{color:#8cb9dc}.sev-informational{color:var(--muted)}.small{font-size:12px;color:var(--muted)}.kv{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:7px 15px}.kv div:nth-child(odd){color:var(--muted)}.method{color:var(--muted)}.hidden{display:none!important}.footer{color:var(--muted);text-align:center;margin:28px 0 8px;font-size:12px}@media(max-width:700px){.wrap{padding:16px}.hero{padding:20px}.kv{grid-template-columns:1fr}} @media print{body{background:#fff;color:#111}.wrap{max-width:none;padding:0}.hero,.card,.toolbar,.table-wrap{background:#fff;border-color:#bbb;box-shadow:none}.toolbar{display:none}a{color:#111}.small,.method,.subtitle,.metric .label{color:#444}th{position:static;background:#eee;color:#111}.badge{border-color:#555;color:#111!important}} </style> '@) [void]$builder.AppendLine('</head><body><main class="wrap">') [void]$builder.AppendLine('<section class="hero">') [void]$builder.AppendLine('<div class="eyebrow">xGreeny / Windows Security Engineering</div>') [void]$builder.AppendLine('<h1>Hardening Lens Fleet</h1>') [void]$builder.AppendLine(('<p class="subtitle">Aggregated read-only posture assessment for <strong>{0}</strong> host(s) against <strong>{1}</strong>.</p>' -f (ConvertTo-HLHtmlEncoded $summary.requestedCount),(ConvertTo-HLHtmlEncoded $run.baselineSelection))) [void]$builder.AppendLine('<div class="meta-grid">') $metaItems = New-Object System.Collections.Generic.List[object] foreach ($pair in @( @('Run ID', $run.id), @('Started', $started), @('Completed', $completed), @('Baseline selection', $run.baselineSelection), @('Module version', $run.moduleVersion), @('Result schema', $FleetResult.schemaVersion), @('Redacted', $run.redacted) )) { $metaItems.Add($pair) } if ($null -ne $comparison) { $metaItems.Add(@('Compared against run', ('{0} ({1} host(s) compared)' -f [string]$comparison.previousRunId, $comparison.comparedHostCount))) } foreach ($meta in $metaItems) { [void]$builder.AppendLine(('<div class="card"><div class="small">{0}</div><div class="mono">{1}</div></div>' -f (ConvertTo-HLHtmlEncoded $meta[0]),(ConvertTo-HLHtmlEncoded $meta[1]))) } [void]$builder.AppendLine('</div></section>') [void]$builder.AppendLine('<section class="metric-grid">') foreach ($metric in @( @('Hosts requested', $summary.requestedCount, ''), @('Succeeded', $summary.succeededCount, 'pass'), @('Failed collections', $summary.failedCount, 'fail'), @('Average score', $averageScore, 'score'), @('Average coverage', $averageCoverage, ''), @('Failing controls', $totalFail, 'fail'), @('Collection errors', $totalError, 'error') )) { [void]$builder.AppendLine(('<div class="card metric"><div class="value {2}">{1}</div><div class="label">{0}</div></div>' -f (ConvertTo-HLHtmlEncoded $metric[0]),(ConvertTo-HLHtmlEncoded $metric[1]),(ConvertTo-HLHtmlEncoded $metric[2]))) } [void]$builder.AppendLine('</section>') [void]$builder.AppendLine('<section class="section"><h2>Hosts</h2>') [void]$builder.AppendLine('<div class="toolbar"><input id="hostSearch" type="search" placeholder="Search host..." aria-label="Search hosts"><select id="hostStatusFilter"><option value="">All outcomes</option><option>Succeeded</option><option>Failed</option></select><span id="hostVisibleCount" class="small"></span></div>') $deltaHeader = if ($null -ne $comparison) { '<th>Score delta</th>' } else { '' } [void]$builder.AppendLine(('<div class="table-wrap"><table><thead><tr><th>Host</th><th>Outcome</th><th>Score</th>{0}<th>Coverage</th><th>Pass</th><th>Fail</th><th>Warning</th><th>Excepted</th><th>Unknown</th><th>Error</th><th>Highest open severity</th><th>Detail</th></tr></thead><tbody id="hostRows">' -f $deltaHeader)) foreach ($hostEntry in $hostEntries) { $hostName = [string]$hostEntry.requestedComputerName $status = [string]$hostEntry.status $statusClass = if ($status -eq 'Succeeded') { 'succeeded' } else { 'failed' } $searchText = ("$hostName $status").ToLowerInvariant() $deltaCell = '' if ($null -ne $comparison) { $driftValue = if ((Test-HLProperty -InputObject $hostEntry -Name 'drift') -and $null -ne $hostEntry.drift -and $null -ne $hostEntry.drift.scoreDelta) { '{0:+0.0;-0.0;0.0}' -f [double]$hostEntry.drift.scoreDelta } else { 'n/a' } $deltaCell = '<td class="mono">{0}</td>' -f (ConvertTo-HLHtmlEncoded $driftValue) } if ($status -eq 'Succeeded' -and $null -ne $hostEntry.assessment) { $hostSummary = $hostEntry.assessment.summary $hostScore = if ($null -ne $hostSummary.HardeningScore) { '{0:N1}%' -f [double]$hostSummary.HardeningScore } else { 'n/a' } $hostCoverage = if ($null -ne $hostSummary.EvidenceCoverage) { '{0:N1}%' -f [double]$hostSummary.EvidenceCoverage } else { 'n/a' } $detail = 'Baseline {0} {1}' -f [string]$hostEntry.assessment.baseline.name, [string]$hostEntry.assessment.baseline.version [void]$builder.AppendLine((('<tr data-status="{0}" data-search="{1}"><td><strong class="mono">{2}</strong></td><td><span class="badge {3}">{0}</span></td><td class="mono">{4}</td>' + $deltaCell + '<td class="mono">{5}</td><td>{6}</td><td>{7}</td><td>{8}</td><td>{9}</td><td>{10}</td><td>{11}</td><td class="severity {12}">{13}</td><td class="small">{14}</td></tr>') -f (ConvertTo-HLHtmlEncoded $status),(ConvertTo-HLHtmlEncoded $searchText),(ConvertTo-HLHtmlEncoded $hostName),$statusClass,(ConvertTo-HLHtmlEncoded $hostScore),(ConvertTo-HLHtmlEncoded $hostCoverage),(ConvertTo-HLHtmlEncoded $hostSummary.Pass),(ConvertTo-HLHtmlEncoded $hostSummary.Fail),(ConvertTo-HLHtmlEncoded $hostSummary.Warning),(ConvertTo-HLHtmlEncoded $hostSummary.Excepted),(ConvertTo-HLHtmlEncoded $hostSummary.Unknown),(ConvertTo-HLHtmlEncoded $hostSummary.Error),(Get-HLSeverityClass -Severity ([string]$hostSummary.HighestOpenSeverity)),(ConvertTo-HLHtmlEncoded $hostSummary.HighestOpenSeverity),(ConvertTo-HLHtmlEncoded $detail))) } else { $errorText = if ((Test-HLProperty -InputObject $hostEntry -Name 'error') -and $null -ne $hostEntry.error) { if (Test-HLProperty -InputObject $hostEntry.error -Name 'message') { [string]$hostEntry.error.message } else { ConvertTo-HLDisplayString -Value $hostEntry.error } } else { 'No detail recorded.' } [void]$builder.AppendLine((('<tr data-status="{0}" data-search="{1}"><td><strong class="mono">{2}</strong></td><td><span class="badge {3}">{0}</span></td><td class="mono">n/a</td>' + $deltaCell + '<td class="mono">n/a</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td class="small">{4}</td></tr>') -f (ConvertTo-HLHtmlEncoded $status),(ConvertTo-HLHtmlEncoded $searchText),(ConvertTo-HLHtmlEncoded $hostName),$statusClass,(ConvertTo-HLHtmlEncoded $errorText))) } } [void]$builder.AppendLine('</tbody></table></div></section>') $topFindings = @(Get-HLFleetTopFinding -HostEntries $hostEntries) [void]$builder.AppendLine('<section class="section"><h2>Most affected controls</h2>') if ($topFindings.Count -eq 0) { [void]$builder.AppendLine('<div class="card">No failing, warning, error, or excepted controls were recorded on any successfully assessed host.</div>') } else { [void]$builder.AppendLine('<div class="table-wrap"><table><thead><tr><th>Control</th><th>Severity</th><th>Category</th><th>Affected hosts</th><th>Fail</th><th>Warning</th><th>Error</th><th>Excepted</th></tr></thead><tbody>') foreach ($finding in $topFindings) { [void]$builder.AppendLine(('<tr><td><strong class="mono">{0}</strong><br>{1}</td><td class="severity {2}">{3}</td><td>{4}</td><td class="mono">{5} / {6}</td><td>{7}</td><td>{8}</td><td>{9}</td><td>{10}</td></tr>' -f (ConvertTo-HLHtmlEncoded $finding.ControlId),(ConvertTo-HLHtmlEncoded $finding.Title),(Get-HLSeverityClass -Severity ([string]$finding.Severity)),(ConvertTo-HLHtmlEncoded $finding.Severity),(ConvertTo-HLHtmlEncoded $finding.Category),(ConvertTo-HLHtmlEncoded $finding.Affected),(ConvertTo-HLHtmlEncoded $succeededHosts.Count),(ConvertTo-HLHtmlEncoded $finding.Fail),(ConvertTo-HLHtmlEncoded $finding.Warning),(ConvertTo-HLHtmlEncoded $finding.Error),(ConvertTo-HLHtmlEncoded $finding.Excepted))) } [void]$builder.AppendLine('</tbody></table></div>') } [void]$builder.AppendLine('</section>') [void]$builder.AppendLine('<section class="section card"><h2>Assessment model</h2>') [void]$builder.AppendLine('<p class="method">Per-host scores are severity-weighted pass percentages; the fleet averages are unweighted means across successfully assessed hosts. Failed collections carry no posture data and are reported separately.</p>') [void]$builder.AppendLine('<p class="method">Hardening Lens is a read-only technical posture assessment. It does not change devices, prove policy intent, replace risk assessment, or certify compliance with Microsoft, CIS, NIST, or another framework.</p>') [void]$builder.AppendLine('</section>') [void]$builder.AppendLine(('<div class="footer mono">hardening-lens / xGreeny | fleet result schema {0}</div>' -f (ConvertTo-HLHtmlEncoded $FleetResult.schemaVersion))) [void]$builder.Append('<script>') [void]$builder.Append($reportScript) [void]$builder.AppendLine('</script>') [void]$builder.AppendLine('</main></body></html>') return $builder.ToString() } |